Metadata-Version: 2.5
Name: redis-lua-py
Version: 0.1.0
Summary: Write Redis Lua scripts as real Python functions, not strings.
Project-URL: Homepage, https://github.com/ignacemaes/redis-lua-py
Project-URL: Issues, https://github.com/ignacemaes/redis-lua-py/issues
Author: Ignace Maes
License-Expression: MIT
License-File: LICENSE
Keywords: eval,evalsha,lua,redis,scripting,transpiler
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Lua
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: redis>=5.0
Description-Content-Type: text/markdown

<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="./.github/assets/banner-dark.svg">
    <img alt="redis-lua-py: Redis Lua scripts as real Python functions." src="./.github/assets/banner-light.svg" width="860">
  </picture>
</p>

<p align="center">
  <a href="https://pypi.org/project/redis-lua-py/"><img alt="PyPI" src="https://img.shields.io/pypi/v/redis-lua-py?color=%230070F3&label=pypi"></a>
  <a href="https://pypi.org/project/redis-lua-py/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/redis-lua-py?color=%230070F3"></a>
  <a href="https://github.com/ignacemaes/redis-lua-py/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/ignacemaes/redis-lua-py/ci.yml?branch=main&color=%230070F3&label=ci"></a>
  <a href="./LICENSE"><img alt="license" src="https://img.shields.io/pypi/l/redis-lua-py?color=%230070F3"></a>
</p>

<p align="center">
  Write Redis Lua scripts as real Python functions, not as strings.<br>
  Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
</p>

```python
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.

```python
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

```bash
uv add redis-lua-py
```

## What it compiles to

Nothing is hidden. Every script exposes the Lua it produced:

```python
>>> print(rate_limit.lua)
```

```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.

```python
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:

```python
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:

```python
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:

```python
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

```python
@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
```

```lua
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

```bash
uv sync
uv run pytest
uv run ruff check
uv run mypy
```

Tests run against [fakeredis](https://github.com/cunla/fakeredis-py), which
executes real Lua, so `uv run pytest` needs no server. Set `REDIS_URL` to also
run them against a live Redis:

```bash
REDIS_URL=redis://localhost:6379/0 uv run pytest
```

Pull requests are squash-merged and their titles must follow
[Conventional Commits](https://www.conventionalcommits.org/): the title becomes
the changelog entry and decides the version bump. See
[CONTRIBUTING.md](CONTRIBUTING.md).

## License

MIT
