redis-lua-py 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redis_lua_py-0.2.0/.release-please-manifest.json +3 -0
- redis_lua_py-0.2.0/CHANGELOG.md +109 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/CONTRIBUTING.md +12 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/PKG-INFO +250 -5
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/README.md +249 -4
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/pyproject.toml +13 -2
- redis_lua_py-0.2.0/scripts/generate_commands.py +105 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/__init__.py +32 -9
- redis_lua_py-0.2.0/src/redis_lua_py/_commands.py +556 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_compile.py +359 -21
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_lua.py +36 -1
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_script.py +52 -8
- redis_lua_py-0.2.0/src/redis_lua_py/errors.py +95 -0
- redis_lua_py-0.2.0/tests/test_binary.py +94 -0
- redis_lua_py-0.2.0/tests/test_commands.py +193 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_compile.py +50 -2
- redis_lua_py-0.2.0/tests/test_constants.py +139 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_execute.py +1 -1
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_semantics.py +117 -2
- redis_lua_py-0.2.0/tests/test_typing.py +60 -0
- redis_lua_py-0.1.0/.release-please-manifest.json +0 -3
- redis_lua_py-0.1.0/CHANGELOG.md +0 -42
- redis_lua_py-0.1.0/src/redis_lua_py/errors.py +0 -53
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/README.md +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/banner-dark.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/banner-light.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/generate.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/logo.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/logomark.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/social-preview.png +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/social-preview.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/workflows/ci.yml +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/workflows/pr-title.yml +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/workflows/release.yml +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.gitignore +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.python-version +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/LICENSE +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/release-please-config.json +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_runtime.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/py.typed +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/conftest.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/module_with_client.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/module_without_import.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_binding.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_errors.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_namespace.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/uv.lock +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries
|
|
6
|
+
are written by [release-please](https://github.com/googleapis/release-please)
|
|
7
|
+
from the conventional commit subjects on `main`; see
|
|
8
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for how a release is cut.
|
|
9
|
+
|
|
10
|
+
## [0.2.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.1.0...v0.2.0) (2026-09-12)
|
|
11
|
+
|
|
12
|
+
Everything raised by the first adoption report of 0.1.0
|
|
13
|
+
([#2](https://github.com/IgnaceMaes/redis-lua-py/pull/2),
|
|
14
|
+
[0ef4ed2](https://github.com/IgnaceMaes/redis-lua-py/commit/0ef4ed23dd3cef8616ab5132d7a9089e38bb518a)).
|
|
15
|
+
|
|
16
|
+
### ⚠ BREAKING CHANGES
|
|
17
|
+
|
|
18
|
+
- A command name Redis does not have is now refused at import rather than
|
|
19
|
+
compiled, and a literal `None` inside a returned table is refused. Both
|
|
20
|
+
previously compiled to Lua that failed, or silently truncated, at runtime.
|
|
21
|
+
- The generated header's source path is now relative to the project root,
|
|
22
|
+
which changes the SHA of every script — once.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Command names are checked at compile time against a table generated from the
|
|
27
|
+
Redis source (`scripts/generate_commands.py`, tracking Redis 8.10), so a name
|
|
28
|
+
Redis does not have is refused with a caret and a suggestion rather than
|
|
29
|
+
raising the first time its branch runs.
|
|
30
|
+
- The redis-py spellings that name exactly one command are translated instead
|
|
31
|
+
of refused: `redis.delete(k)` compiles to `redis.call('DEL', k)`.
|
|
32
|
+
- Container commands are checked down to the subcommand, and a hyphenated one
|
|
33
|
+
is reached through its underscores: `redis.client_no_evict("on")` compiles to
|
|
34
|
+
`redis.call('CLIENT', 'NO-EVICT', 'on')`.
|
|
35
|
+
- Module-level `int`, `float`, `str`, `bytes` and `bool` constants are folded
|
|
36
|
+
into a script body, including through a dotted name such as an `IntEnum`
|
|
37
|
+
member or a settings attribute. A body no longer has to repeat a number its
|
|
38
|
+
own module already names.
|
|
39
|
+
- The return annotation is carried to the caller: a script is a
|
|
40
|
+
`CompiledScript[R]`, `bind()` gives a `BoundScript`, and an async client
|
|
41
|
+
yields `Awaitable[R]` rather than `Any`.
|
|
42
|
+
- `@script(header=False)` drops the provenance comment, for anyone who wants
|
|
43
|
+
the script body and nothing else.
|
|
44
|
+
- `NilTruncationWarning` (and `RedisLuaWarning`) are raised when a name that is
|
|
45
|
+
not assigned on every path is returned inside a table, where it would
|
|
46
|
+
truncate the reply.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- The generated header recorded an absolute source path. Since the header is
|
|
51
|
+
part of the body, and the body is what `EVALSHA` hashes, the same script had
|
|
52
|
+
a different SHA on a laptop, in CI and in a container, and put build-machine
|
|
53
|
+
paths on the Redis server. The path is now relative to the project root.
|
|
54
|
+
- A `bytes` literal in a body was decoded with `surrogateescape`, which could
|
|
55
|
+
not survive the script being sent to Redis as text. Bytes literals are now
|
|
56
|
+
emitted as numeric escapes, so they arrive exactly.
|
|
57
|
+
- A NUL in a Lua string literal was written `\0`, which Lua reads together
|
|
58
|
+
with a following digit as a different byte. It is now `\000`.
|
|
59
|
+
- `redis.sort_ro(...)` and the other `_RO` variants split into two tokens,
|
|
60
|
+
making the `RO` a stray argument. Their underscore is part of the wire name
|
|
61
|
+
and is now kept.
|
|
62
|
+
|
|
63
|
+
### Documentation
|
|
64
|
+
|
|
65
|
+
- Binary values through `ARGV` are now a stated guarantee with a test that
|
|
66
|
+
round-trips non-UTF-8 bytes through `ARGV`, a stored value and a returned
|
|
67
|
+
`GETRANGE`.
|
|
68
|
+
- A returned table is truncated at the first genuine `nil` -- but a command
|
|
69
|
+
with nothing to return hands Lua `false`, which becomes a null *element* and
|
|
70
|
+
does not truncate. Both are written down, in "Where Lua differs from Python".
|
|
71
|
+
- "Testing your scripts": snapshot `.lua` in a golden test, and run behaviour
|
|
72
|
+
against `fakeredis[lua]` without a server.
|
|
73
|
+
- Scripts belong at module level, where they compile once at import.
|
|
74
|
+
- `float` round-trips through Lua's `%.14g` number formatting; `bool` encodes
|
|
75
|
+
to `"1"` / `"0"`; `bytes` is a passthrough.
|
|
76
|
+
|
|
77
|
+
## 0.1.0 (2026-09-12)
|
|
78
|
+
|
|
79
|
+
First release.
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- `@script`, which compiles a Python function to Redis Lua at import time.
|
|
84
|
+
- `Key` annotation marking a parameter as `KEYS` rather than `ARGV`, so that
|
|
85
|
+
Redis Cluster can route the script correctly.
|
|
86
|
+
- `int` and `float` annotations wrap their argument in `tonumber`, since `ARGV`
|
|
87
|
+
always arrives as a string.
|
|
88
|
+
- One script object drives both sync and async redis-py clients, deferring to
|
|
89
|
+
redis-py for `EVALSHA`, script caching and the `NOSCRIPT` reload.
|
|
90
|
+
- `redis.*` / `call.*` command calls, with underscores splitting into
|
|
91
|
+
subcommand tokens, plus `cjson.encode` / `cjson.decode`.
|
|
92
|
+
- The generated Lua is exposed on `.lua` for review and golden testing.
|
|
93
|
+
- `UnsupportedSyntax` reports the file, line and column of anything outside the
|
|
94
|
+
supported subset, with a hint for what to write instead.
|
|
95
|
+
- The command namespace is resolved by value rather than by name, so it works
|
|
96
|
+
under any alias (`from redis_lua_py import redis as r`). A receiver that
|
|
97
|
+
turns out to be redis-py itself is refused with an explanation, instead of
|
|
98
|
+
being compiled against the client library.
|
|
99
|
+
- `script.bind(client)` returns a callable with the client attached, for code
|
|
100
|
+
that would otherwise repeat it at every call site.
|
|
101
|
+
|
|
102
|
+
### Semantics closed between Python and Lua
|
|
103
|
+
|
|
104
|
+
- Truthiness: `0`, `''` and empty tables are falsy, as in Python.
|
|
105
|
+
- Missing values: a Redis command returning nothing gives Lua `false`, not
|
|
106
|
+
`nil`, so `is None` accepts both and evaluates its operand once.
|
|
107
|
+
- Indexing: Python's 0-based indices are translated to Lua's 1-based tables.
|
|
108
|
+
- Scope: a name first assigned inside a block is hoisted, because Lua's `local`
|
|
109
|
+
is block-scoped where Python's assignment is function-scoped.
|
|
@@ -10,6 +10,18 @@ uv run pytest && uv run ruff check && uv run ruff format --check && uv run mypy
|
|
|
10
10
|
See the [README](README.md#development) for running the suite against a live
|
|
11
11
|
Redis.
|
|
12
12
|
|
|
13
|
+
`src/redis_lua_py/_commands.py` is generated, not written. It is the table
|
|
14
|
+
command names are checked against, and it comes from the command definitions
|
|
15
|
+
in the Redis source. Refresh it when a Redis release adds commands, and commit
|
|
16
|
+
the result:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv run python scripts/generate_commands.py 8.10.1
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A stale table can never block a caller: `redis.call('NEW.CMD', ...)` is
|
|
23
|
+
deliberately never checked.
|
|
24
|
+
|
|
13
25
|
## Commit messages
|
|
14
26
|
|
|
15
27
|
Pull requests are squash-merged, so **the PR title becomes the commit subject
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: redis-lua-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Write Redis Lua scripts as real Python functions, not strings.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ignacemaes/redis-lua-py
|
|
6
6
|
Project-URL: Issues, https://github.com/ignacemaes/redis-lua-py/issues
|
|
@@ -58,6 +58,10 @@ imported, compiled to Lua, and sent to Redis with `EVALSHA`. Your editor
|
|
|
58
58
|
highlights it, your linter sees it, and `mypy` checks the signature — none of
|
|
59
59
|
which is true of a string.
|
|
60
60
|
|
|
61
|
+
Define scripts at module level, where they compile once at import. A script
|
|
62
|
+
defined inside a function recompiles on every call, and one defined through
|
|
63
|
+
`exec` has no source to read and is refused.
|
|
64
|
+
|
|
61
65
|
```python
|
|
62
66
|
from redis import Redis
|
|
63
67
|
|
|
@@ -84,7 +88,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
|
|
|
84
88
|
|
|
85
89
|
```lua
|
|
86
90
|
-- rate_limit
|
|
87
|
-
-- Generated by redis-lua-py from /
|
|
91
|
+
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
|
|
88
92
|
local key = KEYS[1]
|
|
89
93
|
local limit = tonumber(ARGV[1])
|
|
90
94
|
local ttl = tonumber(ARGV[2])
|
|
@@ -101,6 +105,12 @@ return limit - current
|
|
|
101
105
|
Read it in review, paste it into `redis-cli`, check it into a golden test. The
|
|
102
106
|
point of this library is to generate Lua you would have been willing to write.
|
|
103
107
|
|
|
108
|
+
The header is part of the body, and the body is what `EVALSHA` hashes, so the
|
|
109
|
+
path in it is relative to your project root rather than absolute — the same
|
|
110
|
+
script has the same SHA on a laptop, in CI and in a container, and the server's
|
|
111
|
+
script cache is cold once per script rather than once per environment. Pass
|
|
112
|
+
`@script(header=False)` to drop the comment entirely.
|
|
113
|
+
|
|
104
114
|
## Keys and arguments
|
|
105
115
|
|
|
106
116
|
A parameter annotated `Key` becomes `KEYS`, in declaration order. Everything
|
|
@@ -114,6 +124,17 @@ script will execute on the wrong node. Annotate every key.
|
|
|
114
124
|
`float` wraps it in `tonumber` for you, so `limit` above is a number by the
|
|
115
125
|
time your comparison runs.
|
|
116
126
|
|
|
127
|
+
Annotate `float` for arithmetic, not for a value you mean to write back
|
|
128
|
+
unchanged. `tonumber` makes it a Lua number, and Lua renders a number back to
|
|
129
|
+
text with `%.14g`, so a value with more significant digits than that does not
|
|
130
|
+
come back as it went in. Annotate `str` and call `str()` at the call site when
|
|
131
|
+
the value is only being carried.
|
|
132
|
+
|
|
133
|
+
`bool` encodes to `"1"` or `"0"`. Paired with an `int` annotation that deletes
|
|
134
|
+
the `1 if flag else 0` from the call site: pass `True`, and the body gets `1`.
|
|
135
|
+
|
|
136
|
+
`bytes` is passed through untouched — see [Binary values](#binary-values).
|
|
137
|
+
|
|
117
138
|
Scripts accept positional or keyword arguments; keyword is clearer at the call
|
|
118
139
|
site and is what the errors suggest.
|
|
119
140
|
|
|
@@ -157,6 +178,43 @@ split into subcommand tokens, so `redis.script_load(x)` compiles to
|
|
|
157
178
|
`redis.log` and `cjson.encode` / `cjson.decode` pass through under their own
|
|
158
179
|
names.
|
|
159
180
|
|
|
181
|
+
### Names are checked, not just uppercased
|
|
182
|
+
|
|
183
|
+
Uppercasing turns *any* attribute into a plausible command, which makes a name
|
|
184
|
+
Redis does not have the one mistake with nothing standing in its way: it
|
|
185
|
+
compiles, it survives review, and it raises the first time its branch runs —
|
|
186
|
+
inside a script whose whole purpose was to be atomic.
|
|
187
|
+
|
|
188
|
+
So command names are checked at compile time against Redis' own command table,
|
|
189
|
+
and a miss is refused where you can see it:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
Redis has no EXPIRES command
|
|
193
|
+
File "/srv/app/limits.py", line 14
|
|
194
|
+
redis.expires(key, 60)
|
|
195
|
+
^
|
|
196
|
+
hint: Did you mean redis.expire()?
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The handful of redis-py method names that do not match the wire name are
|
|
200
|
+
translated rather than refused, because each names exactly one command and
|
|
201
|
+
nothing else: `redis.delete(k)` compiles to `redis.call('DEL', k)`. Container
|
|
202
|
+
commands are checked down to the subcommand, and a hyphenated one is reached
|
|
203
|
+
through its underscores — `redis.client_no_evict("on")` compiles to
|
|
204
|
+
`redis.call('CLIENT', 'NO-EVICT', 'on')`.
|
|
205
|
+
|
|
206
|
+
`redis.call(...)` is deliberately never checked. It is the escape hatch for
|
|
207
|
+
module commands, which are spelled with a dot anyway, and for anything a newer
|
|
208
|
+
server has that the table does not:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
redis.call("JSON.SET", doc, "$.status", '"done"')
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The table is generated from the command definitions in the Redis source — the
|
|
215
|
+
same files the server is built from — and currently tracks Redis 8.10.
|
|
216
|
+
Regenerate it with `uv run python scripts/generate_commands.py`.
|
|
217
|
+
|
|
160
218
|
### When the client is imported too
|
|
161
219
|
|
|
162
220
|
Import the client *class* and nothing collides, because the name `redis` is
|
|
@@ -177,7 +235,7 @@ from redis_lua_py import redis as r # the script namespace
|
|
|
177
235
|
|
|
178
236
|
|
|
179
237
|
@script
|
|
180
|
-
def claim(queue: Key, now: int) -> list[
|
|
238
|
+
def claim(queue: Key, now: int) -> list[bytes]:
|
|
181
239
|
return r.zrangebyscore(queue, 0, now)
|
|
182
240
|
|
|
183
241
|
|
|
@@ -200,12 +258,123 @@ client library:
|
|
|
200
258
|
redis as r), or the client under another name (import redis as redis_client).
|
|
201
259
|
```
|
|
202
260
|
|
|
261
|
+
## Constants from the module
|
|
262
|
+
|
|
263
|
+
A script has no closure: the body runs on the server, where nothing from your
|
|
264
|
+
Python process exists. A module-level constant is the exception worth making,
|
|
265
|
+
because it is already a literal and can simply be folded in.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
SESSION_TTL_SECONDS = 30 * 60
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@script
|
|
272
|
+
def touch_session(session: Key) -> int:
|
|
273
|
+
hits = redis.incr(session)
|
|
274
|
+
redis.expire(session, SESSION_TTL_SECONDS) # -> redis.call('EXPIRE', session, 1800)
|
|
275
|
+
return hits
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`int`, `float`, `str`, `bytes` and `bool` are folded, including through a
|
|
279
|
+
dotted name — an `IntEnum` member, or an attribute of a settings object.
|
|
280
|
+
Anything else is refused with the same caret as everything else, because there
|
|
281
|
+
is no literal to fold:
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
'SESSION_TTL' is a module-level timedelta, which has no Lua literal
|
|
285
|
+
File "/srv/app/sessions.py", line 18
|
|
286
|
+
redis.expire(session, SESSION_TTL)
|
|
287
|
+
^
|
|
288
|
+
hint: Only an int, float, str, bytes or bool constant is folded into the
|
|
289
|
+
script. Pass anything else as an argument, or name the literal it reduces to.
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The value is read once, when the module is imported and the script compiles.
|
|
293
|
+
A name rebound afterwards does not change the script — which is what "constant"
|
|
294
|
+
means, but worth saying out loud.
|
|
295
|
+
|
|
296
|
+
## Binary values
|
|
297
|
+
|
|
298
|
+
Nothing here decodes. `KEYS`, `ARGV` and every Lua string are byte strings, so
|
|
299
|
+
a `bytes` argument arrives in the script as exactly those bytes and comes back
|
|
300
|
+
as exactly those bytes.
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
import zlib
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@script
|
|
307
|
+
def cache_compressed(key: Key, blob: bytes, ttl: int) -> int:
|
|
308
|
+
redis.set(key, blob)
|
|
309
|
+
redis.expire(key, ttl)
|
|
310
|
+
return len(blob)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
cache_compressed(client, key="report:42", blob=zlib.compress(report), ttl=300)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A `bytes` annotation is a passthrough: no `tonumber`, no decoding, no round
|
|
317
|
+
trip through text. `memoryview` is accepted the same way. A `bytes` literal in
|
|
318
|
+
a body is emitted as numeric escapes — `b"\x00\xff"` becomes `'\000\255'` —
|
|
319
|
+
so it survives the journey to the server, where the script itself travels as
|
|
320
|
+
text.
|
|
321
|
+
|
|
322
|
+
This is a guarantee rather than an observation:
|
|
323
|
+
[tests/test_binary.py](tests/test_binary.py) round-trips non-UTF-8 bytes
|
|
324
|
+
through `ARGV`, through a stored value, and back out of a returned `GETRANGE`,
|
|
325
|
+
against both fakeredis and a real server.
|
|
326
|
+
|
|
327
|
+
## What the caller gets
|
|
328
|
+
|
|
329
|
+
The return annotation describes the **caller's** side: the value that comes
|
|
330
|
+
back from Redis, not the value the body hands to Lua. The compiler does not
|
|
331
|
+
read it at all.
|
|
332
|
+
|
|
333
|
+
It is carried through to the call, so a script is a `CompiledScript[R]` and
|
|
334
|
+
`rate_limit` above returns an `int` rather than `Any`:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
remaining = rate_limit(client, key="user:42", limit=10, ttl=60) # int
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
An async client gives you `Awaitable[R]`, so `await` gets you back to `R`.
|
|
341
|
+
`bind` carries it too, on both.
|
|
342
|
+
|
|
343
|
+
Redis renders every reply as bytes, which is what to annotate — and what to
|
|
344
|
+
write in the body when a branch needs a placeholder:
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
@script
|
|
348
|
+
def preview(doc: Key) -> list[int | bytes]:
|
|
349
|
+
size = redis.strlen(doc)
|
|
350
|
+
if size == 0:
|
|
351
|
+
return [0, b""]
|
|
352
|
+
return [1, redis.getrange(doc, 0, 1023)]
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`b""` and `""` compile to the same Lua string; only one of them also describes
|
|
356
|
+
what the caller receives, which keeps the body and the signature agreeing
|
|
357
|
+
about the same thing.
|
|
358
|
+
|
|
359
|
+
Inside a body, every value that came from Redis is `Any` — nothing about
|
|
360
|
+
`redis.get(k)` is knowable ahead of time. Under `mypy --strict` that makes
|
|
361
|
+
`warn_return_any` fire on a body that returns a command result directly, on
|
|
362
|
+
the one function whose body Python never runs. Turn it off for the module your
|
|
363
|
+
scripts live in:
|
|
364
|
+
|
|
365
|
+
```toml
|
|
366
|
+
[[tool.mypy.overrides]]
|
|
367
|
+
module = "myapp.scripts"
|
|
368
|
+
warn_return_any = false
|
|
369
|
+
```
|
|
370
|
+
|
|
203
371
|
## The supported subset
|
|
204
372
|
|
|
205
373
|
Supported: assignment, augmented assignment, `if`/`elif`/`else`, `for ... in`
|
|
206
374
|
over a table or `range()`, `while`, `break`, `return`, comparisons, arithmetic,
|
|
207
375
|
f-strings, list and dict literals, `len()`, `.append()`, `int()`, `float()`,
|
|
208
|
-
`str()`, `min()`, `max()`, `abs()`, and calls into
|
|
376
|
+
`str()`, `min()`, `max()`, `abs()`, module-level constants, and calls into
|
|
377
|
+
`redis` and `cjson`.
|
|
209
378
|
|
|
210
379
|
Everything else raises `UnsupportedSyntax` when the module is imported, with a
|
|
211
380
|
caret under the line at fault:
|
|
@@ -257,6 +426,33 @@ nest the rest of the body.
|
|
|
257
426
|
|
|
258
427
|
**A loop variable does not outlive its loop,** unlike in Python.
|
|
259
428
|
|
|
429
|
+
**A nil inside a returned table truncates the reply.** Redis converts a
|
|
430
|
+
returned array by walking it from the first element and stopping at the first
|
|
431
|
+
`nil`, so the caller gets a shorter list rather than a null in the middle of
|
|
432
|
+
one.
|
|
433
|
+
|
|
434
|
+
Which values are actually nil is the part worth being exact about. A command
|
|
435
|
+
with nothing to return hands Lua `false`, and `false` converts to a null
|
|
436
|
+
*element* without ending the array — `return [1, redis.get(missing), 3]` really
|
|
437
|
+
does reach the caller as `[1, None, 3]`. What truncates is a genuine nil, and
|
|
438
|
+
in practice that means a name that was not assigned on this path. The compiler
|
|
439
|
+
warns where it can see one:
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
'first' is not assigned on every path to this return, and a nil in a returned
|
|
443
|
+
table truncates the reply there
|
|
444
|
+
File "/srv/app/queue.py", line 31
|
|
445
|
+
return [1, first, count]
|
|
446
|
+
^
|
|
447
|
+
hint: Give it a value before the branch, so that every branch returns a
|
|
448
|
+
table of the same shape.
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
and refuses a `None` written out in the table, since that one is never what
|
|
452
|
+
anybody meant. Silence the warning with
|
|
453
|
+
`warnings.filterwarnings("ignore", category=NilTruncationWarning)` if your
|
|
454
|
+
script really does mean to stop there.
|
|
455
|
+
|
|
260
456
|
**Return values follow Redis' own conversion rules:** `True` becomes `1`,
|
|
261
457
|
`False` and `None` become nil, floats are truncated to integers. Return a
|
|
262
458
|
string, or `cjson.encode(...)`, when you need one preserved exactly.
|
|
@@ -265,7 +461,7 @@ string, or `cjson.encode(...)`, when you need one preserved exactly.
|
|
|
265
461
|
|
|
266
462
|
```python
|
|
267
463
|
@script
|
|
268
|
-
def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[
|
|
464
|
+
def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[bytes]:
|
|
269
465
|
"""Atomically move due jobs from a sorted set into a processing hash."""
|
|
270
466
|
ids = redis.zrangebyscore(queue, 0, now, "LIMIT", 0, limit)
|
|
271
467
|
claimed = []
|
|
@@ -293,6 +489,48 @@ end
|
|
|
293
489
|
return claimed
|
|
294
490
|
```
|
|
295
491
|
|
|
492
|
+
## Testing your scripts
|
|
493
|
+
|
|
494
|
+
Replacing working Lua in a production path needs evidence. Two things supply
|
|
495
|
+
most of it, and both are short.
|
|
496
|
+
|
|
497
|
+
**Snapshot the Lua.** `.lua` is the whole script, so a golden test is a string
|
|
498
|
+
comparison — and the diff against the Lua you are replacing is the review.
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
from pathlib import Path
|
|
502
|
+
|
|
503
|
+
GOLDEN = Path(__file__).parent / "golden" / "rate_limit.lua"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_generated_lua_is_unchanged():
|
|
507
|
+
assert rate_limit.lua == GOLDEN.read_text()
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
The header path is repo-relative, so this is stable across machines and CI.
|
|
511
|
+
Use `@script(header=False)` if you would rather compare the body alone.
|
|
512
|
+
|
|
513
|
+
**Run the behaviour, without a server.**
|
|
514
|
+
[fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
|
|
515
|
+
interpreter, so your script executes for real against an in-process server:
|
|
516
|
+
|
|
517
|
+
```python
|
|
518
|
+
import fakeredis
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_rate_limit_refuses_past_the_limit():
|
|
522
|
+
client = fakeredis.FakeRedis()
|
|
523
|
+
|
|
524
|
+
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
|
|
525
|
+
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
|
|
526
|
+
assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Install it with `uv add --dev "fakeredis[lua]"`; the `lua` extra is what brings
|
|
530
|
+
the interpreter. This library's own suite runs that way and against a real
|
|
531
|
+
Redis in CI, and the two agree — including on reply conversion, which is the
|
|
532
|
+
part you would most want a real server for.
|
|
533
|
+
|
|
296
534
|
## Development
|
|
297
535
|
|
|
298
536
|
```bash
|
|
@@ -310,6 +548,13 @@ run them against a live Redis:
|
|
|
310
548
|
REDIS_URL=redis://localhost:6379/0 uv run pytest
|
|
311
549
|
```
|
|
312
550
|
|
|
551
|
+
`src/redis_lua_py/_commands.py` is generated from the Redis source. Refresh it
|
|
552
|
+
when a Redis release adds commands:
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
uv run python scripts/generate_commands.py 8.10.1
|
|
556
|
+
```
|
|
557
|
+
|
|
313
558
|
Pull requests are squash-merged and their titles must follow
|
|
314
559
|
[Conventional Commits](https://www.conventionalcommits.org/): the title becomes
|
|
315
560
|
the changelog entry and decides the version bump. See
|