Monday, June 16, 2025

Local AWS API Gateway development with Python: The initial FastAPI implementation

Based on some LinkedIn conversations prompted by my post there about the previous post on this topic, I feel like I should clarify what I'm trying to accomplish, and what I'm not trying to accomplish with this package before I go too much further.

The scenario I had in mind centers around someone who is developing an application or API that will eventually be hosted through an AWS API Gateway resource, and whose backing compute logic is provided, in the main, by Lambda Functions. That much I think was clear. Where I apparently was not clear enough was in some additional wrinkles: I'm thinking of cases where the developer of the application cannot usefully deploy an in-flight instance of their efforts as they are working on it, leaving them with no way to actually make HTTP requests to an instance of the application to test and debug as they are working on it. There could be any number of reasons why that's not possible. The ones I can think of, or that I've encountered include:

  • The process involved for deploying an in-flight instance involves something that they do not have access to — maybe they aren't actually allowed to create the AWS resources for security reasons, or the deployment process isn't geared towards allowing developer-owned copies of the application.
  • They may not have sufficient computer power/resources to run local options like AWS' sam local or LocalStack, both of which require Docker.
    (Alternately, maybe they do have the basic computing power, but those are not responsive enough to make the experience even remotely painless)
  • They may need to be able to develop and test while disconnected from the Internet.
  • Other options that allow local execution of in-progress work might add unwanted dependencies and code that shouldn't be deployed to any real environment. The best example I know of for this is the chalice package, which behaves much like FastAPI or Flask, but also implies that chalice will be used to deploy the code.

A common key point here is that deployment of the in-flight work is not practical, or even not possible. In those cases, the idea of being able to run a local application/API process while working on the code is still very desirable. That is what I'm trying to accomplish.

So, with that out of the way, and with a recent review of Python's decorators and how they actually work still fresh in my mind after the previous post, here's where I picked it back up again. First, I came to the conclusion that in order to really establish that I was doing what I wanted to, I needed a bit more variety in the application/API model that I was going to work with. I landed on having three basic object-types represented, person, place and thing, and was mainly focused on the decorators that support CRUD operations for an API. From an application-centric perspective, generating and sending web pages as responses, the Create and Read would be the only operations that would be needed. Putting all o that together, the endpoints, their operations, the relevant HTTP methods, and the

Endpoints and their Lambda Code
HTTP Method CRUD operation Endpoint Path Lambda Code Path
(examples/src/…)
POST create /person people/crud_operations.py
::create_person
/place places/crud_operations.py
::create_place
/thing things/crud_operations.py
::create_thing

GET read /person/{oid} people/crud_operations.py
::read_person
/people people/crud_operations.py
::read_people
/places/{oid} places/crud_operations.py
::read_place
/place places/crud_operations.py
::read_places
/things/{oid} things/crud_operations.py
::read_thing
/thing things/crud_operations.py
::read_things

PATCH
or
PUT
update /person/{oid} people/crud_operations.py
::update_person
/place/{oid} places/crud_operations.py
::update_place
/thing/{oid} things/crud_operations.py
::update_thing

DELETE delete /person/{oid} people/crud_operations.py
::delete_person
/place/{oid} places/crud_operations.py
::delete_place
/thing/{oid} things/crud_operations.py
::delete_thing
Lambda Function modules are in the examples/src directory of the project's repository

The individual target Lambda Function handlers in the example I used are very simple: All that I expected I needed to do, for now at least, was to be able to observe that the appropriate target function was being called when the local applicatoin endpoint was called. With that in mind, all that I really needed to do was set things up to follow my standard logging pattern, described in earlier posts here. An example of one of those functions, the read_person handler that would map locally to /v1/person/{oid}/ if the local process was successful is:

def read_person(event, context):
    # HTTP GET handler (for single person items)
    logger.info('Calling read_person')
    try:
        logger.debug(f'event ..... {event}')
        _body = event.get('body')
        body = json.loads(_body) if _body else None
        logger.debug(f'body ...... {body}')
        logger.debug(f'context ... {context}')
        result = {
            'statusCode': 200,
            'body': 'read_person completed successfully'
        }
        logger.debug(f'result .... {result}')
    except Exception as error:
        result = {
            'statusCode': 500,
            'body': f'read_person raised '
            f'{error.__class__.__name__}'
        }
        logger.exception(f'{error.__class__.__name__}: {error}')
    logger.info('read_person completed')
    logger.debug(f'<= {json.dumps(event)}')
    logger.debug(f'=> {json.dumps(result)}')
    return result

In this example, since all it's returning is a string, all I would expect back from a browser request to that endpoint on the local application would be.

read_person completed successfully

To get to that point, there are a few steps involved. In a pure FastAPI implementation, any incoming request is picked up by FastAPI's standard request-detection processes, and a Request object is created and available to read request data from, provided that it is imported. The function that actually handles a request is mapped with a decorator that is provided by a FastAPI application object. An example of a typical handler for the same /v1/person/{oid}/ endpoint might look something like this:

from fastapi import FastAPI, Request

# ...

app = FastAPI()

# ...

@app.get('/v1/person/{oid}/')
def get_person(request: Request) -> dict:
    """GET handler for person requests"""
    logger.debug(f'Calling {__name__}.get_person:')
    logger.debug(f'variables: {pformat(vars())}')
    try:
        result = {
            'statusCode': 200,
            'body': 'get_person completed successfully'
        }
    except Exception as error:
        msg = (
            f'get_person raised an exception: '
            f'{error.__class__.__name__}'
        )
        logger.exception(msg)
        result = {
            'statusCode': 200,
            'body': msg
        }
    finally:
        return result

I started my implementation using FastAPI for one very simple reason: Its decorator structure more closely mirrors the sort of resource definitions for Lambda Functions that are called by an API Gateway instance as defined in a SAM template structure. An (incomplete) example of that sort of templated function declaration might look something like this for the same /v1/person/{oid}/ that would call the read_person function shown above:

Type: AWS::Serverless::Function
Properties:

  # The (relative) path from the template to the
  # directory where the function's module can be found
  CodeUri: ../src/people

  # The namespace of the function, in the form
  # module_name.function_name
  Handler: crud_operations.read_person

  Events:
    ApiEvent:
      Type: Api
      Properties:
        Method: get
        Path: /v1/person/{oid}/
        RestApiId:
          Ref: SomeApiIdentifier

  # Other properties that might be of use later, but not today
  # Description: String
  # Environment: Environment
  # FunctionName: String
  # MemorySize: Integer
  # Timeout: Integer

This template structure provides all of the information that would be needed to set up the local route: The Handler is just an import-capable path, and the Method and Path under Events.ApiEvent.Properties indicate the HTTP method and the API path that would be used in a FastAPI decorator to define the function to be called when that method/endpoint combination receives a request.

Goal
Right now, the entire mapping process is manual, but knowing that a SAM template provides those values is a solid step towards eventually automating the process. Somewhere down the line, I plan to write a command-line tool that will read a SAM template (and maybe later a CloudFormation template), and automatically generate the relevant local API mappings.

Back to the process flow! My immediate goal, then, was to provide an override for the various FastAPI HTTP-method decorators (for example, its get decorator) that would accept a handler-function or a namespace representation of one in addition to its existing path specification, and route requests to that path to the specified handler-function. Along the way, it would need to conver the FastAPI Request object's data into a Lambda Proxy Input event and a LambdaContext object that could be passed to the target handler-function. A high-level outline of the code structure, using the get decorator again, shows the basic processes involved, and the parameters used:

def get(
        self,
    path: str,
    external_function: LambdaSpec | str,
    *args,
    **kwargs
) -> Callable:
    """
    Overrides the parent (FastAPI) decorator of the same name, to
    allow the specification of an external function that will be
    used to handle requests.

    Parameters:
    -----------
    path : str
        The path argument to be used in calling the parent class'
        method that this method overrides.
    external_function : LambdaSpec | str
        The "external" function to be wrapped and returned by the
        method
    *args : Any
        Any additional positional or listed arguments to be used
        in calling the parent class' method that this method
        overrides.
    **kwargs : Any
        Any keyword or keyword-only arguments to be used in
        calling the parent class' method that this method
        overrides.
    """
    # At this layer, we're just getting the arguments passed.
    # Resolve the external function
    if isinstance(external_function, str):
        target_function = get_external_function(
            external_function
        )
    elif callable(external_function):
        target_function = external_function
    else:
        raise TypeError()

    def _wrapper(target: Callable | None = None):
        """
        The initial function returned by the decoration process,
        which will be called by the Python runtime with the
        target function it is decorating, if used as a decorator.
        """
        # At this level, we're retrieving the target function
        # that is being decorated, if one was provided.

        # Handle async vs. sync functions based on FastAPI's
        # apparent preferences and the target function, if one
        # has been provided
        if iscoroutinefunction(target):
            async def _replacer(request: Request):
                """
                An async version of the function that will be
                returned, replacing a decorator target where
                applicable.
                """
        else:
            def _replacer(request: Request):
                """
                A sync version of the function that will be
                returned, replacing a decorator target where
                applicable.
                """

        # Call the original decorator to keep all the things
        # it does
        new_function = _FastAPI.get(
            self, path, *args, **kwargs
        )(_replacer)
        return new_function

    return _wrapper

At the outermost layer of the decorator structure, the external_function that is passed in the decorator arguments can either be a function imported earlier, or the namespace of an importable function — the same sort of string-value noted in the SAM template example shown above. The next layer in, _wrapper, is reponsible for retrieving the target function that the decorator is decorating. It also accepts a None value, for reasons that I'll dig into in more detail later. The _wrapper is responsible for defining the function that will be returned to replace the decoration target function. That function, _replacer, is created on the fly inside the decorator's closure, and will return either an async version of the function, or a normal synchronous function, depending on whether the decorator target is async or not.

Note
FastAPI looks like it prefers async functions, though it supports both sync and async. Trying to account for all the potential crossovers between sync and async functions doesn't feel necessary, since the purpose of this package is to provide a developer convenience tool that allows them to work on local Lambda Functions and execute them with local HTTP requests.

Once everything is figured out, the _replacer function is passed to the original, standard FastAPI decorator, and the response from that, new_function is returned. The complete code is far too long to reproduce here — I don't want to overload the reader with a wall of code — but any who are curious can look at it in detail in the project repository.

This post has already gotten much longer than I'd anticipated, and I have other things that I want to show, so rather than dive into the various helper functions that are called in the get decorator above, I'll refer the reader to their entries in the project's repository as well:

I also created a fairly robust, if very simple, test-harness at local/test-harness.py that exercises all of the initially-suported HTTP methods across all of the resource-types. I'll end this post with a couple of log-dumps from the current version (v.0.0.3, after making a couple of minor corrections to a few items). First, the successful routing set-up for the decorator-based FastAPI mapping example:

pipenv run uvicorn --port 5000 app:app
[INFO]  Using sync replacer for delete_person.
[INFO]  Returning _replacer at 0x106c88680 to decorate delete_person.
[INFO]  Using sync replacer for get_person.
[INFO]  Returning _replacer at 0x106c88720 to decorate get_person.
[INFO]  Using sync replacer for get_people.
[INFO]  Returning _replacer at 0x106c88a40 to decorate get_people.
[INFO]  Using sync replacer for patch_person.
[INFO]  Returning _replacer at 0x106c88d60 to decorate patch_person.
[INFO]  Using sync replacer for post_person.
[INFO]  Returning _replacer at 0x106c89080 to decorate post_person.
[INFO]  Using sync replacer for put_person.
[INFO]  Returning _replacer at 0x106c893a0 to decorate put_person.
[INFO]  Using sync replacer for delete_place.
[INFO]  Returning _replacer at 0x106c89d00 to decorate delete_place.
[INFO]  Using sync replacer for get_place.
[INFO]  Returning _replacer at 0x106c89da0 to decorate get_place.
[INFO]  Using sync replacer for get_places.
[INFO]  Returning _replacer at 0x106c8a0c0 to decorate get_places.
[INFO]  Using sync replacer for patch_place.
[INFO]  Returning _replacer at 0x106c8a3e0 to decorate patch_place.
[INFO]  Using sync replacer for post_place.
[INFO]  Returning _replacer at 0x106c8a700 to decorate post_place.
[INFO]  Using sync replacer for put_place.
[INFO]  Returning _replacer at 0x106c8aa20 to decorate put_place.
[INFO]  Using sync replacer for delete_thing.
[INFO]  Returning _replacer at 0x106c8b420 to decorate delete_thing.
[INFO]  Using sync replacer for get_thing.
[INFO]  Returning _replacer at 0x106c8b4c0 to decorate get_thing.
[INFO]  Using sync replacer for get_things.
[INFO]  Returning _replacer at 0x106c8b7e0 to decorate get_things.
[INFO]  Using sync replacer for patch_thing.
[INFO]  Returning _replacer at 0x106c8bb00 to decorate patch_thing.
[INFO]  Using sync replacer for post_thing.
[INFO]  Returning _replacer at 0x106c8be20 to decorate post_thing.
[INFO]  Using sync replacer for put_thing.
[INFO]  Returning _replacer at 0x106cb0180 to decorate put_thing.
INFO:     Started server process [5626]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:5000 (Press CTRL+C to quit)

...and the results of a request made to the /v1/people/ endpoint:

[INFO]  Created LambdaContext(
    [
        aws_request_id=99abaa85-ace8-4890-9753-8113be1c1e5a,
        log_group_name=None,
        log_stream_name=None,
        function_name=None,
        memory_limit_in_mb=None,
        function_version=None,
        invoked_function_arn=None,
        client_context=ClientContext(
            [custom=None,env=None,client=None]
        ),
        identity=CognitoIdentity(
            [
                cognito_identity_id=None
                cognito_identity_pool_id=None
            ]
        )
    ]
) with 900000 ms remaining before timeout.
[app] [INFO]  Created LambdaContext(...).
[INFO]  _request_to_lambda_signature completed
[INFO]  Calling read_people
[INFO]  read_people completed
[INFO]  Calling _request_to_lambda_signature:
[INFO]  Returning <starlette.responses.Response
           object at 0x106ce3790>
INFO:     127.0.0.1:54456 - "GET /v1/people/ HTTP/1.1" 200 OK

As a final consideration, I'd also point out that the examples/local-fastapi/app-funcs-only.py module, which defines the endpoint mappings like this, with no functions being decorated, also works:

# Endpoint-Handler Functions
app.delete(
    '/v1/person/{oid}/', 'people.crud_operations.delete_person'
)()

app.get(
    '/v1/person/{oid}/', 'people.crud_operations.read_person'
)()

app.get(
    '/v1/people/', 'people.crud_operations.read_people'
)()

app.patch(
    '/v1/person/{oid}/', 'people.crud_operations.update_person'
)()

app.post(
    '/v1/person/', 'people.crud_operations.create_person'
)()

app.put(
    '/v1/person/{oid}/', 'people.crud_operations.update_person'
)()

app.delete(
    '/v1/place/{oid}/', 'places.crud_operations.delete_place'
)()

app.get(
    '/v1/place/{oid}/', 'places.crud_operations.read_place'
)()

app.get(
    '/v1/places/', 'places.crud_operations.read_places
')()

app.patch(
    '/v1/place/{oid}/', 'places.crud_operations.update_place'
)()

app.post(
    '/v1/place/', 'places.crud_operations.create_place'
)()

app.put(
    '/v1/place/{oid}/', 'places.crud_operations.update_place'
)()

app.delete(
    '/v1/thing/{oid}/', 'things.crud_operations.delete_thing'
)()

app.get(
    '/v1/thing/{oid}/', 'things.crud_operations.read_thing'
)()

app.get(
    '/v1/things/', 'things.crud_operations.read_things'
)()

app.patch(
    '/v1/thing/{oid}/', 'things.crud_operations.update_thing'
)()

app.post(
    '/v1/thing/', 'places.crud_operations.create_place'
)()

app.put(
    '/v1/thing/{oid}/', 'things.crud_operations.update_thing'
)()

This package isn't complete by any stretch of the imagination, but it is already covering the majority of what I had in mind.

Thursday, June 12, 2025

Change to publishing schedule

I started this blog up, as I have started others before, while looking for a job, as much as anythng else to have somewhere that I could point recruiters and prospective employers at examples of how I write Python code. The fact that the projects I'm writing about are installable copies of tools and processes that I've found useful in previous jobs is a bonus. At this point, though, I'm working again, and between that and my already-existant obligations to continue with the writing on the second edition of my book, my time is going to be signficantly constrained: I simply wont have the time every day to maintain the pace of development and writing that I had when I started this blog.

I'm planning to keep this going, though: I've found that I really like having these tools that I'm packaging up available for projects, whether they are work-related or personal efforts. From this point on, I'll rite as I have time, and publish what I've written (when it's ready) on the following Monday or Thursday, and announce new posts in my usual places:

Thursday, June 5, 2025

Local AWS API Gateway development with Python: Dissecting decorators

Yes, I'm still poking around at the goblinfish.testing.pact package in the background. It's going slowly, since I've been dealing with job-hunting stuff, prepping for a contract job, and still working on the second edition of my first book. It was in the context of that last item that inspiration struck on this package project. As I'm writing this, I had just gotten to the point of starting to write about serverless application development with the services that are officially part of the collection of resources that are, presumably official AWS Serverless Application Model (SAM) services (this is based on their inclusion in the AWS SAM resources and properties documentation), and finished the content about local and server- or container-resident applications. I had focused on Flask and FastAPI in that content because those were, as noted in my previous post, the most popular options (that I saw in the wild, at least).

The inspiration that struck was that it should be possible to extend the various decorators that both of those frameworks use in order to wrap a Lambda Function's entry-point handler-function, translating the native ASGI or WSGI request-input into a Lambda Proxy Integration input, call the wrapped/decorated Lambda function code, and translate its return-value back to an ASGI- or WSGI-compatible response (assuming that the Lambda returns a Lambda Proxy Integration output).

To understand how this will work, it will probably be useful to take a look at what decorators actually do. Here's an example of a simple decorator, applied to a simple function, and called, with print and pprint calls sprinkled through the code at key points to whow what's happening:

from pprint import pprint

head_len = 56

print('-- Defining simple_decorator '.ljust(head_len, '-'))

def simple_decorator(target):
    print(f'simple_decorator({target.__name__}) called.')
    def _inner(*args, **kwargs):
        print('_inner(...) called:')
        pprint(vars())
        print(f'Calling {target.__name__} from _inner')

        # This is where the original function is called
        result = target(*args, **kwargs)

        print(f'Returning results from call to\\\n  {target}')
        return result
    print(f'Returning {_inner.__name__}.')

    # This is where the replacement function comes from
    return _inner

print(
    '-- Applying simple_decorator to simple_function'
    .ljust(head_len, '-')
)

@simple_decorator
def simple_function(arg, *args, kwdonlyarg, **kwargs):
    print('simple_function(...) called:')
    pprint(vars())
    return vars()

if __name__ == '__main__':
    print('-- Calling simple_function '.ljust(head_len, '-'))
    call_result = simple_function(
        'arg', 2, 3, 4, kwdonlyarg=True, kwarg1=object()
    )
    pprint(call_result)

When this code is executed, the output it returns is:

-- Defining simple_decorator ---------------------------
-- Applying simple_decorator to simple_function---------
simple_decorator(simple_function) called.
Returning _inner.
-- Calling simple_function -----------------------------
_inner(...) called:
{'args': ('arg', 2, 3, 4),
 'kwargs': {
     'kwarg1': <object object at 0x1022b85a0>,
     'kwdonlyarg': True
 },
 'target': <function simple_function at 0x102352480>}
Calling simple_function from _inner
simple_function(...) called:
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x1022b85a0>},
 'kwdonlyarg': True}
Returning results from call to\
  <function simple_function at 0x102352480>
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x1022b85a0>},
 'kwdonlyarg': True}

Step by step, this unfolds as:

  • The simple_decorator is defined. It doesn't actually get called just yet, so none of the print calls generate any output.
  • The simple_decorator decorator is applied to the simple_function function as part of the definition of that function.
    • The Python runtime passes the simple_function as an argument — This is an important item to keep in mind, since the target name association with simple_function remains available in the entire scope of the simple_decorator call, even inside the _inner function.
    • A new instance of the _inner function is created.
    • When that function is called, it calls the original target function.
    • The new _inner function instance is returned.
    • The Python runtime replaces the original simple_function with the _inner function returned
  • From that point on, any calls made to the original simple_function name actually call the _inner function instance that was returned during the decoration process. Whether the original simple_function actually gets called as part of the decorated-function process is determined by whether it is called in the _inner function.

This nesting of functions, including the nested call to the original target function, and the persistance of those nested functions outside the scope where they were defined is an example of a closure.

FastAPI- and Flask-application endpoint decorators also accept arguments, though. The quickstart examples for each show that for the FastAPI @app.get() and Flask @app.route() decorations:

# FastAPI bare-bones/quick-start code
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}
# Flask bare-bones/quick-start code
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "

Hello, World!

"

Decorators that accept arguments are a bit more complicated: they have three layers of functions instead of two. The outermost layer is where the arguments for the decoration are retrieved — the "/" in the @app.get("/") and @app.route("/") decorations in the quick-start examples above would be captured at this level, and available to the nested functions within it, for example. The next layer in behaves just like the outermost layer of a simple decorator, accepting the target of the decoration. The innermost layer also behaves like the innermost layer of a simple decorator, actually calling the original target function, with whatever addition al logic is needed before and after that call happens. An example decorator that accepts one argument (decorated), and printing noteworthy occurrences in the process similar to the simple example above is:

from pprint import pprint

head_len = 56

print('-- Defining outer '.ljust(head_len, '-'))

def outer(decorated):
    print(f'outer(...) called:')
    pprint(vars())
    def _middle(target):
        print(f'_middle(...) called:')
        pprint(vars())

        # The "decorated" argument passed above is still
        # available, but only appears in vars() if it is used
        print(f'decorated (from outer): {decorated}')

        def _inner(*args, **kwargs):
            print('_inner(...) called:')
            pprint(vars())

            # The "decorated" argument passed above is still
            # available but only appears in vars() if it is used
            print(f'decorated (from outer): {decorated}')

            print(f'Calling {target.__name__} from _inner')
            result = target(*args, **kwargs)
            print(
                f'Returning results from call to\\\n  {target}'
            )
            return result
        print(f'Returning {_inner.__name__}.')
        return _inner
    return _middle

print(
    '-- Applying outer to target_function'
    .ljust(head_len, '-')
)

@outer(True)
def target_function(arg, *args, kwdonlyarg, **kwargs):
    print('target_function(...) called:')
    pprint(vars())
    return vars()

if __name__ == '__main__':
    print('-- Calling target_function '.ljust(head_len, '-'))
    call_result = target_function(
        'arg', 2, 3, 4, kwdonlyarg=True, kwarg1=object()
    )
    pprint(call_result)

…and running this code results in this output:

-- Defining outer ------------------------
-- Applying outer to target_function------
outer(...) called:
{'decorated': True}
_middle(...) called:
{
    'decorated': True,
    'target': <function target_function at 0x104d6d120>
}
decorated (from outer): True
Returning _inner.
-- Calling target_function -----------------------------
_inner(...) called:
{'args': ('arg', 2, 3, 4),
 'decorated': True,
 'kwargs': {
     'kwarg1': <object object at 0x104a785a0>,
     'kwdonlyarg': True
 },
 'target': <function target_function at 0x104d6d120>}
decorated (from outer): True
Calling target_function from _inner
target_function(...) called:
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x104a785a0>},
 'kwdonlyarg': True}
Returning results from call to\
  <function target_function at 0x104d6d120>
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x104a785a0>},
 'kwdonlyarg': True}
Important
It may seem odd, but any arguments provided to a decorator will not be available within the function being decorated, even though they are demonstrably available to the inner function that is calling the target function. The target function, though it is referenced inside the scope of the decorator, is not actually in that scope, so the preservation of those names and values provided by the closure does not carry through to the target function.

It is also worth noting that because of the way decorators work, and how they are defined, it's possible to call a decorator as a function, and pass the target function as an argument to the result of the decorator function call. That is, running this code after the outer decorator is defined...

def external_function(*args, **kwargs):
    print('external_function(...) called:')
    pprint(vars())
    return vars()

local_function = outer('no-decorator')(external_function)
print('-- Calling local_function '.ljust(head_len, '-'))
call_result = local_function(
    'arg', 2, 3, 4, kwdonlyarg=True, kwarg1=object()
)
pprint(call_result)

... will yield this output (truncated to just the new items):

-- Calling local_function ------------------------------
_inner(...) called:
{'args': ('arg', 2, 3, 4),
 'decorated': 'no-decorator',
 'kwargs': {
     'kwarg1': <object object at 0x1044d85a0>,
     'kwdonlyarg': True
 },
 'target': <function external_function at 0x104572340>
}
decorated (from outer): no-decorator
Calling external_function from _inner
external_function(...) called:
{'args': ('arg', 2, 3, 4),
 'kwargs': {
     'kwarg1': <object object at 0x1044d85a0>,
     'kwdonlyarg': True
 }
}
Returning results from call to\
  <function external_function at 0x104572340>
{'args': ('arg', 2, 3, 4),
 'kwargs': {
     'kwarg1': <object object at 0x1044d85a0>,
     'kwdonlyarg': True
 }
}

Another key factor to keep in mind when working with decorators is that all they really do, when it comes right down to it, is replace the target being decorated with the callable returned by the decorator. That, in turn, means that a decorator function can, in fact, return a completely different function. That is probably not a common use case, but it is a legitimate one, and that capability opens the door for the process that I have in mind. At a high level, after doing some work with throwaway code for this project, what I see as the most likely path forward looks something like this:

  • Defining a class deriving from the FastAPI and Flask classes provided by those packages;
  • Overriding the relevant decorators (FastAPI.get and the other HTTP-verb decorator-methods FastAPI provides, and the Flask.route method), such that:
    • The override accepts a target function that can be pointed at a Lambda Handler elsewhere in a project, either by importing it and passing it, or by specifying its namespace and letting the decorator perform the import.
    • The override would create a new wrapper function.
    • The wrapper function handles converting the FastAPI or Flask request into a Lambda Handler event and context.
    • The wrapper function calls the Lambda Handler with those items.
    • The wrapper function handles converting the response from the Lambda Handler back into whatever response format is expected by FastAPI or Flask before returning it.
  • As part of that decoration process, the override would also be responsible for calling the original decorator — the function that it overrides — with all of the normal arguments that decorator expects or accepts, but pointing it at the wrapper function. This should insure that the normal behaviour expected of the FastAPI or Flask decorators is preserved with respect to registering and configuring endpoint handler functions.

With all this done, I would expect that manually generating an endpoint-to-Lambda-Function mapping would look something like this:

from goblinfish.aws.local.fastapi_apigw import FastAPI
from my_lambda_functions import root_get

app = FastAPI()

@app.get(root_get, '/')
async def root():
    # No code is actually needed here, since this would be
    # *completely replaced* by the decorator.
    ...

Ideally, I would also like to be able to support the function-call-only structure shown above, allowing the same definition above to be executed something like this:

from goblinfish.aws.local.fastapi_apigw import FastAPI
from my_lambda_functions import root_get

app = FastAPI()

def mapping_failed(*args, **kwargs):
    # This function should ALWAYS be replaced by an application
    # endpoint mapping, but in case it isn't it will raise an
    # error.
    raise Exception('Mapping failed; check your code!')

root = app.get()

@app.get(root_get, '/')(mapping_failed)

This post has gotten far longer than I had originally intended — decorators are a complex subject, and I could probably be fairly accused of rambling on about them. With all of the exploration into them that has happened here, though, I feel comfortable moving forward with a first attempt my goal. That is where I will pick up in my next post about this package.

Thursday, May 29, 2025

First thoughts on reworking managed attribute test-expectations

When last I wrote about the goblinfish-testing-pact package, I had a checklist of things that I was going to pursue:

Implement all unit tests.
Rework the property/data-descriptor detection to handle properties specifically, and other descriptors more generally:
Member properties can use isinstance(target, property), and can be checked for fget, fset and fdel members not being None.
Other descriptors can use inspect.isdatadescriptor, but are expected to always have the __get__, and at least one of the __set__ and __delete__ members shown in the descriptor protocol docs.
Set test-method expectations based on the presence of get, set and delete methods discovered using this new breakout.
Update tests as needed!
Correct maximum underscores in test-name expectations: no more than two (__init__, not ___init__).
Think about requiring an _unhappy_paths test for methods and functions that have no arguments (or none but self or cls).
Give some thought to whether to require the full set of test-methods for abstract class members, vs. just requiring a single test-method, where the assertion that the source member is abstract can be made.

I had originally intended to address these items in the order they were listed, but as I started thinking through the implications of completing the entire unit test suite first, it occurred to me that going down that path would almost certainly end up creating more work than I really needed. Specifically, while the idea of having a complete test-suite for the package before making changes had its appeal — providing a regression testing mechanism is, generally speaking, a good thing — but I was all but certain that I was going to have to completely re-think how I was dealing with detecting and analyzing @property members and custom data-descriptor members of classes. That would, I felt, put me in the position of writing a bunch of tests that were likely to go away in very short order. Writing tests is rarely a waste of time, but in this particular case, I expected that I would be spending a fair chunk of time writing them, then time revising the processes behind them, then reconciling the new code against the old tests, fully expecting that many of them would have to be rewritten to a significant degree.

The thinking behind the expectation that I would need to rewrite the property- (and property-like) test-name-expectations processes hinged on various future states that I wanted to support for the package. To start with, all I was concerned about was generating expected test-method names for class attributes that were managed using one of the built-in options for attribute management: the @property decorator, and custom solutions that used the built-in Descriptor protocol (which also include method-sets built with the @property decorator). A property is a data-descriptor, but not all data-descriptors implement the property interface. That is worth showing in some detail, so consider the following class and code:

from inspect import isdatadescriptor

class Person:
    @property
    def first_name(self):
        return self._first_name

    @first_name.setter
    def first_name(self, value):
        self._first_name = value

    @first_name.deleter
    def first_name(self):
        try:
            del self._first_name
        except:
            pass

    def __repr__(self):
        return (
            f'<{self.__class__.__name__} at {hex(id(self))} '
            f'first_name={self.first_name}>'
        )

inst = Person()
inst.first_name = 'Brian'
print(repr(inst))
print(
    '• isinstance(Person.first_name, property) '.ljust(48, '.')
    + f' {isinstance(Person.first_name, property)}'
)
print(
    '• isdatadescriptor(Person.first_name) '.ljust(48, '.')
    + f' {isdatadescriptor(Person.first_name)}'
)

If this is dropped into a Python module and executed, it will output something along the lines of:

<Person at 0x102247650 first_name=Brian>
• isinstance(Person.first_name, property) ...... True
• isdatadescriptor(Person.first_name) .......... True

This is expected behavior: The built-in property class implements the descriptor protocol mentioned earlier, which is really no more, apparently, than implementing __get__, __set__, and __delete__ methods. A property object also has fget, fset and fdel methods, which are where the property methods in the code are stored, and that are called by the __get__, __set__, and __delete__ methods of the descriptor. That is, when the @property is applied to first_name in the class above, that method is stored in the fget method of the resulting property object, and is called by the __get__ method. Similarly the @first_name.setter and @first_name.deleter decorations attach the methods they decorate to the fset and fdel methods of the property, which are called by the __set__ and __delete__ methods, respectively.

Important
All properties are data descriptors.

So, what happens if we add a data-descriptor? Here's a bare-bones implementation of one, added to the same Person class, and with updates to show the results:

from inspect import isdatadescriptor

class Descriptor:

    def __set_name__(self, owner, name):
        self.__name__ = name
        self.__private_name__ = f'_{name}'

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        try:
            return getattr(obj, self.__private_name__)
        except Exception as error:
            raise AttributeError(
                f'{obj.__class__.__name__}.{name} '
                'has not been set'
            )

    def __set__(self, obj, value):
        setattr(obj, self.__private_name__, value)

    def __delete__(self, obj):
        try:
            delattr(obj, self.__private_name__)
        except Exception as error:
            raise AttributeError(
                f'{obj.__class__.__name__}.{name} does '
                'not exist to be deleted'
            )

class Person:
    @property
    def first_name(self):
        return self._first_name
    @first_name.setter
    def first_name(self, value):
        self._first_name = value
    @first_name.deleter
    def first_name(self):
        try:
            del self._first_name
        except:
            pass

    last_name = Descriptor()

    def __repr__(self):
        return (
            f'<{self.__class__.__name__} at {hex(id(self))} '
            f'first_name={self.first_name} '
            f'last_name={self.last_name}>'
        )

inst = Person()
inst.first_name = 'Brian'
inst.last_name = 'Allbee'
print(repr(inst))
print(
    '• isinstance(Person.first_name, property) '.ljust(48, '.')
    + f' {isinstance(Person.first_name, property)}'
)
print(
    '• isdatadescriptor(Person.first_name) '.ljust(48, '.')
    + f' {isdatadescriptor(Person.first_name)}'
)

print(
    '• isinstance(Person.last_name, property) '.ljust(48, '.')
    + f' {isinstance(Person.last_name, property)}'
)
print(
    '• isdatadescriptor(Person.last_name) '.ljust(48, '.')
    + f' {isdatadescriptor(Person.last_name)}'
)

Running this updated module code results in:

<Person at 0x1009bea10 first_name=Brian last_name=Allbee>
• isinstance(Person.first_name, property) ...... True
• isdatadescriptor(Person.first_name) .......... True
• isinstance(Person.last_name, property) ....... False
• isdatadescriptor(Person.last_name) ........... True

From that output, it is apparent that:

Important
Not all data descriptors are properties

This is also expected behavior, based on the Descriptor protocol, which notes that:

Define any of these methods* and an object is considered a descriptor and can override default behavior upon being looked up as an attribute.

* __get__, __set__ and __delete__

That does mean, though, that there are two distinct interfaces to contend with just in the built-in options. The fact that those interfaces overlap was mildly annoying to me, since it meant that a check for whether a given class-member is a property has to happen before checking whether it is a non-property data-descriptor, but in the grander scheme of things, that is not really a big deal, I thought.

Earlier I mentioned that there were future states that I also wanted the package to support. One of those is Pydantic, which I find I use directly or through the Parser extra of the Lambda Powertools package. Pydantic is, at a minimum, a data validation library, that allows a developer to define structured data (as classes) with attributes (Fields) that perform runtime type- and value checking for objects. The other is the BaseModel and Field functionality provided by Django. Django is a high-level Python web framework that encourages rapid development and clean, pragmatic design, that has been around since 2010, and is a very popular framework for developing web applications using Python.

While I wasn't going to worry about the actual implementation for those just yet, I needed to have a solid idea of whether their respective Field objects followed one of the interfaces that I'd already accounted for. The short answer to that question, for both, was no, unfortunately. In Pydantic's case, the fields, defined explicitly using a full Field call as shown in the documentation, do not even exist as named members of the class. That is, given a module with:

from pydantic import BaseModel, Field

class Person(BaseModel):
    first_name: str = Field()
    last_name: str = Field()

print(Person.first_name)

…running that module raises an AttributeError:

File
  "/.../throwaways/pydantic-test/model-test.py",
  line 10, in <module>
    print(Person.first_name)
          ^^^^^^^^^^^^^^^^^
  File "/.../pydantic-test/.../site-packages/pydantic/...
    /_model_construction.py", line 271, in __getattr__
      raise AttributeError(item)
AttributeError: first_name

As it turns out, Pydantic's model fields are stored in a class attribute, __pydantic_fields__, where they are tracked as a dictionary of field-name/field-object key/value pairs. This could be verified by adding the following code to the same module:

inst = Person(first_name='Brian', last_name='Allbee')
print(repr(inst))
from pprint import pprint
pprint(Person.__pydantic_fields__)

for field_name, field in Person.__pydantic_fields__.items():
    print(
        f'isinstance(Person.{field_name}, property) '
        .ljust(48, '.') + f' {isinstance(field, property)}'
    )
    print(
        'inspect.isdatadescriptor(Person.{field_name}) '
        .ljust(48, '.') + f' {inspect.isdatadescriptor(field)}'
    )

…which yielded this output:

Person(first_name='Brian', last_name='Allbee')
Brian Allbee
{'first_name': FieldInfo(annotation=str, required=True),
 'last_name': FieldInfo(annotation=str, required=True)}
isinstance(Person.first_name, property) ........ False
inspect.isdatadescriptor(Person.{field_name}) .. False
isinstance(Person.last_name, property) ......... False
inspect.isdatadescriptor(Person.{field_name}) .. False

So, at least for the purposes of supporting test-method expectations for test suites that test Pydenatic model fields, there is yet another interface involved just finding the fields and their names. That, though, is a problem for another day, though it did show me that I would need to implement a very flexible solution.

Django's Field objects are a little easier to deal with — at a minimum, they can be referenced in a more normal fashion, for example ClassName.FieldName. The exploration code used to determine how those field object behaved was:

import inspect
import sys

from django.db import models
from django.db.models.fields import Field

class Person:
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)
    birth_date = models.DateField()

    class Meta:
        ordering = ['last_name', 'first_name']
        verbose_name = 'Person'
        verbose_name_plural = 'People'

    def __repr__(self):
        return (
            f'<{self.__class__.__name__} at {hex(id(self))} '
            f'first_name={self.first_name} '
            f'last_name={self.last_name} '
            f'birth_date={self.birth_date}>'
        )

    def __str__(self):
        return f'{self.first_name} {self.last_name}'

inst = Person()
inst.first_name='Brian'
inst.last_name='Allbee'
print(repr(inst))
print(type(Person.first_name))
print(type(Person.birth_date))
print(
    'isinstance(Person.first_name, property) '.ljust(48, '.')
    + f' {isinstance(Person.first_name, property)}'
)
print(
    'isinstance(Person.first_name, Field) '.ljust(48, '.')
    + f' {isinstance(Person.first_name, Field)}'
)
print(
    'inspect.isdatadescriptor(Person.first_name) '.ljust(48, '.')
    + f' {inspect.isdatadescriptor(Person.first_name)}'
)

When run, that generated

<Person at 0x101ddbe10
    first_name=Brian last_name=Allbee
    birth_date=<django.db.models.fields.DateField>
>
<class 'django.db.models.fields.CharField'>
<class 'django.db.models.fields.DateField'>
isinstance(Person.first_name, property) ........ False
isinstance(Person.first_name, Field) ........... True
inspect.isdatadescriptor(Person.first_name) .... False

They still are not property objects, or recognized as data-descriptors with inspect.isdatadescriptor, but could be extracted with a similar custom function that uses isinstace to check class-members against the Field base class that appears to be the lowest common denominator for all of Django's specific field classes. That should handle all the different variations like the CharField and DateField shown in the example above.

So, knowing now what I do about how the different variations of managed attributes (properties, descriptors, and model-field implementations for Pydantic and Django) behave, the goal is fairly straightforward: Figure out a way to scan all members of a target class, checking whether they fall into one of those categories, and building out an expected test-case-name set accordingly. Since Pydantic and Django aren't the only third-party packages out there that might have similar constraints, that implementation needs to be highly extensible as well — there's no telling how some other package might implement things, though I would hope that they would tend to gravitate towards using the built-in data-descriptor protocol, since it is built in. That implementation strategy will be the topic for my next post relating to this package.

Thursday, May 15, 2025

When not to mock or patch

Despite taking a break from working on the unit test stubs forthe goblinfish-testing-pact package, it hasn’t been far from my thoughts, for several reasons. I want to get this package done, first off, and I won’t consider it to be done until it has a test-suite that executes the vast majority of its code. That’s particularly true, I feel, for a package that’s intended to help make other testing packages easier to work with to achieve the kinds of test goals that it’s intended to accomplish.

Secondly, and part of the reason I want to get to that done state, I want to be able to use it in my other personal project efforts. Technically, I could proceed with those, and add PACT testing in later, but that would ultimately put me in the same kind of position with those other projects as I’m in now with this one: scrambling to get tests written or reconciled with whatever I might already have in place, rather than working those tests out as I go. I’d much prefer the latter.

The main blocker for me on getting the PACT tests written for the package that provides them was figuring out how I would (or in some cases could) test things. Writing code with an eye towards it being testable is a skill, and something of an art at times. I tried to keep that in mind, but I made a fundamental assumption in a lot of cases: That I would be able to usefully apply either a patch or some variant of a Mock to anything that I needed to. That proved not to be the case in at least one of the tests for the ExaminesModuleMembers class, where the built-in issubclass function is used to determine whether a named test-entity in the test-module is a unittest.TestCase. That proved to be problematic in two ways. The first was in figuring out how to specify a patch target name. For example, using an approach like this:

import unittest
from unittest.mock import patch

class test_SomeClass(unittest.TestCase):

    @patch('issubclass')
    def test_patch_issubclass(self, patched_issubclass):
        patched_issubclass.return_value=True
        self.assertTrue(issubclass(1, float))

…raised an error indicating that the target name was not viable:

TypeError: Need a valid target to patch.
    You supplied: 'issubclass'

Fair enough, I thought. I know that issubclass is a built-in, and after confirming that issubclass.__module__ returned a module name (builtins), I tried this:

class test_SomeClass(unittest.TestCase):

    @patch('builtins.isinstance')
    def test_patch_isinstance(self, patched_isinstance):
        patched_isinstance.return_value=True
        self.assertTrue(isinstance(1, float))

…only to be faced with this error instead:

AttributeError: 'bool' object has no attribute '_mock_name'
Note

Just to see what would happen, I tried a similar variation with the built-in isinstance function. That raised a recursion error, because one of the first things that the patch decorator does is determine whether the target specified is a string… using isinstance.

So, with those discoveries in hand, I’ve added a new item to my list of things to keep in mind when writings tests for Python code:

Tip

Don’t assume that a built-in function, one that lives in the builtins module of a Python distribution, will be patch-able.

There is at least one way around that particular challenge: wrapping the built-in function in a local function that can be patched in the test code. For example, defining a wrapper function for issubclass like this:

def _issubclass(cls, classinfo) -> bool:
    """
    Wrapper around the built-in issubclass function, to allow a
    point for patching that built-in for testing purposes.
    """
    return issubclass(cls, classinfo)

…would allow the code that checks for that subclass relationship to be re-written like this:

    ...
    # Check that it's a unittest.TestCase
    self.assertTrue(
        _issubclass(test_case, unittest.TestCase),
        f'The {test_case.__name__} class is exepcted to be a '
        'subclass of unittest.TestCase, but is not: '
        f'{test_case.__mro__}'
    )
    ...

and the related test’s code could then patch the wrapper _issubclass function, allowing that code to take control over the results wherever needed:

class ExaminesModuleMembers(HasSourceAndTestEntities):
    ...

    @patch('modules._issubclass')
    def test_source_entities_have_test_cases(self, _issubclass):
	    ...

This, and similar variations that would implement the same wrapper logic as a @classmethod or as a @staticmethod, would also be viable; only the scopes of the calls would change, really.

I’m not a big fan of this approach, in general, though I’ll use it if there are no better options. My main concern with it, trivial though it might be, is that it adds more code, and thus more tests. I’ve seen it asserted various places that “every line of code written is a liability,” and there’s a fair amount of truth to that assertion. This function, one-liner though it is, still feels like it would be cluttering things up, even if only by that handful of added lines of code.

Another option would be to re-think the implementation of the target code that the test relates to. In this particular case, there is at least one option that I could think of that would remove the need for applying a patch to the main test-method: Adding another test-method to the mix-in that simply asserts that the test-case class is a subclass of unittest.TestCase. That would be a simple test-method to add, though I’d want to keep it from being duplicated across all the different classes that it applies to. Assuming another hypothetical mix-in class built for that purpose, that new class wouldn’t have to be much more than this:

class RequiresTextCaseMixIn:
    """
    Provides a common test that asserts that a derived class
    also derives from unittest.TestCase
    """
    def test_is_test_case(self):
        self.assertTrue(
            issubclass(self.__class__, unittest.TestCase),
            f'{self.__class__.__name__} is expected to be a '
            'subclass of unittest.TestCase, but is not '
            f'({self.__class__.__mro__})'
        )

For this particular scenario, testing the PACT package, I like this better than the patched/mocked wrapper function. It doesn’t add any new (and trivial) code that adds new test requirements, for starters, and this test is quite simple. What I didn’t like about it, at least on my first consideration of it, is that it moves the checking of the test-case class’ requirements into those individual classes, instead of the testing provided by ExaminesModuleMembers. My initial thoughts about going down this path centered around being concerned that test-failures would be raised later in the current manual process than I wanted, since they would be raised as the tests for those test-cases executed. However, after thinking through it again, I decided that this was not as bad a scenario as I’d initially thought. This rearrangement was, to my thinking, acceptable.

All of the options mentioned so far are predicated on keeping with an assumption that I made early on, but that may not hold true: that using patch and/or Mock objects would be the preferred way to deal with writing tests that are, at their heart, a collection of module files. The original thought behind that was that if I didn’t need to create a fake project-structure, it would be better: there wouldn’t be an additional collection of files to manage, I wouldn’t have to contend with the additional imports, and so on. In retrospect, though, particularly as I’ve really seen just how extensive the collection of mocked/patched values would likely need to be, the more I grew to like the idea of actually having a fake project in place to test against.

My initial concerns that led me away from that decision really boiled down to one thing: A concern about how I would make sure that everything was tested, across a combination of required test-entities in the context of the package, and for a reasonably realistic project representation at the same time. Until I had all of the PACT processes implemented, I wasn’t sure that the test-code would be able to manage both without getting far more complicated than I wanted it to be. However, after seeing how the stubs of the tests worked out, I am much more confident about that concern being significant. Knowing now what I do about how the processes involved took shape, I anticipate that a simple fake project structure will actually simplify things by allowing the happy-paths tests to operate against code structures that are a reasonable and direct simulation of an actual project. I also anticipate that generating unhappy-path data using patch decorators or contexts will keep those tests more manageable.

At a high level, how I expect things to unfold as I progress with the revised tests is:

  • The actual test-entities expectations will be determined by their correlation with the relevant members of the package itself.
  • The actual test executions will use classes set up to derive from the class being tested for any given test-case class, but pointing at a fake-project entity, allowing:
    • Creation of any necessary elements in the scope of the fake project in order to test the behavior of the package source-element.
    • Isolation of the code whose behavior is being tested from the package entities themselves.

I struggled with trying to come up with a better explanation than that, but couldn’t come up with one, so an example would probably be good. Assume a module at fake-project/module.py that starts with no code in it at all, and that the test in question is for the module_members.ExaminesSourceFunction class, responsible for checking that a happy-paths test-method and one unhappy-path test for each parameter in a function exist. The expected test-methods will be defined by inspection of the ExaminesSourceFunction class, and would include five happy-paths tests, one for each instance method and property. The unhappy-path tests, initially, will follow the expectations that were in place as of the v.0.0.3 release, with tests for invalid setter values, invalid setter instances, and invalid deleter calls, but I plan to revise the expectations as noted in my earlier post.

The actual test executions, though, will use in-test classes that point at the fake-project elements that relate. Each test-case will be concerned more with making sure to minimize the number of lines of code that are not executed in the PACT source target element, and the processes for that minimization will rely on adding whatever source-entity stubs are needed to make sure that the PACT methods are thoroughly tested. Using the same ExaminesSourceFunction mix-in as an example, that would include fake-project functions that include:

  • A function with no parameters.
  • Functions with one and two required positional parameters.
  • A function with one required and one optional positional parameter.
  • A function with an *args argument-list.
  • Functions with one and two required keyword-only parameters.
  • A function with one required and one optional keyword-only parameter.
  • A function with a **kwargs keyword-arguments list.

Those function variants may be combined. The tests, in this particular case, could patch the test_entity property of the class, so that actual test-methods are not required, or those test-methods could be stubbed out in the classes defined within their tests.

Monday, May 12, 2025

Break in my cadence

Between various obligations, a (happily) high level of job-search activities late last week, and a holiday over the weekend, I have not had time to complete the writing I had planned for today. I expect that my normal Monday/Thursday posting cycle will resume on Thursday, 15-May.

Thursday, May 8, 2025

Local AWS API Gateway development with Python

I’m going to take a break from writing posts about the goblinfish-testing-pact package for a bit — The work on it is still going on in the background, but it’s going slowly because of constraints on my time (and, if I’m being honest, because I’m not looking forward to trudging through the remaining unit tests there). I needed to change things up a bit, and write about something different in order to have something to post to meet my Monday/Thursday posting plans.

What I opted to write about is the first iteration of another package that I came up with over the course of a technical challenge for a recent job prospect. I won’t go too deeply into the specifics of the challenge — the company in question might pose it to other candidates — but my solution for it got me to thinking about how, at my previous position, we handled developing APIs using AWS’ API Gateway, Lambda Functions, and the Lambda Proxy Integration between the two. We defined our infrastructure using AWS SAM, and testing it locally was not really an option without using the sam local command. By the time I was part of the team where local development and testing would have been most useful, other ways of handling it had been devised that did not involve using sam local. I wasn’t part of the discussions that led to the approach that team used, but I would guess that the decision was made to avoid using sam local because it was slow. When I looked into sam local for my own ends, it looked like it had to build and spin up local Docker containers for every Lambda for every API request, and did so even if one had already been created.

That, then, got me to thinking about how to provide a better way. Essentially, what I was aiming for was a way to set up a local API that would:

The Python ecosystem is not lacking for packages that provide locally-executable HTTP API functionality. Setting aside Django, which is more an application-development environment (though there is an add-on, the Django REST Framework that provides REST API functionality), the two that seem to be the most popular are Flask and FastAPI.

Flask

Flask is a lightweight WSGI web application framework. It is designed to make getting started quick and easy, with the ability to scale up to complex applications. It began as a simple wrapper around Werkzeug and Jinja, and has become one of the most popular Python web application frameworks.

Flask offers suggestions, but doesn’t enforce any dependencies or project layout. It is up to the developer to choose the tools and libraries they want to use. There are many extensions provided by the community that make adding new functionality easy.

FastAPI

FastAPI is a modern, fast (high-performance), web framework for building APIs with Python based on standard Python type hints.

The key features are:

  • Fast: Very high performance, on par with NodeJS and Go (thanks to Starlette and Pydantic). One of the fastest Python frameworks available.
  • Fast to code: Increase the speed to develop features by about 200% to 300%.
  • Fewer bugs: Reduce about 40% of human (developer) induced errors.
  • Intuitive: Great editor support. Completion everywhere. Less time debugging.
  • Easy: Designed to be easy to use and learn. Less time reading docs.
  • Short: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs.
  • Robust: Get production-ready code. With automatic interactive documentation.
  • Standards-based: Based on (and fully compatible with) the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema.

Both offer a fairly simple decorator-based approach to providing API endpoints: Write a function, apply the appropriate decorator, and that’s all that the function needs to handle an incoming request and return a response. Both also offer a local server, allowing someone working on the API code to run and debug it locally. Both of those local servers can also pay attention to at least some of the local project-files, allowing a change to a relevant file to restart the local server. Even in cases where a change to a Lambda Function file are not picked up automatically, restarting the local API is much faster than waiting for the sam build and sam local processes to complete, and the resolution of a local API request, assuming that it can simply call the relevant Lambda handler function, is immediate, not requiring a Docker container to spin up first.

There are trade-offs, to be sure. The SAM CLI presumably supports other Serverless Application Model resources that may not have local-API equivalents. In particular, GraphQLApi, SimpleTable and StateMachine resources, if they are needed by an application, are likely to need special handling from a local development and testing perspective. All of the other API types, though, can be represented at a very basic level, accepting requests and returning responses, and Lambda Layers are just a code-import problem to be solved. The remaining SAM resource-types I cannot speak to, having never needed to use them, and any additional resources defined in a SAM template using standard CloudFormation are almost certainly not able to be represented in a local API implementation.

For the sake of this post, I’m going to start with Flask as the API provider, not because it’s necessarily better, but because I’m more familiar with it than any of the other options. A “toy” project layout will be helpful in describing what I’m trying to accomplish:

toy-project/
├─ Pipfile
│  │  # Packages managed in categories
│  ├─ [api-person-rest]
│  │  └─ ...
│  └─ [local-api]
│     └─ Flask
├─ Pipfile.lock
├─ .env
├─ src/
│  │  # The modules that define the Lambda Handler functions
│  └─ api_person_lambdas.py
│     │  # The functions that handle {HTTP-verb} requests
│     ├─ ::get_person(event, context)
│     ├─ ::post_person(event, context)
│     ├─ ::put_person(event, context)
│     ├─ ::patch_person(event, context)
│     └─ ::delete_person(event, context)
├─ local-api/
│  └─ api_person_rest.py
│     │  # Flask() object, accepts methods (e.g., 'GET', 'POST'),
│     │  # app provides a 'route' decorator.
│     ├─ ::app
│     │  # These are decorated with app.route('path', methods=[]),
│     │  # and Flask provides a request object that may be used
│     │  # in each.
│     ├─ ::api_get_person()
│     ├─ ::api_post_person()
│     ├─ ::api_put_person()
│     ├─ ::api_patch_person()
│     └─ ::api_delete_person()
└─ tests/

In this project, the Flask application lives entirely under the local-api directory, and its api_person_rest module defines a fairly typical set of CRUD operation functions for HTTP GET, POST, PUT, PATCH and DELETE requests. Each of those functions is decorated according to Flask standards; the bare bones of the code in api_person_rest.py would start with something like this, assuming a common /person route, and no other parameters defined at this point:

from flask import Flask, request

app = Flask(__name__)

@app.route('person', methods=['GET'])
api_get_person():
    """Handles GET /person requests"""
    # Needs to call get_person(event, context)
    ...

@app.route('person', methods=['POST'])
api_post_person():
    """Handles POST /person requests"""
    # Needs to call post_person(event, context)
    ...

@app.route('person', methods=['PUT'])
api_put_person():
    """Handles PUT /person requests"""
    # Needs to call put_person(event, context)
    ...

@app.route('person', methods=['PATCH'])
api_patch_person():
    """Handles PATCH /person requests"""
    # Needs to call patch_person(event, context)
    ...

@app.route('person', methods=['DELETE'])
api_delete_person():
    """Handles DELETE /person requests"""
    # Needs to call delete_person(event, context)
    ...

When the local API is actually running, requests to any of the /person-route endpoint functions would be received based on the HTTP verb/action involved. From there, what needs to happen is a series of steps that is simple to describe, but whose implementation may be quite a bit more complex:

  • The API function needs to know to call the appropriate function from src/api_person_lambdas. For example, if a GET /person request is received by the API, the routing defined will tell the API to call the api_get_person function, and that function will need to call the api_person_lambdas::get_person function.
  • Before actually making that function-call, the incoming request needs to be converted into a Lambda Proxy Integration input data-structure. The Lambda Powertools Parser package could be installed and leveraged to provide a pre-defined data-model, complete with validation of the data types, to that end.
  • Since the Lambda handler also has a context argument, and that may or may not be used by the handler, creation of a Lambda context object; also needs to happen.
  • Once the event and context have been created, the API function can call the Lambda handler: api_person_lambdas::get_person(event, context).
  • The Lambda handler is expected, at least in this case, to return a Lambda Proxy Integration output (which may also be represented in the Lambda Power Tools models, the naming of those models isn’t clear enough to say with any certainty whether that is the case or not).
  • The response from the Lambda handler will need to be converted to a Flask Response object, possibly using the make_response helper-function that Flask provides.
  • That response will be returned through normal Flask response processes.

With those in mind, the to-do list for this package effort boils down to these items, I think:

  • Figure out how to map API (Flask) endpoint-function calls to their corresponding Lambda Function handlers.
  • Figure out how to convert an API request into a Lambda Proxy Integration event structure.
    • Take a deeper look at the Lambda Power Tools parsing extra to see if it provides both input/request and output/response models for that integration.
  • Figure out how to generate a meaningful, realistic LambdaContext object from an API request.
    • If it’s not possible, or not a realistic expectation for complete LambdaContext objects to be populated, define a minimum acceptable basis for creating one.
  • Determine the best approach for having a route-decorated API function call the appropriate Lambda handler. Some possibilities to explore include:
    • An additional decorator between the app.route decorator provided by Flask and the target function.
    • Extending the Flask Application object to add a new route-equivalent decorator that handles the process.
    • Overriding the existing decorator to handle the process.
    • Manually dealing with it in some fashion is acceptable as a starting-point, but not where it should end up by the time the package reaches a Development Status :: 5 - Production/Stable / v.1.0.0 release.
  • Figure out how to convert a Flask Response object to a Lambda Proxy Integration output object.
  • Implement anything that wasn’t implemented as part of the discovery above.
  • Test everything that wasn’t tested during previous implementation.
  • Release v.1.0.0
  • Figure out how to read a SAM Template file to automate the creation of endpoint-function to Lambda-handler function processes, and implement it.
  • Release v.1.1.0

And with that roadmap all defined, at least for now, this post is done, I think. As I write more on this idea, and get the package moving and released, the relevant posts will all be tagged with “local.lpi_apis” (Lambda Proxy Integration APIs) for ease of following my stream of thoughts and work on it.

Local AWS API Gateway development with Python: The initial FastAPI implementation

Based on some LinkedIn conversations prompted by my post there about the previous post on this topic , I feel like I shoul...