redis-lua-py 0.5.1__tar.gz → 0.7.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 (98) hide show
  1. redis_lua_py-0.7.0/.release-please-manifest.json +3 -0
  2. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/CHANGELOG.md +24 -0
  3. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/PKG-INFO +6 -5
  4. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/README.md +5 -4
  5. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/async.md +28 -0
  6. redis_lua_py-0.7.0/docs/guide/build-time.md +196 -0
  7. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/redis-functions.md +6 -4
  8. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/index.md +3 -2
  9. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/installation.md +4 -0
  10. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/reference/api.md +6 -2
  11. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/reference/errors.md +4 -4
  12. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/reference/lua-vs-python.md +27 -13
  13. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/reference/supported-subset.md +12 -10
  14. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/pyproject.toml +7 -3
  15. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/__init__.py +1 -1
  16. redis_lua_py-0.7.0/src/redis_lua_py/_compile/__init__.py +181 -0
  17. redis_lua_py-0.7.0/src/redis_lua_py/_compile/analysis.py +271 -0
  18. redis_lua_py-0.7.0/src/redis_lua_py/_compile/base.py +342 -0
  19. redis_lua_py-0.7.0/src/redis_lua_py/_compile/calls.py +268 -0
  20. redis_lua_py-0.7.0/src/redis_lua_py/_compile/control.py +343 -0
  21. redis_lua_py-0.7.0/src/redis_lua_py/_compile/expressions.py +453 -0
  22. redis_lua_py-0.7.0/src/redis_lua_py/_compile/helpers.py +204 -0
  23. redis_lua_py-0.7.0/src/redis_lua_py/_compile/scope.py +228 -0
  24. redis_lua_py-0.7.0/src/redis_lua_py/_compile/source.py +62 -0
  25. redis_lua_py-0.7.0/src/redis_lua_py/_compile/statements.py +317 -0
  26. redis_lua_py-0.7.0/src/redis_lua_py/_compile/tables.py +144 -0
  27. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/_library.py +38 -12
  28. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/_lua.py +9 -0
  29. redis_lua_py-0.7.0/src/redis_lua_py/_portable.py +116 -0
  30. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/_script.py +19 -74
  31. redis_lua_py-0.7.0/src/redis_lua_py/codegen.py +429 -0
  32. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/codegen_scripts.py +21 -2
  33. redis_lua_py-0.7.0/tests/generated_scripts.py +296 -0
  34. redis_lua_py-0.7.0/tests/test_codegen.py +378 -0
  35. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_commands.py +4 -3
  36. redis_lua_py-0.7.0/tests/test_coredis.py +163 -0
  37. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_errors.py +6 -37
  38. redis_lua_py-0.7.0/tests/test_expressions.py +174 -0
  39. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_namespace.py +8 -7
  40. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_strings_and_indexing.py +113 -5
  41. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_typing.py +30 -0
  42. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/uv.lock +186 -2
  43. redis_lua_py-0.5.1/.release-please-manifest.json +0 -3
  44. redis_lua_py-0.5.1/docs/guide/build-time.md +0 -152
  45. redis_lua_py-0.5.1/src/redis_lua_py/_compile.py +0 -2457
  46. redis_lua_py-0.5.1/src/redis_lua_py/codegen.py +0 -221
  47. redis_lua_py-0.5.1/tests/test_codegen.py +0 -229
  48. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/README.md +0 -0
  49. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/banner-dark.svg +0 -0
  50. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/banner-light.svg +0 -0
  51. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/generate.py +0 -0
  52. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/logo.svg +0 -0
  53. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/logomark.svg +0 -0
  54. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/social-preview.png +0 -0
  55. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/assets/social-preview.svg +0 -0
  56. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/workflows/ci.yml +0 -0
  57. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/workflows/docs.yml +0 -0
  58. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/workflows/pr-title.yml +0 -0
  59. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.github/workflows/release.yml +0 -0
  60. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.gitignore +0 -0
  61. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/.python-version +0 -0
  62. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/CONTRIBUTING.md +0 -0
  63. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/LICENSE +0 -0
  64. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/assets/logo.svg +0 -0
  65. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/contributing.md +0 -0
  66. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/development.md +0 -0
  67. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/examples.md +0 -0
  68. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/binary-values.md +0 -0
  69. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/binding-a-client.md +0 -0
  70. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/calling-redis-commands.md +0 -0
  71. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/constants.md +0 -0
  72. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/generated-lua.md +0 -0
  73. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/keys-and-arguments.md +0 -0
  74. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/return-values.md +0 -0
  75. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/guide/testing.md +0 -0
  76. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/quickstart.md +0 -0
  77. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/docs/stylesheets/extra.css +0 -0
  78. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/release-please-config.json +0 -0
  79. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/scripts/generate_commands.py +0 -0
  80. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/__main__.py +0 -0
  81. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/_commands.py +0 -0
  82. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/_runtime.py +0 -0
  83. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/errors.py +0 -0
  84. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/src/redis_lua_py/py.typed +0 -0
  85. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/conftest.py +0 -0
  86. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/module_with_client.py +0 -0
  87. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/module_without_import.py +0 -0
  88. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_binary.py +0 -0
  89. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_binding.py +0 -0
  90. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_compile.py +0 -0
  91. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_constants.py +0 -0
  92. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_control_flow.py +0 -0
  93. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_execute.py +0 -0
  94. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_functions.py +0 -0
  95. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_mistranslations.py +0 -0
  96. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_semantics.py +0 -0
  97. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/tests/test_table_stakes.py +0 -0
  98. {redis_lua_py-0.5.1 → redis_lua_py-0.7.0}/zensical.toml +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.7.0"
3
+ }
@@ -7,6 +7,30 @@ are written by [release-please](https://github.com/googleapis/release-please)
7
7
  from the conventional commit subjects on `main`; see
8
8
  [CONTRIBUTING.md](CONTRIBUTING.md) for how a release is cut.
9
9
 
10
+ ## [0.7.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.6.0...v0.7.0) (2026-09-12)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * a + b where neither side is known to be a number or a string now joins two strings instead of adding them as numbers, as Python does, so wrap a side in int to add. Integer keys of a dict the compiler knows to be one are no longer shifted by one, and a lambda or helper function that reads a loop variable is refused.
16
+
17
+ ### Added
18
+
19
+ * compile lambdas, chained comparisons and slice steps, and tell dicts from lists ([6024009](https://github.com/IgnaceMaes/redis-lua-py/commit/6024009eaefeb20ccb743225c609799aa332f7c2))
20
+ * support coredis clients ([#22](https://github.com/IgnaceMaes/redis-lua-py/issues/22)) ([3d07a89](https://github.com/IgnaceMaes/redis-lua-py/commit/3d07a89f5b4c55eddca3a1f3bae5f45b41fd4156))
21
+
22
+
23
+ ### Changed
24
+
25
+ * split the compiler into a package of modules ([#20](https://github.com/IgnaceMaes/redis-lua-py/issues/20)) ([5f67766](https://github.com/IgnaceMaes/redis-lua-py/commit/5f67766f015c038086894f3b61fa76d6f9deef12))
26
+
27
+ ## [0.6.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.5.1...v0.6.0) (2026-09-12)
28
+
29
+
30
+ ### Added
31
+
32
+ * generate typed functions that call scripts the way `@script` does ([#18](https://github.com/IgnaceMaes/redis-lua-py/issues/18)) ([8f602f7](https://github.com/IgnaceMaes/redis-lua-py/commit/8f602f7bdf4afaa22c83c63e215ef5d965fbc584))
33
+
10
34
  ## [0.5.1](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.5.0...v0.5.1) (2026-09-12)
11
35
 
12
36
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Write Redis Lua scripts as real Python functions, not strings.
5
5
  Project-URL: Homepage, https://ignacemaes.com/redis-lua-py/
6
6
  Project-URL: Documentation, https://ignacemaes.com/redis-lua-py/
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  <p align="center">
42
42
  Write Redis Lua scripts as real Python functions, not as strings.<br>
43
- Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
43
+ Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py, and coredis.
44
44
  </p>
45
45
 
46
46
  <p align="center">
@@ -141,12 +141,13 @@ script cache is cold once per script rather than once per environment.
141
141
  a script is a `CompiledScript[R]`, and an async client gives you
142
142
  `Awaitable[R]`.
143
143
  - **[Sync and async](https://ignacemaes.com/redis-lua-py/guide/async/)** from
144
- the same script object, and
144
+ the same script object, with redis-py or coredis, and
145
145
  **[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
146
146
  passing the client every time gets repetitive.
147
147
  - **[Build-time generation](https://ignacemaes.com/redis-lua-py/guide/build-time/)**
148
- for libraries — `python -m redis_lua_py generate` writes the Lua to a module
149
- of plain strings, so your users never depend on this package, and `--check`
148
+ for libraries — `python -m redis_lua_py generate` writes your scripts to a
149
+ module of typed functions that needs only the standard library, called just
150
+ like the `@script`, so your users never depend on this package. `--check`
150
151
  keeps it current in CI.
151
152
  - **[Redis Functions](https://ignacemaes.com/redis-lua-py/guide/redis-functions/)** —
152
153
  the same Python compiles into a function library, loaded with `FUNCTION LOAD`
@@ -14,7 +14,7 @@
14
14
 
15
15
  <p align="center">
16
16
  Write Redis Lua scripts as real Python functions, not as strings.<br>
17
- Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
17
+ Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py, and coredis.
18
18
  </p>
19
19
 
20
20
  <p align="center">
@@ -115,12 +115,13 @@ script cache is cold once per script rather than once per environment.
115
115
  a script is a `CompiledScript[R]`, and an async client gives you
116
116
  `Awaitable[R]`.
117
117
  - **[Sync and async](https://ignacemaes.com/redis-lua-py/guide/async/)** from
118
- the same script object, and
118
+ the same script object, with redis-py or coredis, and
119
119
  **[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
120
120
  passing the client every time gets repetitive.
121
121
  - **[Build-time generation](https://ignacemaes.com/redis-lua-py/guide/build-time/)**
122
- for libraries — `python -m redis_lua_py generate` writes the Lua to a module
123
- of plain strings, so your users never depend on this package, and `--check`
122
+ for libraries — `python -m redis_lua_py generate` writes your scripts to a
123
+ module of typed functions that needs only the standard library, called just
124
+ like the `@script`, so your users never depend on this package. `--check`
124
125
  keeps it current in CI.
125
126
  - **[Redis Functions](https://ignacemaes.com/redis-lua-py/guide/redis-functions/)** —
126
127
  the same Python compiles into a function library, loaded with `FUNCTION LOAD`
@@ -27,3 +27,31 @@ own script machinery, which this defers to rather than reimplementing.
27
27
 
28
28
  [`bind`](binding-a-client.md) works on async clients just as well, and carries
29
29
  the awaitable through.
30
+
31
+ ## coredis
32
+
33
+ [coredis](https://github.com/alisaifee/coredis), which is async only, works
34
+ the same way. A script, a bound script and a
35
+ [library function](redis-functions.md) each return an awaitable, and the
36
+ overloads type it as `Awaitable[R]`:
37
+
38
+ ```python
39
+ import coredis
40
+
41
+ async with coredis.Redis() as client:
42
+ remaining = await rate_limit(client, key="user:42", limit=10, ttl=60)
43
+ ```
44
+
45
+ Script caching is coredis's own `register_script`, which reloads a script the
46
+ server has dropped. In a coredis pipeline a call is queued like any other
47
+ command, and coredis loads the pipeline's scripts before running it; await
48
+ each call once the `async with` block has run the pipeline:
49
+
50
+ ```python
51
+ async with client.pipeline() as pipe:
52
+ queued = rate_limit(pipe, key="user:42", limit=10, ttl=60)
53
+ remaining = await queued
54
+ ```
55
+
56
+ As with redis-py, a client created with `decode_responses=True` decodes
57
+ replies, so a script annotated `bytes` returns `str` from it.
@@ -0,0 +1,196 @@
1
+ # Shipping without the dependency
2
+
3
+ Calling a script needs redis-lua-py at runtime: it compiles the body when the
4
+ module is imported, and resolves keys and arguments on every call. In an
5
+ application that is a dependency you chose. In a library it is a dependency
6
+ every one of your users inherits, along with the import-time compile, for Lua
7
+ that never changes between your releases.
8
+
9
+ So a library can compile ahead of time instead. You write the scripts with
10
+ `@script`, as anywhere else, and generate a module from them while you
11
+ develop. What you ship is that module: plain Python that needs only the
12
+ standard library, with a typed function per script, called exactly the way
13
+ the `@script` would be.
14
+
15
+ ## Generate a module
16
+
17
+ Keep the scripts outside the package you ship, and redis-lua-py in your
18
+ development dependencies:
19
+
20
+ ```bash
21
+ uv add --dev redis-lua-py
22
+ ```
23
+
24
+ ```
25
+ myproj/
26
+ ├── pyproject.toml
27
+ ├── redis_scripts/
28
+ │ └── limits.py # the @script functions; never shipped
29
+ └── src/myproj/
30
+ ├── _lua.py # generated, checked in, shipped
31
+ └── limits.py # the code that runs them
32
+ ```
33
+
34
+ Then, from the project root:
35
+
36
+ ```bash
37
+ python -m redis_lua_py generate redis_scripts.limits --out src/myproj/_lua.py
38
+ ```
39
+
40
+ Your library calls the generated functions the way it would call the scripts
41
+ themselves:
42
+
43
+ ```python
44
+ from redis import Redis
45
+
46
+ from ._lua import rate_limit
47
+
48
+
49
+ def hit(client: Redis, key: str, limit: int, ttl: int) -> int:
50
+ return rate_limit(client, key=key, limit=limit, ttl=ttl)
51
+ ```
52
+
53
+ Moving from `@script` to generated code, or back, is a change of import. A sync
54
+ client gets a value and an async one an awaitable, `EVALSHA` falls back to
55
+ `EVAL` when the server has dropped the script, and a cluster pipeline is sent
56
+ the source: the generated module carries a copy of the code the package itself
57
+ calls scripts with.
58
+
59
+ ## What the module holds
60
+
61
+ For each script, its Lua as a constant named after it in capitals, and a
62
+ function with the signature it was written with:
63
+
64
+ ```python
65
+ # rate_limit -- KEYS: key; ARGV: limit, ttl
66
+ RATE_LIMIT = """\
67
+ -- rate_limit
68
+ -- Generated by redis-lua-py from redis_scripts/limits.py:6. Do not edit.
69
+ local key = KEYS[1]
70
+ local limit = tonumber(ARGV[1])
71
+ local ttl = tonumber(ARGV[2])
72
+ local current = redis.call('INCR', key)
73
+ if current == 1 then
74
+ redis.call('EXPIRE', key, ttl)
75
+ end
76
+ if current > limit then
77
+ return -1
78
+ end
79
+ return limit - current
80
+ """
81
+ _RATE_LIMIT_CLIENTS: WeakKeyDictionary[Any, Any] = WeakKeyDictionary()
82
+
83
+
84
+ @overload
85
+ def rate_limit(
86
+ client: _AsyncClient,
87
+ /,
88
+ key: _Key,
89
+ limit: int,
90
+ ttl: int,
91
+ ) -> Awaitable[int]: ...
92
+ @overload
93
+ def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> int: ...
94
+ def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> Any:
95
+ return _run(
96
+ RATE_LIMIT,
97
+ _RATE_LIMIT_CLIENTS,
98
+ client,
99
+ [key],
100
+ [_encode("limit", limit), _encode("ttl", ttl)],
101
+ )
102
+ ```
103
+
104
+ Because it is a real signature, Python itself refuses a missing, misspelled or
105
+ duplicated argument, and your type checker sees every call, including what it
106
+ returns from a sync client and from an async one. Parameter names, their
107
+ order, keyword-only parameters and the docstring all come from the script.
108
+
109
+ The types come from the script's annotations, as far as a module that imports
110
+ nothing of yours can repeat them:
111
+
112
+ | Parameter | Accepts |
113
+ | --- | --- |
114
+ | annotated `Key` | `str \| bytes \| memoryview`, as redis-py does for a key |
115
+ | `list[Key]`, or `list[...]` of arguments | any iterable of those, as `@script` does |
116
+ | an argument annotated with builtins only, such as `int` or `str \| bytes` | exactly that |
117
+ | an argument annotated with anything else | `str \| bytes \| memoryview \| int \| float` |
118
+
119
+ A return annotation made only of builtins, such as `list[bytes] | None`, is kept
120
+ as written; anything else becomes `Any`. A value Redis has no representation
121
+ for, such as `None`, raises `TypeError`, as does a string passed where a list
122
+ belongs.
123
+
124
+ Above the scripts sits that copied call path, about a hundred lines, every name
125
+ in it private. A script or parameter named like one of them would shadow it, so
126
+ generation refuses one and names it.
127
+
128
+ Every path in the file is relative to the project root, so it comes out
129
+ byte-for-byte the same on every machine, and regenerating it without a change
130
+ to the scripts leaves it untouched. It is laid out the way black and ruff
131
+ format code, and passes strict mypy.
132
+
133
+ If you would rather call redis-py yourself, the constants are there for that:
134
+ `client.register_script(RATE_LIMIT)`, with `keys` and `args` in the order the
135
+ comment above each one records.
136
+
137
+ ## Or one `.lua` file per script
138
+
139
+ Pass a directory instead of a `.py` path:
140
+
141
+ ```bash
142
+ python -m redis_lua_py generate redis_scripts.limits --out src/myproj/lua/
143
+ ```
144
+
145
+ That writes `rate_limit.lua` and a file for every other script, for a library
146
+ that already loads its Lua from files. When a script is renamed or removed, its
147
+ old file is deleted. Only files carrying the generated header are ever
148
+ deleted, so hand-written Lua in the same directory is safe. The flip side is
149
+ that a script compiled with `header=False` leaves its old file behind when it
150
+ is renamed.
151
+
152
+ ## Keep it current
153
+
154
+ Checking generated code in only works if something notices when it goes stale.
155
+ `--check` writes nothing, and exits 1 with a diff if the output no longer
156
+ matches the scripts. Run it in CI:
157
+
158
+ ```yaml
159
+ - run: uv run python -m redis_lua_py generate redis_scripts.limits --out src/myproj/_lua.py --check
160
+ ```
161
+
162
+ Or as a test, which fails with the same diff:
163
+
164
+ ```python
165
+ from redis_lua_py import codegen
166
+
167
+
168
+ def test_generated_lua_is_current():
169
+ codegen.check("redis_scripts.limits", "src/myproj/_lua.py")
170
+ ```
171
+
172
+ A test imports `redis_scripts.limits` like any other module, so the project
173
+ root has to be on the path. With pytest, set `pythonpath = ["."]` under
174
+ `[tool.pytest.ini_options]`.
175
+
176
+ ## What you give up
177
+
178
+ The generated Lua is identical to what a `@script` call would send, and the
179
+ call path is the same code. What differs is at the edges:
180
+
181
+ - **Errors.** A bad argument raises `TypeError`, not
182
+ [`ScriptArgumentError`](../reference/errors.md#scriptargumenterror), which
183
+ lives in this package.
184
+ - **`bind`.** A generated function takes the client on every call. Wrap it in a
185
+ function of your own if that gets repetitive.
186
+ - **Your own types.** An annotation that names a type from your module is
187
+ widened, as in the table above.
188
+ - **Redis Functions.** Only `@script` functions are generated, not a
189
+ [`Library`](redis-functions.md).
190
+
191
+ Keep testing the scripts against
192
+ [fakeredis](testing.md#run-the-behaviour-without-a-server), through the
193
+ generated module or through `@script`: both run the same Lua.
194
+
195
+ The script module itself is ordinary Python, so point your type checker and
196
+ linter at it along with the rest of the project.
@@ -34,7 +34,8 @@ peek(client, key="user:42") # FCALL_RO peek 1 user:42
34
34
 
35
35
  A function is called like a script: pass a sync client and you get a value,
36
36
  an async one and you get an awaitable, and [`bind`](binding-a-client.md)
37
- works the same way.
37
+ works the same way. With a [coredis](async.md#coredis) client, the call goes
38
+ through its `fcall` or `fcall_ro`, and loading through its `function_load`.
38
39
 
39
40
  ## Loading
40
41
 
@@ -51,10 +52,11 @@ limits.load(client)
51
52
 
52
53
  A call queued in a pipeline cannot load the library halfway through
53
54
  `execute()`. Call `load()` before queueing calls to a library the server
54
- may not have yet.
55
+ may not have yet. The same goes for a coredis pipeline, which runs when its
56
+ `async with` block ends: `await limits.load(client)` before entering it.
55
57
 
56
- On Redis Cluster, redis-py sends `FUNCTION LOAD` to every primary, and routes
57
- `FCALL` on the function's keys, as it does a script.
58
+ On Redis Cluster, redis-py and coredis send `FUNCTION LOAD` to every primary,
59
+ and route `FCALL` on the function's keys, as they do a script.
58
60
 
59
61
  ## What the library looks like
60
62
 
@@ -6,7 +6,7 @@ hide:
6
6
  # redis-lua-py
7
7
 
8
8
  Write Redis Lua scripts as real Python functions, not as strings. Compiled at
9
- import, checked by `mypy`, sent with `EVALSHA`. Sync and async redis-py.
9
+ import, checked by `mypy`, sent with `EVALSHA`. Sync and async redis-py, and coredis.
10
10
 
11
11
  ```python
12
12
  from redis_lua_py import Key, redis, script
@@ -68,7 +68,8 @@ requirements, or go straight to the [Quickstart](quickstart.md).
68
68
  - **Typed on the caller's side.** A script is a `CompiledScript[R]`, so
69
69
  `rate_limit(...)` returns an `int` rather than `Any`, and an async client
70
70
  gives you `Awaitable[R]`. See [What the caller gets](guide/return-values.md).
71
- - **Sync and async from the same script object.** See [Async](guide/async.md).
71
+ - **Sync and async from the same script object**, with redis-py or coredis.
72
+ See [Async](guide/async.md).
72
73
  - **Binary-safe throughout.** Nothing here decodes. See
73
74
  [Binary values](guide/binary-values.md).
74
75
 
@@ -21,6 +21,10 @@ own script machinery, which this library defers to rather than reimplementing.
21
21
  Anything redis-py can talk to, this can run against: a standalone server, a
22
22
  cluster, Sentinel, or an in-process [fakeredis](guide/testing.md).
23
23
 
24
+ [coredis](https://github.com/alisaifee/coredis) clients work as well, and are
25
+ tested against, but coredis is not a dependency: install it yourself. See
26
+ [Async](guide/async.md#coredis).
27
+
24
28
  ## For testing
25
29
 
26
30
  [fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
@@ -112,6 +112,9 @@ script(client, /, *positional, **keyword) -> Awaitable[R] # async client
112
112
  | `args` | `tuple[str, ...]` | everything else, in `ARGV` order |
113
113
  | `variadic_key` | `str \| None` | the `list[Key]` parameter, which fills the rest of `KEYS` |
114
114
  | `variadic_arg` | `str \| None` | the list parameter that fills the rest of `ARGV` |
115
+ | `param_annotations` | `tuple[str \| None, ...]` | each parameter's annotation as written, in `params` order |
116
+ | `return_annotation` | `str \| None` | the return annotation as written |
117
+ | `keyword_only` | `tuple[str, ...]` | the parameters declared after a bare `*` |
115
118
  | `doc` | `str \| None` | the function's docstring |
116
119
  | `source` | `str` | where it was defined, repo-relative |
117
120
 
@@ -227,8 +230,9 @@ package at runtime. See
227
230
  [Shipping without the dependency](../guide/build-time.md). Every function takes
228
231
  the module as a module object or a dotted name, and `out` as a path:
229
232
 
230
- - ending in `.py`, for one module holding every script as a string constant
231
- and importing nothing
233
+ - ending in `.py`, for one module importing only the standard library, with a
234
+ typed function per script, called the way the `@script` is, and its Lua as a
235
+ string constant
232
236
  - anything else, for a directory with one `.lua` file per script
233
237
 
234
238
  ### `codegen.generate`
@@ -4,11 +4,11 @@ Every error this package raises carries a caret under the line at fault, and a
4
4
  hint saying what to write instead.
5
5
 
6
6
  ```
7
- chained comparisons are not supported
7
+ math.log() with a base is not supported
8
8
  File "/srv/app/limits.py", line 12
9
- if 0 < n < limit:
10
- ^
11
- hint: Split 'a < b < c' into 'a < b and b < c'.
9
+ buckets = math.log(n, 2)
10
+ ^
11
+ hint: Lua 5.1's math.log takes no base; divide by math.log(base) instead.
12
12
  ```
13
13
 
14
14
  ## The hierarchy
@@ -23,22 +23,27 @@ Lua tables are 1-based. `items[0]` compiles to `items[1]`. Write Python indices
23
23
  and let the compiler shift them.
24
24
 
25
25
  A dict key must not be shifted, and Lua cannot tell a list from a dict. So the
26
- compiler looks at the subscript:
26
+ compiler looks first at what is subscripted. A dict is indexed by its keys as
27
+ they are, integer keys included, and a list's index is shifted by one. When
28
+ that is not known, it looks at the subscript:
27
29
 
28
30
  - a string literal, or a value known to be a string, is used as it is;
29
31
  - an integer literal, or a value known to be a number, is shifted by one;
30
32
  - anything else goes through a small `__key` helper, which shifts numbers and
31
33
  leaves everything else alone, at runtime.
32
34
 
33
- A value is known to be a string or a number from its annotation, a literal,
34
- the builtin or method that produced it, a `range()` or `enumerate()` loop
35
- variable, or every assignment to the name agreeing. Integer keys in a dict
36
- are treated as positions; use string keys.
35
+ A value's type is known from its annotation, a literal, the builtin, method or
36
+ operator that produced it, a `range()` or `enumerate()` loop variable, or
37
+ every assignment to the name agreeing. So after `counts = {}`, `counts[0]` is
38
+ the key `0`. A table that comes from elsewhere -- a Redis reply,
39
+ `cjson.decode`, a helper's parameter -- is not known, and an integer subscript
40
+ on it is taken to be a position.
37
41
 
38
42
  `items[-1]` compiles to `items[#items]`, which needs a name to count back from.
39
43
  Indexing a string gives a one-character string, as it does in Python, through
40
- `string.sub`. Slices, `v[i:j]`, work on lists and strings, with negative and
41
- missing bounds; a slice with a step is refused.
44
+ `string.sub`. Slices, `v[i:j]` and `v[i:j:k]`, work on lists and strings, with
45
+ negative and missing bounds, and bounds are clamped exactly as Python clamps
46
+ them. A negative step walks backwards, so `word[::-1]` reverses.
42
47
 
43
48
  ## Assignment scope is closed
44
49
 
@@ -50,8 +55,10 @@ the top of the script, so it does not silently read back `nil`.
50
55
 
51
56
  Lua's `+` is only arithmetic. It will add `"1" + "2"` to `3`. So `+`
52
57
  compiles to Lua's `..` wherever either side is known to be a string, as
53
- above, and `"=" * n` to `string.rep`. Where neither side is known, `+` stays
54
- arithmetic; use an f-string to concatenate two values of unknown type.
58
+ above, to Lua's `+` wherever either side is known to be a number, and
59
+ `"=" * n` to `string.rep`. Where neither side is known, as with two Redis
60
+ replies, a small `__add` helper decides at runtime: two strings are joined,
61
+ two lists are joined into a new list, and anything else is added.
55
62
 
56
63
  The string methods compile to Lua's string library, and to small helpers where
57
64
  Python means something Lua's own functions do not:
@@ -60,7 +67,9 @@ Python means something Lua's own functions do not:
60
67
  substring. `string.find` would read `.` as a pattern.
61
68
  - **`strip`, `lstrip` and `rstrip`** strip whitespace, and take no argument.
62
69
  - **`x in s`** is a substring test on a string. On a list it is an element test;
63
- on a dict it is a key test.
70
+ on a dict it is a key test. A table whose type is not known, such as a
71
+ decoded JSON value, is taken to be a dict when any of its keys is not a list
72
+ position, and a list otherwise.
64
73
 
65
74
  `"%s: %d" % (name, n)` and f-string format specs such as `{price:8.2f}` compile
66
75
  to `string.format`. Width, precision, sign and zero padding are supported. A
@@ -88,8 +97,10 @@ becomes the same kind of function, because `c and a or b` is wrong whenever
88
97
  `.items()`, `.keys()` and `.values()` compile to Lua's `pairs()`, which visits
89
98
  entries in no fixed order. Sort the result if the order reaches the caller.
90
99
 
91
- Iterating a dict directly with `for k in d` walks its array part, which a dict
92
- does not have, so the loop never runs. Say `.keys()`.
100
+ `for k in d` walks the keys of a dict the compiler knows to be one, as
101
+ `.keys()` does, and `len(d)` counts them. On a table whose type is not known,
102
+ say `.keys()`: there, `for k in d` and `len(d)` see only list positions, which
103
+ a dict does not have.
93
104
 
94
105
  ## Redis replies are flat lists, not dicts
95
106
 
@@ -149,7 +160,10 @@ kept, whether or not something catches it.
149
160
 
150
161
  ## A loop variable does not outlive its loop
151
162
 
152
- Unlike in Python.
163
+ Unlike in Python. Lua also gives each step of a loop a variable of its own,
164
+ which a function defined inside the loop would keep, where in Python every
165
+ step shares one. So a `lambda` or a helper function that reads a loop variable
166
+ is refused; pass the value in as a parameter instead.
153
167
 
154
168
  ## A nil inside a returned table truncates the reply
155
169
 
@@ -13,12 +13,14 @@ language with a faithful Lua meaning is accepted.
13
13
  `assert`.
14
14
  - **Loops:** `for ... in` over a table, `range()`, `enumerate()`, or a dict's
15
15
  `.items()`, `.keys()` and `.values()`, binding a name or a tuple of names.
16
- - **Helper functions** defined with `def` at the top level of the body. They
17
- can call each other and themselves.
18
- - **Expressions:** comparisons; arithmetic; `in` and `not in`; `and`/`or`, and
19
- `a if c else b`, both as values; list and dict literals.
20
- - **Subscripts:** indices, dict keys, negative literal indices on a name, and
21
- slices without a step, of lists and strings.
16
+ - **Helper functions** defined with `def` at the top level of the body, and
17
+ `lambda` wherever an expression can go. They can call each other and
18
+ themselves, and be passed to a helper that calls them.
19
+ - **Expressions:** comparisons, chained ones such as `0 < n <= limit`
20
+ included; arithmetic; `in` and `not in`; `and`/`or`, and `a if c else b`,
21
+ both as values; list and dict literals.
22
+ - **Subscripts:** indices, dict keys of any type, negative literal indices on
23
+ a name, and slices of lists and strings, with a step or without.
22
24
  - **Strings:** f-strings with format specs; `%` formatting; `+` and `*` on a
23
25
  string; `str.join`, `upper`, `lower`, `strip`, `lstrip`, `rstrip`,
24
26
  `startswith`, `endswith`, `find`, `split` and `replace`.
@@ -37,11 +39,11 @@ Everything else raises
37
39
  with a caret under the line at fault:
38
40
 
39
41
  ```
40
- chained comparisons are not supported
42
+ math.log() with a base is not supported
41
43
  File "/srv/app/limits.py", line 12
42
- if 0 < n < limit:
43
- ^
44
- hint: Split 'a < b < c' into 'a < b and b < c'.
44
+ buckets = math.log(n, 2)
45
+ ^
46
+ hint: Lua 5.1's math.log takes no base; divide by math.log(base) instead.
45
47
  ```
46
48
 
47
49
  Failing at import, loudly, is deliberate. A body that looks like Python but is
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.5.1"
3
+ version = "0.7.0"
4
4
  description = "Write Redis Lua scripts as real Python functions, not strings."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -41,6 +41,9 @@ dev = [
41
41
  "zensical>=0.0.61",
42
42
  # typing.assert_type, for tests/test_typing.py, arrived in Python 3.11.
43
43
  "typing_extensions>=4.2; python_version < '3.11'",
44
+ # Tested against, never required: coredis shapes a command differently
45
+ # from redis-py, and tests/test_coredis.py runs the same scripts through it.
46
+ "coredis>=6.9",
44
47
  ]
45
48
 
46
49
  [build-system]
@@ -77,8 +80,9 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "N", "C4", "PT"]
77
80
  python_version = "3.10"
78
81
  strict = true
79
82
  # test_typing.py carries assert_type() claims about what a call returns, which
80
- # are only worth anything if mypy actually reads them.
81
- files = ["src", "tests/test_typing.py"]
83
+ # are only worth anything if mypy actually reads them. generated_scripts.py is
84
+ # codegen output, checked in so that strict mode reads what adopters ship.
85
+ files = ["src", "tests/test_typing.py", "tests/generated_scripts.py"]
82
86
  mypy_path = "tests"
83
87
 
84
88
  # A script body returns Lua-side values, whose Python type is Any by
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.5.1" # x-release-please-version
70
+ __version__ = "0.7.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload