Skip to content

Shipping without the dependency

Calling a script needs redis-lua-py at runtime: it compiles the body when the module is imported, and resolves keys and arguments on every call. In an application that is a dependency you chose. In a library it is a dependency every one of your users inherits, along with the import-time compile, for Lua that never changes between your releases.

So a library can compile ahead of time instead. You write the scripts with @script, as anywhere else, and generate a module from them while you develop. What you ship is that module: plain Python that needs only the standard library, with a typed function per script, called exactly the way the @script would be.

Generate a module

Keep the scripts outside the package you ship, and redis-lua-py in your development dependencies:

uv add --dev redis-lua-py
myproj/
├── pyproject.toml
├── redis_scripts/
│   └── limits.py      # the @script functions; never shipped
└── src/myproj/
    ├── _lua.py        # generated, checked in, shipped
    └── limits.py      # the code that runs them

Then, from the project root:

python -m redis_lua_py generate redis_scripts.limits --out src/myproj/_lua.py

Your library calls the generated functions the way it would call the scripts themselves:

from redis import Redis

from ._lua import rate_limit


def hit(client: Redis, key: str, limit: int, ttl: int) -> int:
    return rate_limit(client, key=key, limit=limit, ttl=ttl)

Moving from @script to generated code, or back, is a change of import. A sync client gets a value and an async one an awaitable, EVALSHA falls back to EVAL when the server has dropped the script, and a cluster pipeline is sent the source: the generated module carries a copy of the code the package itself calls scripts with.

What the module holds

For each script, its Lua as a constant named after it in capitals, and a function with the signature it was written with:

# rate_limit -- KEYS: key; ARGV: limit, ttl
RATE_LIMIT = """\
-- rate_limit
-- Generated by redis-lua-py from redis_scripts/limits.py:6. Do not edit.
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
  redis.call('EXPIRE', key, ttl)
end
if current > limit then
  return -1
end
return limit - current
"""
_RATE_LIMIT_CLIENTS: WeakKeyDictionary[Any, Any] = WeakKeyDictionary()


@overload
def rate_limit(client: _SyncClient, /, key: _Key, limit: int, ttl: int) -> int: ...
@overload
def rate_limit(
    client: _AsyncClient,
    /,
    key: _Key,
    limit: int,
    ttl: int,
) -> Awaitable[int]: ...
@overload
def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> int: ...
def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> Any:
    return _run(
        RATE_LIMIT,
        _RATE_LIMIT_CLIENTS,
        client,
        [key],
        [_encode("limit", limit), _encode("ttl", ttl)],
    )

Because it is a real signature, Python itself refuses a missing, misspelled or duplicated argument, and your type checker sees every call, including what it returns from a sync client and from an async one. Parameter names, their order, keyword-only parameters and the docstring all come from the script.

The types come from the script's annotations, as far as a module that imports nothing of yours can repeat them:

Parameter Accepts
annotated Key str \| bytes \| memoryview, as redis-py does for a key
list[Key], or list[...] of arguments any iterable of those, as @script does
an argument annotated with builtins only, such as int or str \| bytes exactly that
an argument annotated with anything else str \| bytes \| memoryview \| int \| float

A return annotation made only of builtins, such as list[bytes] | None, is kept as written; anything else becomes Any. A value Redis has no representation for, such as None, raises TypeError, as does a string passed where a list belongs.

Above the scripts sits that copied call path, about a hundred lines, every name in it private. A script or parameter named like one of them would shadow it, so generation refuses one and names it.

Every path in the file is relative to the project root, so it comes out byte-for-byte the same on every machine, and regenerating it without a change to the scripts leaves it untouched. It is laid out the way black and ruff format code, and passes strict mypy.

If you would rather call redis-py yourself, the constants are there for that: client.register_script(RATE_LIMIT), with keys and args in the order the comment above each one records.

Or one .lua file per script

Pass a directory instead of a .py path:

python -m redis_lua_py generate redis_scripts.limits --out src/myproj/lua/

That writes rate_limit.lua and a file for every other script, for a library that already loads its Lua from files. When a script is renamed or removed, its old file is deleted. Only files carrying the generated header are ever deleted, so hand-written Lua in the same directory is safe. The flip side is that a script compiled with header=False leaves its old file behind when it is renamed.

Keep it current

Checking generated code in only works if something notices when it goes stale. --check writes nothing, and exits 1 with a diff if the output no longer matches the scripts. Run it in CI:

- run: uv run python -m redis_lua_py generate redis_scripts.limits --out src/myproj/_lua.py --check

Or as a test, which fails with the same diff:

from redis_lua_py import codegen


def test_generated_lua_is_current():
    codegen.check("redis_scripts.limits", "src/myproj/_lua.py")

A test imports redis_scripts.limits like any other module, so the project root has to be on the path. With pytest, set pythonpath = ["."] under [tool.pytest.ini_options].

What you give up

The generated Lua is identical to what a @script call would send, and the call path is the same code. What differs is at the edges:

  • Errors. A bad argument raises TypeError, not ScriptArgumentError, which lives in this package.
  • bind. A generated function takes the client on every call. Wrap it in a function of your own if that gets repetitive.
  • Your own types. An annotation that names a type from your module is widened, as in the table above.
  • Redis Functions. Only @script functions are generated, not a Library.

Keep testing the scripts against fakeredis, through the generated module or through @script: both run the same Lua.

The script module itself is ordinary Python, so point your type checker and linter at it along with the rest of the project.