Write Redis Lua scripts as real Python functions, not as strings.
Compiled at import, checked by mypy, sent with EVALSHA. Sync and async redis-py.
from redis_lua_py import Key, redis, script
@script
def rate_limit(key: Key, limit: int, ttl: int) -> int:
current = redis.incr(key)
if current == 1:
redis.expire(key, ttl)
if current > limit:
return -1
return limit - current
The body is never executed by Python. It is read as source when the module is
imported, compiled to Lua, and sent to Redis with EVALSHA. Your editor
highlights it, your linter sees it, and mypy checks the signature — none of
which is true of a string.
from redis import Redis
client = Redis()
remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
Importing the client as from redis import Redis leaves the name redis free
for the script namespace, so the two never collide.
Install
uv add redis-lua-py
What it compiles to
Nothing is hidden. Every script exposes the Lua it produced:
>>> print(rate_limit.lua)
-- rate_limit
-- Generated by redis-lua-py from /srv/app/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
Read it in review, paste it into redis-cli, check it into a golden test. The
point of this library is to generate Lua you would have been willing to write.
Keys and arguments
A parameter annotated Key becomes KEYS, in declaration order. Everything
else becomes ARGV.
This distinction is not cosmetic. Redis Cluster routes a script by its declared keys, and a key smuggled in as an argument is invisible to the router — the script will execute on the wrong node. Annotate every key.
ARGV always arrives in Lua as a string. Annotating a parameter int or
float wraps it in tonumber for you, so limit above is a number by the
time your comparison runs.
Scripts accept positional or keyword arguments; keyword is clearer at the call site and is what the errors suggest.
Async
The same script object works with either client. Pass a sync client and you get a value; pass an async one and you get an awaitable.
from redis.asyncio import Redis
client = Redis()
remaining = await rate_limit(client, key="user:42", limit=10, ttl=60)
Script caching, EVALSHA, and the NOSCRIPT reload are handled by redis-py's
own script machinery, which this defers to rather than reimplementing.
Binding a client
Passing the client to every call gets repetitive. bind attaches one:
limiter = rate_limit.bind(client)
limiter(key="user:42", limit=10, ttl=60)
limiter(key="user:43", limit=10, ttl=60)
A bound script exposes the same .lua, .keys and .args as the original,
binds async clients just as well, and leaves the unbound form working — the
script itself is unchanged and still usable against any other client.
Calling Redis commands
redis.<command>(...) becomes redis.call('<COMMAND>', ...). Underscores
split into subcommand tokens, so redis.script_load(x) compiles to
redis.call('SCRIPT', 'LOAD', x).
redis.pcall, redis.error_reply, redis.status_reply, redis.sha1hex,
redis.log and cjson.encode / cjson.decode pass through under their own
names.
When the client is imported too
Import the client class and nothing collides, because the name redis is
never taken:
from redis import Redis
from redis_lua_py import Key, redis, script
If you want the client module itself, the namespace is resolved by value rather than by spelling, so import it under any name you like:
import redis # the client
from redis_lua_py import Key, script
from redis_lua_py import redis as r # the script namespace
@script
def claim(queue: Key, now: int) -> list[str]:
return r.zrangebyscore(queue, 0, now)
client = redis.Redis()
call is also exported as an alias of redis, if you would rather rename
nothing at all.
Getting this wrong is caught rather than compiled. If the name in scope turns out to be redis-py, the script is refused instead of being quietly aimed at the client library:
'redis' is bound to redis-py here, not to the script namespace
File "/srv/app/jobs.py", line 9
return redis.zrangebyscore(queue, 0, now)
^
hint: Import the namespace under another name (from redis_lua_py import
redis as r), or the client under another name (import redis as redis_client).
The supported subset
Supported: assignment, augmented assignment, if/elif/else, for ... in
over a table or range(), while, break, return, comparisons, arithmetic,
f-strings, list and dict literals, len(), .append(), int(), float(),
str(), min(), max(), abs(), and calls into redis and cjson.
Everything else raises UnsupportedSyntax when the module is imported, with a
caret under the line at fault:
'and'/'or' are only supported in an if or while condition
File "/srv/app/limits.py", line 12
flag = a and b
^
hint: In Python these return an operand, which does not survive the
difference in truthiness. Use an if statement instead.
Failing at import, loudly, is deliberate. A body that looks like Python but is never run by Python is exactly where a quiet mistranslation would cost the most.
Where Lua differs from Python
These are the gaps that matter. Most are closed for you; the rest are refused.
Truthiness is closed. Lua counts 0 and '' as true. Any condition that
is not already a boolean is routed through a generated __truthy helper, so
if count: means what it means in Python.
Missing values are closed. A Redis command with nothing to return hands Lua
false, not nil. This is the classic trap: a hand-written == nil never
matches, so the branch silently never runs. x is None compiles to a helper
accepting both, which also takes x as an argument — so
if redis.hget(k, f) is None: does not run the command twice.
Indexing is closed. Lua tables are 1-based. items[0] compiles to
items[1]. Write Python indices and let the compiler shift them. Negative
indices are refused, because Lua has no equivalent.
Assignment scope is closed. Python scopes a name to the whole function;
Lua's local scopes it to the enclosing block. A name assigned inside an if
and read after it is hoisted to the top of the script, so it does not silently
read back nil.
+ is arithmetic, not concatenation. Use an f-string, which compiles to
Lua's ...
and / or work only in conditions. In Python they return an operand, not
a boolean, and that does not survive the truthiness difference. Use an if.
There is no continue. Lua 5.1 does not have one. Invert the condition and
nest the rest of the body.
A loop variable does not outlive its loop, unlike in Python.
Return values follow Redis' own conversion rules: True becomes 1,
False and None become nil, floats are truncated to integers. Return a
string, or cjson.encode(...), when you need one preserved exactly.
A larger example
@script
def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[str]:
"""Atomically move due jobs from a sorted set into a processing hash."""
ids = redis.zrangebyscore(queue, 0, now, "LIMIT", 0, limit)
claimed = []
for job_id in ids:
if redis.zrem(queue, job_id) == 1:
redis.hset(processing, job_id, now)
claimed.append(job_id)
return claimed
local queue = KEYS[1]
local processing = KEYS[2]
local now = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local ids = redis.call('ZRANGEBYSCORE', queue, 0, now, 'LIMIT', 0, limit)
local claimed = {}
for __i1 = 1, #ids do
local job_id = ids[__i1]
if redis.call('ZREM', queue, job_id) == 1 then
redis.call('HSET', processing, job_id, now)
claimed[#claimed + 1] = job_id
end
end
return claimed
Development
uv sync
uv run pytest
uv run ruff check
uv run mypy
Tests run against fakeredis, which
executes real Lua, so uv run pytest needs no server. Set REDIS_URL to also
run them against a live Redis:
REDIS_URL=redis://localhost:6379/0 uv run pytest
Pull requests are squash-merged and their titles must follow Conventional Commits: the title becomes the changelog entry and decides the version bump. See CONTRIBUTING.md.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file redis_lua_py-0.1.0.tar.gz.
File metadata
- Download URL: redis_lua_py-0.1.0.tar.gz
- Upload date:
- Size: 143.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4893532998978bb8b96a73641a7024420b23f21e2d0104abab4a6ee7d6a57a7e
|
|
| MD5 |
d485f280e2686b5db15a4ff8d1034bdd
|
|
| BLAKE2b-256 |
9895598777669a293f74fc9f2d6febdbe429c38e9b91e7e12009c853170da557
|
Provenance
The following attestation bundles were made for redis_lua_py-0.1.0.tar.gz:
Publisher:
release.yml on IgnaceMaes/redis-lua-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
redis_lua_py-0.1.0.tar.gz -
Subject digest:
4893532998978bb8b96a73641a7024420b23f21e2d0104abab4a6ee7d6a57a7e - Sigstore transparency entry: 2807858482
- Sigstore integration time:
-
Permalink:
IgnaceMaes/redis-lua-py@adc731c689da58685eb9d275986b1c0fd77f67ec -
Branch / Tag:
refs/heads/main - Owner: https://github.com/IgnaceMaes
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@adc731c689da58685eb9d275986b1c0fd77f67ec -
Trigger Event:
push
-
Statement type:
File details
Details for the file redis_lua_py-0.1.0-py3-none-any.whl.
File metadata
- Download URL: redis_lua_py-0.1.0-py3-none-any.whl
- Upload date:
- Size: 23.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bc458772083467c07ecd5752d7b81eaee22053b1f4de9f34c19b8cafbd88a285
|
|
| MD5 |
b5ac8f46e975e54c2fe394f7e4b2b66e
|
|
| BLAKE2b-256 |
7e72f92f10076995be4c9a8aeaee086d9974e39b545cefa3c6207fe2cfc63d1f
|
Provenance
The following attestation bundles were made for redis_lua_py-0.1.0-py3-none-any.whl:
Publisher:
release.yml on IgnaceMaes/redis-lua-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
redis_lua_py-0.1.0-py3-none-any.whl -
Subject digest:
bc458772083467c07ecd5752d7b81eaee22053b1f4de9f34c19b8cafbd88a285 - Sigstore transparency entry: 2807858514
- Sigstore integration time:
-
Permalink:
IgnaceMaes/redis-lua-py@adc731c689da58685eb9d275986b1c0fd77f67ec -
Branch / Tag:
refs/heads/main - Owner: https://github.com/IgnaceMaes
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@adc731c689da58685eb9d275986b1c0fd77f67ec -
Trigger Event:
push
-
Statement type: