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:
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:
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:
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:
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, notScriptArgumentError, 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
@scriptfunctions are generated, not aLibrary.
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.