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.
Files changed (47) hide show
  1. redis_lua_py-0.2.0/.release-please-manifest.json +3 -0
  2. redis_lua_py-0.2.0/CHANGELOG.md +109 -0
  3. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/CONTRIBUTING.md +12 -0
  4. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/PKG-INFO +250 -5
  5. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/README.md +249 -4
  6. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/pyproject.toml +13 -2
  7. redis_lua_py-0.2.0/scripts/generate_commands.py +105 -0
  8. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/__init__.py +32 -9
  9. redis_lua_py-0.2.0/src/redis_lua_py/_commands.py +556 -0
  10. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_compile.py +359 -21
  11. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_lua.py +36 -1
  12. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_script.py +52 -8
  13. redis_lua_py-0.2.0/src/redis_lua_py/errors.py +95 -0
  14. redis_lua_py-0.2.0/tests/test_binary.py +94 -0
  15. redis_lua_py-0.2.0/tests/test_commands.py +193 -0
  16. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_compile.py +50 -2
  17. redis_lua_py-0.2.0/tests/test_constants.py +139 -0
  18. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_execute.py +1 -1
  19. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_semantics.py +117 -2
  20. redis_lua_py-0.2.0/tests/test_typing.py +60 -0
  21. redis_lua_py-0.1.0/.release-please-manifest.json +0 -3
  22. redis_lua_py-0.1.0/CHANGELOG.md +0 -42
  23. redis_lua_py-0.1.0/src/redis_lua_py/errors.py +0 -53
  24. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/README.md +0 -0
  25. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/banner-dark.svg +0 -0
  26. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/banner-light.svg +0 -0
  27. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/generate.py +0 -0
  28. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/logo.svg +0 -0
  29. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/logomark.svg +0 -0
  30. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/social-preview.png +0 -0
  31. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/assets/social-preview.svg +0 -0
  32. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/workflows/ci.yml +0 -0
  33. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/workflows/pr-title.yml +0 -0
  34. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.github/workflows/release.yml +0 -0
  35. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.gitignore +0 -0
  36. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/.python-version +0 -0
  37. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/LICENSE +0 -0
  38. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/release-please-config.json +0 -0
  39. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/_runtime.py +0 -0
  40. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/src/redis_lua_py/py.typed +0 -0
  41. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/conftest.py +0 -0
  42. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/module_with_client.py +0 -0
  43. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/module_without_import.py +0 -0
  44. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_binding.py +0 -0
  45. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_errors.py +0 -0
  46. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/tests/test_namespace.py +0 -0
  47. {redis_lua_py-0.1.0 → redis_lua_py-0.2.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.0"
3
+ }
@@ -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.1.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 /srv/app/limits.py:6. Do not edit.
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[str]:
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 `redis` and `cjson`.
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[str]:
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