IgnaceMaes/redis-lua-py

Write Redis Lua scripts as real Python functions, not strings.

1

stars

6

commits

Python

primary language

Sep 12, 2026

updated

ignacemaes.com/redis-lua-py/

README

redis-lua-py: Redis Lua scripts as real Python functions.

PyPI Python CI license

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.

Documentation · Quickstart · API reference · Changelog

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.

Define scripts at module level, where they compile once at import. A script defined inside a function recompiles on every call, and one defined through exec has no source to read and is refused.

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

Python 3.11+, and redis-py 5.0+ as the only dependency.

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 src/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.

The header is part of the body, and the body is what EVALSHA hashes, so the path in it is relative to your project root rather than absolute — the same script has the same SHA on a laptop, in CI and in a container, and the server's script cache is cold once per script rather than once per environment.

What else it does

  • Keys and arguments — a parameter annotated Key becomes KEYS, which is what Redis Cluster routes on; an int or float is wrapped in tonumber for you.
  • Command names are checked at compile time against Redis' own command table, so redis.expires(...) is refused where you can see it rather than raised inside a script whose whole purpose was to be atomic.
  • Constants are folded — a module-level int, float, str, bytes or bool is read once, at import, and written into the script as a literal.
  • Binary values survive — nothing here decodes, and bytes is a passthrough in both directions.
  • The caller's side is typed — a script is a CompiledScript[R], and an async client gives you Awaitable[R].
  • Sync and async from the same script object, and bind when passing the client every time gets repetitive.
  • The gaps between Lua and Python are closed or refused — truthiness, 1-based indexing, false versus nil, block scope, and the nil that truncates a returned table.
  • Anything outside the supported subset raises at import, with a caret under the line at fault.

Full documentation: ignacemaes.com/redis-lua-py.

Testing your scripts

fakeredis embeds a real Lua interpreter, so your script executes for real against an in-process server:

import fakeredis


def test_rate_limit_refuses_past_the_limit():
    client = fakeredis.FakeRedis()

    assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
    assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
    assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1

Install it with uv add --dev "fakeredis[lua]"; the lua extra is what brings the interpreter. .lua is the whole script, so a golden snapshot is a string comparison — see Testing your scripts.

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

src/redis_lua_py/_commands.py is generated from the Redis source. Refresh it when a Redis release adds commands:

uv run python scripts/generate_commands.py 8.10.1

The docs site is built with Zensical; uv run zensical serve previews it with live reload.

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

Contributors

IgnaceMaes/redis-lua-py

Write Redis Lua scripts as real Python functions, not strings.

1

stars

6

commits

Python

primary language

Sep 12, 2026

updated

ignacemaes.com/redis-lua-py/

README

redis-lua-py: Redis Lua scripts as real Python functions.

PyPI Python CI license

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.

Documentation · Quickstart · API reference · Changelog

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.

Define scripts at module level, where they compile once at import. A script defined inside a function recompiles on every call, and one defined through exec has no source to read and is refused.

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

Python 3.11+, and redis-py 5.0+ as the only dependency.

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 src/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.

The header is part of the body, and the body is what EVALSHA hashes, so the path in it is relative to your project root rather than absolute — the same script has the same SHA on a laptop, in CI and in a container, and the server's script cache is cold once per script rather than once per environment.

What else it does

  • Keys and arguments — a parameter annotated Key becomes KEYS, which is what Redis Cluster routes on; an int or float is wrapped in tonumber for you.
  • Command names are checked at compile time against Redis' own command table, so redis.expires(...) is refused where you can see it rather than raised inside a script whose whole purpose was to be atomic.
  • Constants are folded — a module-level int, float, str, bytes or bool is read once, at import, and written into the script as a literal.
  • Binary values survive — nothing here decodes, and bytes is a passthrough in both directions.
  • The caller's side is typed — a script is a CompiledScript[R], and an async client gives you Awaitable[R].
  • Sync and async from the same script object, and bind when passing the client every time gets repetitive.
  • The gaps between Lua and Python are closed or refused — truthiness, 1-based indexing, false versus nil, block scope, and the nil that truncates a returned table.
  • Anything outside the supported subset raises at import, with a caret under the line at fault.

Full documentation: ignacemaes.com/redis-lua-py.

Testing your scripts

fakeredis embeds a real Lua interpreter, so your script executes for real against an in-process server:

import fakeredis


def test_rate_limit_refuses_past_the_limit():
    client = fakeredis.FakeRedis()

    assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
    assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
    assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1

Install it with uv add --dev "fakeredis[lua]"; the lua extra is what brings the interpreter. .lua is the whole script, so a golden snapshot is a string comparison — see Testing your scripts.

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

src/redis_lua_py/_commands.py is generated from the Redis source. Refresh it when a Redis release adds commands:

uv run python scripts/generate_commands.py 8.10.1

The docs site is built with Zensical; uv run zensical serve previews it with live reload.

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

Contributors

Languages

Python

100.0%