The python packaging landscape and developer experience has shifted dramatically in the past year or so with uv's launch marked a pivotal moment. But behind the scenes many PEPs have worked to get us to this point.
One such PEP was PEP 723 – Inline script metadata which we discussed in why you should write your tools in Python Again. This PEP combined with support from uv allow us to write single file scripts whilst also handling dependencies in the same script.
I've been thinking about other places this might be useful outside of command line apps. Having recently used a lot of lambdas, I believe lambdas are the perfect place to use inline metadata and UV.
Building a single file lambda with UV
Lambdas most often run a small amount of simple code meaning the code is usually a single file. This fits particularly well with inline-metadata as we can keep everything compact and simple.
So let's give it a go, using the commands in uv's docs.
uv init
Start by creating a file to write our code, we can define which python version we want here:
uv init --script main.py --python 3.13
This creates a file:
# /// script
# requires-python = ">=3.13"
# dependencies = []
# ///
def main() -> None:
print("Hello from main.py!")
if __name__ == "__main__":
main()
We can now add our code to it:
# /// script
# requires-python = ">=3.13"
# dependencies = []
# ///
from pydantic import BaseModel
class Body(BaseModel):
message: str
def lambda_handler(event, context):
return {"statusCode": 200, "body": Body(message="Hello, World!").model_dump_json()}
uv add
Then we add any dependencies we need:
uv add --script main.py pydantic
This adds the dependency to the metadata of the file:
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "pydantic",
# ]
# ///
...
Optional: uv lock
uv lock
is a relatively new feature for scripts, for those who want reproducible builds using lock files.
uv lock --script main.py
Produces a lock file at main.py.lock
. When the lock file is present uv will then respect the lockfile's dependency.
Develop code and uv run
locally
After you develop your code, you can run the script without any virtual environment setup using:
uv run --script main.py
Packaging the app
So far we've used relatively well known workflows within uv. Now comes the tricky part, we need to download the dependencies and package it alongside the code.
The simplest way I found is to first export the deps as a requirements file.
uv export --script main.py --output-file requirements.txt
And then install using uv pip install
uv pip install \
-r requirements.txt \
--target package/ \
--python-platform x86_64-manylinux2014 \
--python-version 3.13 \
--link-mode copy \
--only-binary=:all: \
--upgrade
Now this is a complex command! Let's break it down:
--target
to install the package to a directory instead of a virtualenv--python-platform
is needed as we need to install packages for the lambdas specific. The options are essentially just arm or x86. ----link-code
copy to produce a hard copy of the package if it comes from the uv cache ----only-binary
to prevent uv from building a wheel from sdist, the resulting wheel may not work on the target platform.
We need this complexity because pydantic contains platform specific native code. This is also a problem for pip.
Creating the zip file and uploading it
Creating the zip file now is simple:
cd package
zip -r ../deployment.zip .
cd ..
zip -g deployment.zip main.py
Finally we can use aws cli to upload the zip file:
aws lambda update-function-code \
--function-name <YOUR_FUNCTION_NAME> \
--zip-file fileb://deployment.zip \
We now have a workflow that uses a single file for both code and dependencies, which is convenient and then leverages uv for its dependency management and faster workflow.
Making things easier
But we can do a bit better, in our current workflow we still need to know which platform we need to install the dependencies for. To avoid this, we can simply just look up the platform and python versoin from AWS using boto3
:
def get_lambda_info(client: LambdaClient, name: str) -> dict:
response = client.get_function_configuration(FunctionName=name)
match response:
case {"Runtime": runtime, "Architectures": ["x86_64"]}:
return {
"python-version": runtime.remove_prefix("python"),
"architecture": "x86_64-manylinux2014",
}
case {"Runtime": runtime, "Architectures": ["arm64"]}:
return {
"python-version": runtime.remove_prefix("python"),
"architecture": "aarch64-manylinux2014",
}
case other:
raise Exception(f"Unexpected response: {other}")
Putting things together
Since get_lambda_info
is a python function, and if I'm honest I am terrible at bash scripting. I've added everything in a Python script and published it as simple-lambda.
I won't include the full source here, you can check it out on github.
In my script I ended up using the following builtin libraries:
- pathlib to handle file and directory paths.
- tempfile to create a temporary directory that gets deleted afterwards
- zipfile to bundle everything in a
.zip
file at the end
Using builtin python modules doesn't only save you from installing extra dependencies but also ensures that you code is platform-agnostic. This is a very desirable feature of a cli.
How to use the script
It's simple, just write your single file lambda function as before. and run:
uvx simple-lambda deploy <your_function_name> main.py
We're using uvx
here which will dynamically install simple-lambda
and run the script of the same name (by default). It also caches the package to avoid reinstalling it in the future.
If you want to explicitly keep a version of simple-lambda
on your machine then you can install it as a tool:
uv tool install simple-lambda
simple-lambda deploy <your_function_name> main.py
You do have the option to install with pip
of course but uv
is really just more convenient.
Finally
My hope is that either you find the script itself useful, or you've learned a bit more about tooling in Python.
I wrote this script to solve a real problem for myself, but I'm constantly amazed at the progress being made to improve Python packaging and tooling.
p.s. If you want to see another great use case of inline metadata, check out pydantic.run.