How to dynamically change the signature of a function in Python

Tags:
  • Python
  • FastAPI
  • TIL

Published

Updated

Here's something I learned today when adding Redis backed caching to a FastAPI application.


In my day job, I currently work on a full-stack application with a React frontend and a FastAPI backend. I recently added Redis backed caching to some of the slower endpoints in our backend to give some relief to our database.

My first implementation was a decorator inspired by Django's per-view cache. A great approach if you ask me:

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)
def my_view(request):
    ...

Since I wanted to set the Cache-Control header to allow clients and reverse proxies to store the cached response, I needed access to the response: Response argument in the decorator. I also wanted to bypass the cache if the request's Cache-Control header is no-cache, so I needed access to the request: Request argument. In code, this is how such a decorator can be implemented with a lot of handwaving:

from fastapi import Request, Response
from functools import wraps


def cache_view():

    def decorator(view_func):

        @wraps(view_func)
        def decorated_view(*args, **kwargs):
            request: Request = kwargs["request"]
            response: Response = kwargs["response"]
            # read cache-control header in request
            # write cache-control header in response
            cached_response = cache.get()

            return cached_response

        return decorated_view

    return decorator

And this is how you would then add caching to a view:

@cache_view()
def my_view(request: Request, response: Response):
    return []

This works fine, but it bothered me that I had to include request and response in every view I wanted to add caching to even though I'm not using those parameters in the view itself. I asked about this in the FastAPI discussion board, but I did not receive a satisfying answer.

A few months have gone by, and today, when touching this part of the app again, I ran into a project that basically implements the same decorator I came up with in December 2022: fastapi-cache.

While going through the source code of fastapi-cache, I finally figured out how to have access to the request and response arguments in every view even when they are not explicitly added as parameters to the view function.

In Python it's possible to inspect and replace a function's signature dynamically at runtime using the inspect module from the standard library. This is the technique we can leverage here. You can also see it in action in the source code of fastapi-cache.

Even though we can replace a function's signature, this does not mean we can actually call the function with different arguments. You can read more about it in PEP 362

import inspect
from fastapi import Request

@cache_view()
def view_func():
    return []

# first, we get the signature of the original function
signature = inspect.signature(view_func)

# second, we store parameters and keyword only
# parameters separately so we can reconstruct
# the signature at the end
parameters = []
extra_params = []
for p in signature.parameters.values():
    if p.kind <= inspect.Parameter.KEYWORD_ONLY:
        parameters.append(p)
    else:
        extra_params.append(p)

# third, we check if the request parameter is in the
# function signature. For brevity I didn't include the
# response parameter here, but the idea is the same
request_param = None
for param in signature.parameters.values():
    if param.annotation is Request:
        request_param = param
        break

# fourth, if the request parameter is not in the function
# signature, we add it to the parameters list
if not request_param:
    parameters.append(
        inspect.Parameter(
            name="request",
            annotation=Request,
            kind=inspect.Parameter.KEYWORD_ONLY,
        ),
    )

# last we reconstruct the function signature and we
# override the original signature with the new one
parameters.extend(extra_params)
view_func.__signature__ = signature.replace(parameters=parameters)

At this point, whenever the view function is called, we have access to request and response in the cache decorator.

As mentioned above, however, remember that you cannot actually call view_func with request or response arguments. Replacing a function's signature only affects the output of inspect.signature. In code:

def function(arg):
    return arg

# pretend this function is replacing the original
# signature to include `request` and `response`
replace_signature(arg)

# this will work
function(0)

# this will raise a TypeError
function(0, request=request, response=response)