39
loading...
This website collects cookies to deliver better user experience
pytest
to correctly run a test suite located in a separate directorymypy
type-check the project correctly despite its non-standard structureNote: A finished reference serverless project is available on Github. Feel free to consult it at any stage or just read the finished code instead of the description below.
├── functions/
│ ├── add/
│ │ └── handler.py
│ └── multiply/
│ │ └── handler.py
├── layer/
│ └── shared/
│ ├── __init__.py
│ ├── math.py
│ └── py.typed
├── tests/
layer/shared
folder, while the lambda handlers live in the functions
folder. Tests have been separated from the application code in the tests
folder - since we don't want them to be included with the deployed code.shared
package from the global namespace. This "magical" behavior is courtesy of the lambda layer machinery working behind the scenes. Things will work when deployed, that's great, but what about the local development experience? If you clone the example repository and open either of the handlers in your code editor you'll find the import statements referencing the shared
module underlined in red. Module resolution is broken, since Python doesn't automatically understand a codebase structured as described above. It seems that many projects end up accepting this state of affairs as the fact of life when building with lambdas - some really hacky workarounds for this very issue can be found, for example, in official serverless project examples published by AWS. We can do better!shared
package installed in the development environment so that it can be imported in other parts of the project irrespective of the project's directory structure. Python, in fact, has a well established pattern for installing packages in "editable" mode to ease local development. We can leverage this feature to effectively create an editable simulated layer that can be developed alongside the handlers. Yes, a little bit of initial setup is required, and we will always need to install the shared
package locally as a prerequisite to doing development work and/or running the test suite, but the tradeoff is well worth it.├── layer/
│ └── shared/
│ ├── __init__.py
│ ├── math.py
│ └── py.typed
├── tests/
├── pyproject.toml
└── setup.cfg
src
directory renamed as layer
. For this project I'm using setuptools as the packaging tool, and I'm configuring the package declaratively using a setup.cfg
file.pyproject.toml
file:[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
pip
or build
) on how to build the package.setup.cfg
file:[metadata]
name = shared
version = 0.1.0
[options]
package_dir =
=layer
packages = find:
include_package_data = True
[options.packages.find]
where = layer
[options.package_data]
* = py.typed
[metadata]
section holds some basic information about the project. We don't need much here, since this package will be only used internally and will not be published to external package repositories.[options]
section accomplishes two things:It informs packaging tools that they should automatically find and include all modules located inside the layer
subdirectory, and that the layer
directory itself should be excluded from the packaged module hierarchy. The [options.packages.find]
section points the package auto-discovery logic at the layer
directory.
It states that the package is allowed to contain data files, i.e. files that don't contain Python code, as long as they are referenced defined in the [options.package_data]
section. This is required in order to include the py.typed
file from the layer/shared
folder in the package. This empty marker file informs mypy
that the packaged code contains type definitions.
shared
package locally in editable mode using the following command:❯ pip install --editable .
shared
package is now installed and we can import it like any other package:❯ python
>>> from shared.math import Addition
>>> a = Addition()
>>> print(a.add(2, 2))
4
shared
package code will be immediately applied throughout the project, without the need to re-install it.*.whl
file). We can use the build tool for this purpose. After installing build
with pip
we can run it as follows:❯ python -m build -w
-w
flag to build the wheel only. By default the build artifacts are placed in the dist
folder:├── dist
│ └── shared-0.1.0-py3-none-any.whl
python
directory containing Python modules. These modules can be anything Python understands as modules - individual Python files or directories containing __init__.py
files. The example project uses the build
directory as staging area - let's, therefore, create python
directory as a subdirectory of build
:❯ mkdir -p build/python
pip
to install the shared
package wheel to the build/python
directory:❯ python -m pip install dist/*.whl -t build/python
build
directory:├── build
│ └── python
│ ├── shared
│ │ ├── __init__.py
│ │ ├── math.py
│ │ └── py.typed
│ └── shared-0.1.0.dist-info
│ ├── (...)
requirements.txt
file we run:❯ python -m pip install -r requirements.txt -t build/python
python
directory:❯ cd build; zip -rq ../layer.zip python; cd ..
layer.zip
file located in the root directory of the project. This file is ready to be deployed as a layer using a AWS deployment tool of your preference.Makefile
to perform the above-described manuals steps automatically:ARTIFACTS_DIR ?= build
# (...)
.PHONY: build
build:
rm -rf dist || true
python -m build -w
.PHONY: build_layer
build_layer: build
rm -rf "$(ARTIFACTS_DIR)/python" || true
mkdir -p "$(ARTIFACTS_DIR)/python"
python -m pip install -r requirements.txt -t "$(ARTIFACTS_DIR)/python"
python -m pip install dist/*.whl -t "$(ARTIFACTS_DIR)/python"
.PHONY: package_layer
package_layer: build build_layer
cd "$(ARTIFACTS_DIR)"; zip -rq ../layer.zip python
make build
will build the package, running make build_layer
will populate the layer python
directory, and running make package_layer
will turn the python
directory into a zip archive. The ARTIFACTS_DIR
defaults to "build" if not set, so the default behavior of the make targets will be like in the manual commands described earlier. The single command to package the layer as a zip file is make package_layer
(this target will run build
and build_layer
targets as its prerequisites/dependencies).shared
package installed in the local Python environment, pytest
mostly works with this repository structure. This is because pytest
uses its own module discovery logic that's more permissive regarding directory layout compared to the Python default.pytest
is invoked as follows from the root of the project:❯ python -m pytest
tests/unit/functions_add_test.py
and tests/unit/functions_multiply_test.py
) will fail, however, with the following error when invoking pytest
directly (i.e. not as a Python module with python -m
) from the root of the project:❯ pytest
tests/unit/functions_add_test.py:2: in <module>
from functions.add.handler import handler
E ModuleNotFoundError: No module named 'functions'
(...)
tests/unit/functions_multiply_test.py:2: in <module>
from functions.multiply.handler import handler
E ModuleNotFoundError: No module named 'functions'
python -m pytest
has a side-effect of adding the current directory to sys.path
per standard python
behavior.pytest
directly you can work around this quirk by including a conftest.py
file in the root of the project. This will effectively force pytest
to include project root in its hierarchy of discovered modules and the command should run without module resolution errors.mypy
will run happily against the layer directory, it throws an error when asked to type-check the functions
directory:❯ mypy functions
functions/multiply/handler.py: error: Duplicate module named "handler" (also at "functions/add/handler.py")
Found 1 error in 1 file (errors prevented further checking)
functions
directory contains multiple subdirectories, each with a file called handler.py
. From mypy
's perspective this indicates an invalid package structure.mypy
repo with a discussion about this problem. The problem can be boiled down to this: mypy
only understands Python packages and relationships between them, while our functions
folder holds multiple discrete, parallel entry-points into the codebase that don't make sense when interpreted as a package. Contents of the functions
directory, in other words, is a bit like a monorepo with multiple distinct projects located in separate directories, and mypy
doesn't understand monorepos.make
target that runs mypy
separately on each directory that ought to be type-checked:MYPY_DIRS := $(shell find functions layer ! -path '*.egg-info*' -type d -maxdepth 1 -mindepth 1 | xargs)
# (...)
.PHONY: mypy
mypy: $(MYPY_DIRS)
$(foreach d, $(MYPY_DIRS), python -m mypy $(d);)
MYPY_DIRS
variable holds all direct subdirectories of layer
and functions
directories (except the egg-info
directory that's created by installing the shared
package in editable mode). The make mypy
command will run python -m mypy
for each of those directories.