The Ops Community ⚙️

Arseny Zinchenko
Arseny Zinchenko

Posted on • Originally published at rtfm.co.ua on

Python: introduction to @decorators using FastAPI as an example

The last time I used decorators in Python was about 10 years ago, in Python 2. I want to refresh my memory a bit because now I’ve started using them quite actively. So, I want to see how it works under the hood and what it is in general.

The post turned out a bit… weird? Because the first half is in the style of “we have one apple, and we add another one to it,” and the second half is about some integrals. But anyway, personally, I got a picture in my head, an understanding, so let’s just say it’s like this.

So, in short, a Python decorator is simply a function that takes another function as arguments and “adds” some new functionality to it.

First, let’s make our own decorator, see how this whole thing looks like in operating system memory, and then will use the FastAPI and how routers are added there via the app.get("/path") decorator.

At the end, there will be some useful links where the theory of functions and decorators in Python is discussed in more detail, but here is a purely practical part.

Content

  • A simple example of a Python decorator
  • How do decorators work?
  • A real example from FastAPI
  • How does FastAPI get() work?
  • Useful links

A simple example of a Python decorator

Let’s describe the function that will be our decorator and our “working” function:

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    print("I'm sothing")
    # execute the function, passed in the argument
    func()
  # return the "featured" functionality
  return do_domething

# just a common function 
def just_a_func():
  print("Just a text")

# run it
just_a_func()
Enter fullscreen mode Exit fullscreen mode

Here, the decorator() function takes any other function as an argument, and just_a_func() is our "main" function that does some actions for us:

$ ./example_decorators.py 
Just a text
Enter fullscreen mode Exit fullscreen mode

Now we can do the following trick: create a variable $decorated which will be a reference to the decorator(), pass our just_a_func() as an argument to the decorator(), and call the $decorated as a function:

...
# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
# call the function from the 'decorated' object
decorated()
Enter fullscreen mode Exit fullscreen mode

The result — we will have executed the “internal” function do_domething(), because it is in the return of the decorator() function, and the function just_a_func() which we passed in the arguments - because decorator.do_domething() has its call:

$ ./example_decorators.py 
I'm sothing 
Just a text
Enter fullscreen mode Exit fullscreen mode

And now, instead of creating a variable and assigning the decorator() function with an argument to it, we can do the same thing, but by calling the decorator as @decorator before our working function:

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    print("I'm sothing")
    # execute the function, passed in the argument
    func()
  # return the "featured" functionality
  return do_domething

# just a common function 
#def just_a_func():
# print("Just a text")

# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
#decorated = decorator(just_a_func)
# call the function from the 'decorated' object
#decorated()

@decorator
def just_a_func():
  print("Just a text")

just_a_func()
Enter fullscreen mode Exit fullscreen mode

And we will get the same result:

$ ./example_decorators.py 
I'm sothing 
Just a text
Enter fullscreen mode Exit fullscreen mode

How do decorators work?

Do you know why infrastructure is easier than programming? Because when working with servers, networking and clusters, we have some conventionally physical objects that we can touch with our hands and see with our eyes. In programming, you have to keep it all in your head. But there is a very effective life hack: just look at the process memory map.

Let’s take a look at what happens “under the hood” when we use decorators:

  • def decorator(func): an object of the decorator() function is created in memory
  • def just_a_func(): similarly, an object is created for the just_a_func() function
  • decorated = decorator(just_a_func): a third object is created - the decorated variable:
  • decorated contains a reference to the decorator() function
  • an argument to decorator() passes a reference to the address where just_a_func() is located
  • decorator() creates a new object - do_domething(), because it is in the return of the decorator()
  • do_domething() performs some additional actions and calls the function that is passed to the func

As a result, when you call the decorated as a function (that is, with the ()), the do_domething() function will be executed, and then the function that was passed as an argument, because the func argument contains a reference to the just_a_func() function.

All this can be seen in the console:

>>> from example_decorators import *
>>> decorator # check the decorator() address
<function decorator at 0x7668b8eef2e0>
>>> just_a_func # check the just_a_func() address
<function just_a_func at 0x7668b8eef380>
>>> decorated # check the decorated variable address
<function decorator.<locals>.do_domething at 0x7668b8eef420>
Enter fullscreen mode Exit fullscreen mode

Since we created a reference to the decorator() function in the decorated = decorator() which returns its internal function do_domething(), then now decorated is the function decorator.do_domething().

And in the func, we will have the address of the just_a_func.

For a better understanding, let’s just look at the memory addresses with the id():

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    #print("I'm sothing")
    print(f"Address of the do_domething() function: {id(do_domething)}")
    # execute the function, passed in the argument
    func()
    print(f"Address of the 'func' argument: {id(func)}")
  # return the "featured" functionality
  return do_domething

# just a common function 
def just_a_func():
  return None
  #print("Just a text")

print(f"Address of the decorator() function object: {id(decorator)}")
print(f"Address of the just_a_func() function object (before decoration): {id(just_a_func)}")

# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
decorated()

print(f"Address of the just_a_func() function object (after decoration): {id(just_a_func)}")
print(f"Address of the 'decorated' variable: {id(decorated)}")
Enter fullscreen mode Exit fullscreen mode

Execute the script, and you’ll get the following result (of course, which other memory addresses):

$ ./example_decorators.py 
Address of the decorator() function object: 130166777561632
Address of the just_a_func() function object (before decoration): 130166777574272
Address of the do_domething() function: 130166777574432
Address of the 'func' argument: 130166777574272
Address of the just_a_func() function object (after decoration): 130166777574272
Address of the 'decorated' variable: 130166777574432
Enter fullscreen mode Exit fullscreen mode

Here:

  • decorator(): a function object at 130166777561632 (created when the program starts)
  • just_a_func(): the second function object at 130166777574272 (created when the program starts)
  • a call to decorator() in decorated() creates a do_domething() function object located at 130166777574432 (created during the decorator() execution)
  • the func argument passes the address of the just_a_func() object - 130166777574272
  • the just_a_func() function itself does not changed, and is located at the same place - 130166777574272
  • and the decorated variable now "sends" us to the do_domething() function at 130166777574432 , because decorator() returns the value of the do_domething()

A real example from FastAPI

So let’s see how this is used in real life.

For example, I came to this post because I was making new routes for the FastAPI, and I wondered how FastAPI app.get("/path") adds routes.

Let’s create a fastapi_routes.py file with two routes:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()

# main route
@app.get("/")
def home():
    return {"message": "default route"}

# new route
@app.get("/ping")
def new_route():
    return {"message": "pong"}
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • create an instance of the FastAPI() class
  • through the @app.get("/") decorator, add the launch of the home() function when calling path "/"
  • do the same for the request when calling app from path "/ping"

Install fastapi and uvicorn:

$ python3 -m venv .venv
$ . ./.venv/bin/activate
$ pip install fastapi uvicorn
Enter fullscreen mode Exit fullscreen mode

Run the script with the uvicorn:

$ uvicorn fastapi_routes:app --reload --port 8082
INFO: Will watch for changes in these directories: ['/home/setevoy/Scripts/Python/decorators']
INFO: Uvicorn running on http://127.0.0.1:8082 (Press CTRL+C to quit)
INFO: Started reloader process [2700158] using StatReload
INFO: Started server process [2700161]
INFO: Waiting for application startup.
INFO: Application startup complete.
...
Enter fullscreen mode Exit fullscreen mode

Check it:

$ curl localhost:8082/    
{"message":"default route"}

$ curl localhost:8082/ping
{"message":"pong"}
Enter fullscreen mode Exit fullscreen mode

How does FastAPI get() work?

How does it work?

The get() function itself is not a decorator, but it returns a decorator - see applications.py#L1460:

...
-> Callable[[DecoratedCallable], DecoratedCallable]:
...
  return self.router.get(...)
Enter fullscreen mode Exit fullscreen mode

Here:

  • ->: return type annotation (annotation of the type of the returned value), that is, get() returns some type of data
  • Callable[...]: returns the type Callable (function)
  • Callable[[DecoratedCallable], DecoratedCallable]: the returned function takes a DecoratedCallable type as an argument, and returns a DecoratedCallable type as well:
  • the DecoratedCallable type is described in the types.py: DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]):
  • bound=Callable indicates that the data can be only a function (callable object)
  • this function can take any arguments  —  ...,
  • and can return any data  —  Any
  • the app.get() call returns the self.router.get() method
  • and self.router.get() is the APIRouter method described in routing.py#:1366, which returns the self.api_route method
  • and the api_route() function, which is described in the same routing.py#L963, returns a function decorator(func: DecoratedCallable)
  • and the decorator() function calls the add_api_route() method described in the same routing.py#L994:
  • and add_api_route takes path as the first argument, and the second argument is the func function to be associated with this route
  • then add_api_route() returns func
  • and api_route() returns decorator()
  • router.get() returns api_route()
  • app.get() returns router.get()
...
    def api_route(
        self,
        path: str,
        ...
        ),
    ) -> Callable[[DecoratedCallable], DecoratedCallable]:
        def decorator(func: DecoratedCallable) -> DecoratedCallable:
            self.add_api_route(
                path,
                func,
                ...
            )
            return func

        return decorator
...
Enter fullscreen mode Exit fullscreen mode

We could rewrite this code as follows: leave the addition of the "/" through app.get(), and for the "/ping" do the same as we did in our first example - by creating a variable.

Just here, we need to create two objects — first for the app.get(), and then call decorator() and pass our function:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()

# main route
@app.get("/")
def home():
    return {"message": "default route"}

# new route
def new_route():
    return {"message": "pong"}

# create 'decorator' variable pointed to the app.get() function
# the 'decorator' then will return another function, the decorator() itself
decorator = app.get("/ping")

# create another variable using the decorator() returned by the get() above, and pass our function
decorated = decorator(new_route)
Enter fullscreen mode Exit fullscreen mode

The result will be similar in both cases  —  for both "/" and for the "/ping".

For clarity, let’s do it again in the console:

>>> from fastapi import FastAPI
>>> app = FastAPI()
>>> def new_route():
... return {"message": "pong"}
...     
>>> decorator = app.get("/ping")
>>> decorated = decorator(new_route)
Enter fullscreen mode Exit fullscreen mode

And let’s check the object types and memory addresses:

>>> app
<fastapi.applications.FastAPI object at 0x7381bb521940>
>>> app.get("/ping")
<function APIRouter.api_route.<locals>.decorator at 0x7381bb5a2480>
>>> decorator
<function APIRouter.api_route.<locals>.decorator at 0x7381bb5a2200>
>>> new_route
<function new_route at 0x7381bb5a22a0>
>>> decorated
<function new_route at 0x7381bb5a22a0>
Enter fullscreen mode Exit fullscreen mode

Or we can even just use the add_api_route() method directly, removing the @app.get call:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()

# main route
#@app.get("/")
def home():
    return {"message": "default route"}

app.add_api_route("/", home)

# new route 
#@app.get("/ping")
def new_route():
    return {"message": "pong"}

app.add_api_route("/ping", new_route)
Enter fullscreen mode Exit fullscreen mode

That’s all you need to know about using get() as a decorator in FastAPI.

Useful links

Originally published at RTFM: Linux, DevOps, and system administration.


Top comments (0)