Abstracting procedures
Basic requirements
A procedure in our framework can be a module-level function or a class method. In our example above, we define a module-level function with
def hello_world(name: str) -> str:
return 'Hello World, ' + name
and make this available for the RPC.
It’s a standard python function, so, nothing special here. But there is
one important aspect: the so-called type annotation name: str
and
-> str
. This feature of the python language, introduced with python 3.5, is
crucial to the DASF messaging framework. In standard python, type annotations
have no impact during runtime. So you can call hello_world(["ok"])
and it
would run the function (and produce an error of course, as it cannot combine
"Hello World, " + ["ok"]
). Only if you would use a static type checker such
as mypy
, it would produce an error without even prior to calling the
function, as it realizes that the hello_world
function accepts a string,
and not a list. Within DASF however, we do use the type annotation
during runtime. It is an important part in any web-based framework,
that you need to verify the input. But instead of adding an additional
verification system, we use the type annotations here[1]. If you
want to understand the internals of the DASF messaging framework, you should
familiarize yourself a bit with type annotations in python.
How the abstraction works
Our framework takes standard python functions or classes and makes them available for a remote procedure call. As a web-based framework, this means that
Our framework needs to be able to deserialize JSON requests
Our framework needs to be able to validate the requests
Our framework needs to be able to serialize JSON requests
Let’s take our hello_world
function from above. If you would want to call
this function without our framework, you’d need to implement some wrapper that
deserializes the request, validates the input and serializes the output, i.e.
something like
import json
def hello_world_request_wrapper(request: str):
data = json.loads(request) # deserialize the request
assert isinstance(data, dict) # make sure we get a dictionary
assert "name" in data # check that the name is in the data
assert isinstance(data["name"], str) # make sure the name is a string
result = hello_world(name=data["name"]) # call our function
return json.dumps(result) # serialize and return the result
You can see that such kind of serialization and validation becomes quite verbose. And now imagine you have more complicated procedure calls then our very simple example. A hard-coded verification would add a lot of overhead to the code, just to make the procedures available for RPCs.
The abstraction in the DASF works differently and (usually) without additional
code. The idea is that when you specify a function such as
def hello_world(name: str) -> str:
we know what we expect. Everything is
here: The function, the arguments, the inputs, etc. So what we are doing is,
that we take pythons built-in inspect
module to analyse the function, and
docstring-parser for interpreting
the docstring of the procedure. Then we use this information and create a
subclass of pydantics BaseModel
that represents this
function (or class or method).
Note
pydantic is a python package that uses pythons type annotations for runtime validation of data. Moreover, it can be used to serialize and de-serialize input based on the annotations. You can find many examples in their docs.
We recommend that you go through the overview and familiarize yourself a bit with it before continuing.
We’ll stick now to our hello_world
function and look how this is abstracted
for the remote procedure call. Classes are handled pretty much the same, but
we’ll discuss this later in Abstracting classes for RPC.
Abstracting functions for RPC
When you call the main()
function, it loads the
procedures that you make available for the remote procedure call
(see Specifying module members). If it encounters a function, it will call
the create_model()
classmethod of the demessaging.backend.function.BackendFunction()
class.
This method creates a subclass of the
BackendFunction()
using
pydantics create_model
function
that represents our hello_world
function in DASF.
In other words, you end up with
from demessaging.backend.function import BackendFunction
HelloWorldModel = BackendFunction.create_model(hello_world)
The HelloWorldModel
class that has been created through this method is
comparable to the following class:
from demessaging.backend.function import BackendFunction
from typing import Literal
class HelloWorldModel(BackendFunction):
func_name: Literal["hello_world"] = "hello_world"
name: str
It’s a class with two attributes, func_name
and name
.
func_name
is an attribute that is always added to these kind of models.
It can take exactly one value, and this is the name of the function that it
serialized (in this case, "hello_world"
). The second attribute, name
, comes
from the function that has been called. The hello_world
function accepts
exactly one argument, the name: str:
, and so this has been added as an
attribute to our model.
The create_model()
classmethod does create another model, the return model. Our hello_world
function returns a string and also needs to be serialized, when sent to
the client stub. Our return model is a __root__ model of pydantic and
comparable to
from pydantic import BaseModel
class HelloWorldReturnModel(BaseModel):
root: str
and is accessible as HelloWorldModel.return_model
.
Calling the procedure
Our framework creates an instance of the created HelloWorldModel
from the
request. It then executes the underlying model function via
request = HelloWorldModel(name="Peter")
result_model = request() # calls the `hello_world` function. `result_model` is an instance of HelloWorldReturnModel
assert result_model.root == hello_world(name="Peter")
But the thing is, that we can now also create and return JSON from this:
request = HelloWorldModel.parse_json('{"name": "Peter"}')
result_model = request()
result_model.model_dump_json() # gives 'Hello World, Peter'
Configuring the Function Model Creation
You can also change how things are converted from the python function to the
pydantic model using the configure()
function. Passing
arguments to this function would be equivalent to setting the config
parameter to the call of
create_model()
. You can
give any keyword argument here that is available for the initialization of a
FunctionConfig
instance. You can, for instance,
add custom validators
or field_params
.
Abstracting classes for RPC
Instead of using a function as described above, one can also use a method of a python class. Here is an example:
class HelloClass:
def __init__(self, text: str ="Hello"):
self._text = text
def hello_world(self, name: str) -> str:
return self._text + " World, " + name
calling hello_world("Peter")
is equivalent to calling
HelloClass().hello_world("Peter")
. When we encounter a class in the members
that you specified for the main()
function
(see Specifying module members), then we will not use the
BackendFunction
to create a new model,
but instead we use the BackendClass
.
The create_model()
classmethod
of the BackendClass
uses the same
procedure as described in the previous section (Abstracting functions for RPC) to
abstract the __init__
method of the class. And it does an abstraction for
all the methods of the class.
The pydantic representation of our class, i.e. the HelloClassModel
in
from demessaging.backend.class_ import BackendClass
HelloClassModel = BackendClass.create_model(HelloClass)
is comparable to:
from demessaging.backend.class_ import BackendClass
from demessaging.backend.function import BackendFunction
class HelloClassHelloWorldModel(BackendFunction):
func_name: Literal["hello_world"] = "hello_world"
name: str
class HelloClassModel(BackendClass):
class_name: Literal["HelloClass"] = "HelloClass"
text: str = "Hello"
function: HelloClassHelloWorldModel
and you would create an instance of this method via
request = HelloClassModel(
text="Hello",
function={"func_name": "hello_world", "name": "Peter"}
)
Hence, you specify the method that you want to call. Running request()
now
creates an instance of HelloClass
and runs it’s hello_world
method, i.e.
response = HelloClassModel()
response.root == HelloClass(text="Hello").hello_world(name="Peter")
Configuring the Class Model Creation
You can configure classes as you can
configure functions, just decorate your
class with the configure()
function and pass the
parameters for the ClassConfig
. You can, for
instance, use the methods
parameter to specify what methods will be available
for the RPC. You can also decorate any method of the class with the
configure()
function as you do with normal functions.