redis-lua-py 0.6.0__tar.gz → 0.8.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 (96) hide show
  1. redis_lua_py-0.8.0/.release-please-manifest.json +3 -0
  2. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/CHANGELOG.md +28 -0
  3. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/PKG-INFO +3 -3
  4. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/README.md +2 -2
  5. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/async.md +28 -0
  6. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/keys-and-arguments.md +9 -4
  7. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/redis-functions.md +6 -4
  8. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/return-values.md +3 -1
  9. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/index.md +3 -2
  10. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/installation.md +4 -0
  11. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/reference/errors.md +4 -4
  12. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/reference/lua-vs-python.md +32 -16
  13. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/reference/supported-subset.md +17 -14
  14. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/pyproject.toml +4 -1
  15. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/__init__.py +1 -1
  16. redis_lua_py-0.8.0/src/redis_lua_py/_compile/__init__.py +181 -0
  17. redis_lua_py-0.8.0/src/redis_lua_py/_compile/analysis.py +271 -0
  18. redis_lua_py-0.8.0/src/redis_lua_py/_compile/base.py +351 -0
  19. redis_lua_py-0.8.0/src/redis_lua_py/_compile/calls.py +298 -0
  20. redis_lua_py-0.8.0/src/redis_lua_py/_compile/control.py +343 -0
  21. redis_lua_py-0.8.0/src/redis_lua_py/_compile/expressions.py +458 -0
  22. redis_lua_py-0.8.0/src/redis_lua_py/_compile/helpers.py +213 -0
  23. redis_lua_py-0.8.0/src/redis_lua_py/_compile/scope.py +233 -0
  24. redis_lua_py-0.8.0/src/redis_lua_py/_compile/source.py +62 -0
  25. redis_lua_py-0.8.0/src/redis_lua_py/_compile/statements.py +317 -0
  26. redis_lua_py-0.8.0/src/redis_lua_py/_compile/tables.py +146 -0
  27. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/_library.py +38 -12
  28. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/_lua.py +9 -0
  29. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/_script.py +3 -2
  30. redis_lua_py-0.8.0/tests/test_booleans_and_conversions.py +162 -0
  31. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_commands.py +4 -3
  32. redis_lua_py-0.8.0/tests/test_coredis.py +163 -0
  33. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_errors.py +6 -37
  34. redis_lua_py-0.8.0/tests/test_expressions.py +174 -0
  35. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_namespace.py +8 -7
  36. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_strings_and_indexing.py +113 -5
  37. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_typing.py +19 -0
  38. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/uv.lock +186 -2
  39. redis_lua_py-0.6.0/.release-please-manifest.json +0 -3
  40. redis_lua_py-0.6.0/src/redis_lua_py/_compile.py +0 -2472
  41. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/README.md +0 -0
  42. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/banner-dark.svg +0 -0
  43. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/banner-light.svg +0 -0
  44. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/generate.py +0 -0
  45. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/logo.svg +0 -0
  46. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/logomark.svg +0 -0
  47. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/social-preview.png +0 -0
  48. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/assets/social-preview.svg +0 -0
  49. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/workflows/ci.yml +0 -0
  50. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/workflows/docs.yml +0 -0
  51. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/workflows/pr-title.yml +0 -0
  52. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.github/workflows/release.yml +0 -0
  53. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.gitignore +0 -0
  54. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/.python-version +0 -0
  55. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/CONTRIBUTING.md +0 -0
  56. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/LICENSE +0 -0
  57. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/assets/logo.svg +0 -0
  58. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/contributing.md +0 -0
  59. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/development.md +0 -0
  60. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/examples.md +0 -0
  61. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/binary-values.md +0 -0
  62. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/binding-a-client.md +0 -0
  63. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/build-time.md +0 -0
  64. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/calling-redis-commands.md +0 -0
  65. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/constants.md +0 -0
  66. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/generated-lua.md +0 -0
  67. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/guide/testing.md +0 -0
  68. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/quickstart.md +0 -0
  69. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/reference/api.md +0 -0
  70. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/docs/stylesheets/extra.css +0 -0
  71. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/release-please-config.json +0 -0
  72. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/scripts/generate_commands.py +0 -0
  73. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/__main__.py +0 -0
  74. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/_commands.py +0 -0
  75. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/_portable.py +0 -0
  76. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/_runtime.py +0 -0
  77. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/codegen.py +0 -0
  78. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/errors.py +0 -0
  79. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/src/redis_lua_py/py.typed +0 -0
  80. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/codegen_scripts.py +0 -0
  81. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/conftest.py +0 -0
  82. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/generated_scripts.py +0 -0
  83. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/module_with_client.py +0 -0
  84. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/module_without_import.py +0 -0
  85. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_binary.py +0 -0
  86. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_binding.py +0 -0
  87. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_codegen.py +0 -0
  88. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_compile.py +0 -0
  89. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_constants.py +0 -0
  90. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_control_flow.py +0 -0
  91. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_execute.py +0 -0
  92. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_functions.py +0 -0
  93. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_mistranslations.py +0 -0
  94. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_semantics.py +0 -0
  95. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/tests/test_table_stakes.py +0 -0
  96. {redis_lua_py-0.6.0 → redis_lua_py-0.8.0}/zensical.toml +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.8.0"
3
+ }
@@ -7,6 +7,34 @@ 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.8.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.7.0...v0.8.0) (2026-09-13)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * a bool parameter is a boolean in the body rather than "1" or "0", and integer conversion of a non-number raises instead of giving nil.
16
+
17
+ ### Fixed
18
+
19
+ * give bool arguments, int() and known booleans their python meaning ([#24](https://github.com/IgnaceMaes/redis-lua-py/issues/24)) ([27b3de8](https://github.com/IgnaceMaes/redis-lua-py/commit/27b3de8e7c12aea9076b940e6a7f369b054876f3))
20
+
21
+ ## [0.7.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.6.0...v0.7.0) (2026-09-12)
22
+
23
+
24
+ ### ⚠ BREAKING CHANGES
25
+
26
+ * 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.
27
+
28
+ ### Added
29
+
30
+ * compile lambdas, chained comparisons and slice steps, and tell dicts from lists ([6024009](https://github.com/IgnaceMaes/redis-lua-py/commit/6024009eaefeb20ccb743225c609799aa332f7c2))
31
+ * support coredis clients ([#22](https://github.com/IgnaceMaes/redis-lua-py/issues/22)) ([3d07a89](https://github.com/IgnaceMaes/redis-lua-py/commit/3d07a89f5b4c55eddca3a1f3bae5f45b41fd4156))
32
+
33
+
34
+ ### Changed
35
+
36
+ * 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))
37
+
10
38
  ## [0.6.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.5.1...v0.6.0) (2026-09-12)
11
39
 
12
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.6.0
3
+ Version: 0.8.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,7 +141,7 @@ 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/)**
@@ -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,7 +115,7 @@ 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/)**
@@ -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.
@@ -30,7 +30,7 @@ to it on the way in.
30
30
  | `Key` | `KEYS[n]`, and what the cluster routes on |
31
31
  | `int`, `float` | wrapped in `tonumber`, so it is a number by the time your comparison runs |
32
32
  | `str` | passed through as the string it already is |
33
- | `bool` | encoded as `"1"` or `"0"` |
33
+ | `bool` | encoded as `"1"` or `"0"`, and a Lua boolean in the body |
34
34
  | `bytes`, `memoryview` | passed through untouched — see [Binary values](binary-values.md) |
35
35
 
36
36
  ### `float` carries a caveat
@@ -41,10 +41,15 @@ text with `%.14g`, so a value with more significant digits than that does not
41
41
  come back as it went in. Annotate `str` and call `str()` at the call site when
42
42
  the value is only being carried.
43
43
 
44
- ### `bool` deletes a conditional
44
+ ### `bool` is a boolean in the body
45
45
 
46
- `bool` encodes to `"1"` or `"0"`. Paired with an `int` annotation that deletes
47
- the `1 if flag else 0` from the call site: pass `True`, and the body gets `1`.
46
+ `bool` travels as `"1"` or `"0"`, and arrives as `ARGV[n] == '1'`, so
47
+ `if flag:` means what it says. `"0"` is a non-empty string, which both Lua and
48
+ Python count as true, so passing the string through would make every flag set.
49
+
50
+ A Lua boolean cannot be a command argument, as in redis-py. Write `int(flag)`
51
+ where the body hands it to Redis, or annotate the parameter `int` instead:
52
+ pass `True`, and the body gets `1`, with no `1 if flag else 0` at the call site.
48
53
 
49
54
  ## A variable number of keys or arguments
50
55
 
@@ -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
 
@@ -30,7 +30,9 @@ def preview(doc: Key) -> list[int | bytes]:
30
30
 
31
31
  `b""` and `""` compile to the same Lua string; only one of them also describes
32
32
  what the caller receives, which keeps the body and the signature agreeing
33
- about the same thing.
33
+ about the same thing. For a string built in the body, `str(n).encode()` does
34
+ the same: Lua strings are already bytes, so `encode()` compiles to nothing, and
35
+ the body type-checks against `-> bytes`.
34
36
 
35
37
  Return values follow Redis' own conversion rules: `True` becomes `1`, `False`
36
38
  and `None` become nil, floats are truncated to integers. Return a string, or
@@ -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
@@ -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
@@ -4,9 +4,11 @@ These are the gaps that matter. Most are closed for you; the rest are refused.
4
4
 
5
5
  ## Truthiness is closed
6
6
 
7
- Lua counts `0` and `''` as true. Any condition that is not already a boolean is
8
- routed through a generated `__truthy` helper, so `if count:` means what it
9
- means in Python.
7
+ Lua counts `0` and `''` as true. Any condition that is not known to be a
8
+ boolean is routed through a generated `__truthy` helper, so `if count:` means
9
+ what it means in Python. A comparison, `not`, `startswith()`, `endswith()`, a
10
+ `bool` parameter, and a name only ever assigned one of those are used as they
11
+ are, and `and`/`or` between them compile to Lua's own operators.
10
12
 
11
13
  ## Missing values are closed
12
14
 
@@ -23,22 +25,27 @@ Lua tables are 1-based. `items[0]` compiles to `items[1]`. Write Python indices
23
25
  and let the compiler shift them.
24
26
 
25
27
  A dict key must not be shifted, and Lua cannot tell a list from a dict. So the
26
- compiler looks at the subscript:
28
+ compiler looks first at what is subscripted. A dict is indexed by its keys as
29
+ they are, integer keys included, and a list's index is shifted by one. When
30
+ that is not known, it looks at the subscript:
27
31
 
28
32
  - a string literal, or a value known to be a string, is used as it is;
29
33
  - an integer literal, or a value known to be a number, is shifted by one;
30
34
  - anything else goes through a small `__key` helper, which shifts numbers and
31
35
  leaves everything else alone, at runtime.
32
36
 
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.
37
+ A value's type is known from its annotation, a literal, the builtin, method or
38
+ operator that produced it, a `range()` or `enumerate()` loop variable, or
39
+ every assignment to the name agreeing. So after `counts = {}`, `counts[0]` is
40
+ the key `0`. A table that comes from elsewhere -- a Redis reply,
41
+ `cjson.decode`, a helper's parameter -- is not known, and an integer subscript
42
+ on it is taken to be a position.
37
43
 
38
44
  `items[-1]` compiles to `items[#items]`, which needs a name to count back from.
39
45
  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.
46
+ `string.sub`. Slices, `v[i:j]` and `v[i:j:k]`, work on lists and strings, with
47
+ negative and missing bounds, and bounds are clamped exactly as Python clamps
48
+ them. A negative step walks backwards, so `word[::-1]` reverses.
42
49
 
43
50
  ## Assignment scope is closed
44
51
 
@@ -50,8 +57,10 @@ the top of the script, so it does not silently read back `nil`.
50
57
 
51
58
  Lua's `+` is only arithmetic. It will add `"1" + "2"` to `3`. So `+`
52
59
  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.
60
+ above, to Lua's `+` wherever either side is known to be a number, and
61
+ `"=" * n` to `string.rep`. Where neither side is known, as with two Redis
62
+ replies, a small `__add` helper decides at runtime: two strings are joined,
63
+ two lists are joined into a new list, and anything else is added.
55
64
 
56
65
  The string methods compile to Lua's string library, and to small helpers where
57
66
  Python means something Lua's own functions do not:
@@ -60,7 +69,9 @@ Python means something Lua's own functions do not:
60
69
  substring. `string.find` would read `.` as a pattern.
61
70
  - **`strip`, `lstrip` and `rstrip`** strip whitespace, and take no argument.
62
71
  - **`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.
72
+ on a dict it is a key test. A table whose type is not known, such as a
73
+ decoded JSON value, is taken to be a dict when any of its keys is not a list
74
+ position, and a list otherwise.
64
75
 
65
76
  `"%s: %d" % (name, n)` and f-string format specs such as `{price:8.2f}` compile
66
77
  to `string.format`. Width, precision, sign and zero padding are supported. A
@@ -88,8 +99,10 @@ becomes the same kind of function, because `c and a or b` is wrong whenever
88
99
  `.items()`, `.keys()` and `.values()` compile to Lua's `pairs()`, which visits
89
100
  entries in no fixed order. Sort the result if the order reaches the caller.
90
101
 
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()`.
102
+ `for k in d` walks the keys of a dict the compiler knows to be one, as
103
+ `.keys()` does, and `len(d)` counts them. On a table whose type is not known,
104
+ say `.keys()`: there, `for k in d` and `len(d)` see only list positions, which
105
+ a dict does not have.
93
106
 
94
107
  ## Redis replies are flat lists, not dicts
95
108
 
@@ -149,7 +162,10 @@ kept, whether or not something catches it.
149
162
 
150
163
  ## A loop variable does not outlive its loop
151
164
 
152
- Unlike in Python.
165
+ Unlike in Python. Lua also gives each step of a loop a variable of its own,
166
+ which a function defined inside the loop would keep, where in Python every
167
+ step shares one. So a `lambda` or a helper function that reads a loop variable
168
+ is refused; pass the value in as a parameter instead.
153
169
 
154
170
  ## A nil inside a returned table truncates the reply
155
171
 
@@ -13,18 +13,21 @@ 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
- `startswith`, `endswith`, `find`, `split` and `replace`.
25
- - **Builtins and methods:** `len()`, `int()`, `float()`, `str()`, `min()`,
26
- `max()`, `abs()`, `ord()`, `chr()`, `.append()`, `.insert()`, `.pop()` and
27
- `dict.get()`.
26
+ `startswith`, `endswith`, `find`, `split` and `replace`; `encode()` and
27
+ `decode()` in UTF-8, which leave the bytes as they are.
28
+ - **Builtins and methods:** `len()`, `int()`, which truncates toward zero,
29
+ `float()`, `str()`, `min()`, `max()`, `abs()`, `ord()`, `chr()`, `.append()`,
30
+ `.insert()`, `.pop()` and `dict.get()`.
28
31
  - **The `math` module:** `floor`, `ceil`, `sqrt`, `fabs`, `fmod`, `exp`,
29
32
  `log`, `log10` and `pow`, imported either way.
30
33
  - **Calls:** into `redis` and `cjson`, with `*xs` allowed as the last argument.
@@ -37,11 +40,11 @@ Everything else raises
37
40
  with a caret under the line at fault:
38
41
 
39
42
  ```
40
- chained comparisons are not supported
43
+ math.log() with a base is not supported
41
44
  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'.
45
+ buckets = math.log(n, 2)
46
+ ^
47
+ hint: Lua 5.1's math.log takes no base; divide by math.log(base) instead.
45
48
  ```
46
49
 
47
50
  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.6.0"
3
+ version = "0.8.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]
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.6.0" # x-release-please-version
70
+ __version__ = "0.8.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload
@@ -0,0 +1,181 @@
1
+ """Turn a Python function into Lua.
2
+
3
+ The supported subset is deliberately small. Anything outside it raises
4
+ :class:`UnsupportedSyntax` pointing at the offending line, because a script
5
+ body that *looks* like Python but is never executed by Python is exactly the
6
+ place where a silent mistranslation would be most expensive.
7
+
8
+ The compiler itself is in layers, described in ``base``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import warnings
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any, TypeVar
18
+
19
+ from .. import _lua as lua
20
+ from .._script import CompiledScript
21
+ from ..errors import CompileError, NilTruncationWarning, render_location
22
+ from .analysis import unassigned_in_returns
23
+ from .helpers import helper_order, helper_source
24
+ from .source import parse_function, provenance
25
+ from .statements import Compiler
26
+
27
+ __all__ = [
28
+ "SCRIPT_FLAGS",
29
+ "CompiledBody",
30
+ "check_flags",
31
+ "compile_body",
32
+ "compile_function",
33
+ "helper_order",
34
+ "helper_source",
35
+ "provenance",
36
+ ]
37
+
38
+ R = TypeVar("R")
39
+
40
+
41
+ #: The flags Redis 7 defines, for a script's `#!lua` line and for a function.
42
+ SCRIPT_FLAGS = frozenset(
43
+ {"no-writes", "allow-oom", "allow-stale", "no-cluster", "allow-cross-slot-keys"}
44
+ )
45
+
46
+
47
+ def check_flags(flags: tuple[str, ...], *, owner: str) -> tuple[str, ...]:
48
+ """Refuse a flag Redis does not define, where the mistake is made."""
49
+ for flag in flags:
50
+ if flag not in SCRIPT_FLAGS:
51
+ raise CompileError(
52
+ f"{owner}: unknown flag {flag!r}; Redis defines {', '.join(sorted(SCRIPT_FLAGS))}"
53
+ )
54
+ return flags
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CompiledBody:
59
+ """A function compiled to Lua, before it is framed as a script or a library function."""
60
+
61
+ name: str
62
+ #: The prelude binding KEYS and ARGV, and the statements; no helpers.
63
+ body: str
64
+ #: The helpers the body calls, in the order they must be emitted.
65
+ helpers: tuple[str, ...]
66
+ params: tuple[str, ...]
67
+ keys: tuple[str, ...]
68
+ args: tuple[str, ...]
69
+ variadic_key: str | None
70
+ variadic_arg: str | None
71
+ doc: str | None
72
+ filename: str
73
+ first_lineno: int
74
+ #: Each parameter's annotation as written, in the order of ``params``.
75
+ param_annotations: tuple[str | None, ...] = ()
76
+ return_annotation: str | None = None
77
+ keyword_only: tuple[str, ...] = ()
78
+
79
+ @property
80
+ def provenance(self) -> str:
81
+ return f"{provenance(self.filename)}:{self.first_lineno}"
82
+
83
+
84
+ def compile_function(
85
+ func: Callable[..., R],
86
+ *,
87
+ name: str | None = None,
88
+ header: bool = True,
89
+ flags: tuple[str, ...] = (),
90
+ ) -> CompiledScript[R]:
91
+ compiled = compile_body(func, name=name)
92
+ parts: list[str] = []
93
+ if flags:
94
+ # Redis only reads a script's flags from a shebang on its first line.
95
+ parts.append(f"#!lua flags={','.join(flags)}")
96
+ if header:
97
+ parts.append(
98
+ f"-- {compiled.name}\n"
99
+ f"-- Generated by redis-lua-py from {compiled.provenance}. Do not edit."
100
+ )
101
+ parts.extend(helper_source(helper) for helper in compiled.helpers)
102
+ parts.append(compiled.body)
103
+
104
+ return CompiledScript(
105
+ name=compiled.name,
106
+ lua="\n".join(parts) + "\n",
107
+ params=compiled.params,
108
+ keys=compiled.keys,
109
+ args=compiled.args,
110
+ variadic_key=compiled.variadic_key,
111
+ variadic_arg=compiled.variadic_arg,
112
+ doc=compiled.doc,
113
+ source=f"{compiled.filename}:{compiled.first_lineno}",
114
+ param_annotations=compiled.param_annotations,
115
+ return_annotation=compiled.return_annotation,
116
+ keyword_only=compiled.keyword_only,
117
+ )
118
+
119
+
120
+ def compile_body(func: Callable[..., Any], *, name: str | None = None) -> CompiledBody:
121
+ node, filename, first_lineno, lines = parse_function(func)
122
+ compiler = Compiler(
123
+ node,
124
+ filename=filename,
125
+ first_lineno=first_lineno,
126
+ lines=lines,
127
+ # Receivers are resolved against the defining module, so the namespace
128
+ # is recognised under whatever name it was imported as.
129
+ globalns=getattr(func, "__globals__", {}),
130
+ )
131
+
132
+ prelude = compiler.compile_signature()
133
+ compiler.collect_assigned()
134
+
135
+ body = node.body
136
+ doc = ast.get_docstring(node)
137
+ if doc is not None:
138
+ body = body[1:]
139
+
140
+ statements = compiler.block(body)
141
+ if compiler.hoisted:
142
+ prelude.append(lua.Local(sorted(set(compiler.hoisted)), []))
143
+
144
+ for element, unassigned in unassigned_in_returns(body, set(compiler.params), compiler.known):
145
+ warnings.warn_explicit(
146
+ render_location(
147
+ f"{unassigned!r} is not assigned on every path to this return, and a nil "
148
+ "in a returned table truncates the reply there",
149
+ filename=filename,
150
+ lineno=first_lineno + element.lineno - 1,
151
+ col=element.col_offset,
152
+ source_line=lines[element.lineno - 1] if element.lineno <= len(lines) else None,
153
+ hint="Give it a value before the branch, so that every branch returns a "
154
+ "table of the same shape.",
155
+ ),
156
+ NilTruncationWarning,
157
+ filename,
158
+ first_lineno + element.lineno - 1,
159
+ )
160
+
161
+ return CompiledBody(
162
+ name=name or func.__name__,
163
+ body=lua.emit(prelude + statements).rstrip(),
164
+ helpers=tuple(helper_order(compiler.helpers)),
165
+ params=tuple(compiler.params),
166
+ keys=tuple(compiler.keys),
167
+ args=tuple(compiler.args),
168
+ variadic_key=compiler.variadic_key,
169
+ variadic_arg=compiler.variadic_arg,
170
+ doc=doc,
171
+ filename=filename,
172
+ first_lineno=first_lineno,
173
+ # Kept as source text: the compiler does not need them, but a generated
174
+ # function repeats them in its signature.
175
+ param_annotations=tuple(
176
+ None if arg.annotation is None else ast.unparse(arg.annotation)
177
+ for arg in [*node.args.args, *node.args.kwonlyargs]
178
+ ),
179
+ return_annotation=None if node.returns is None else ast.unparse(node.returns),
180
+ keyword_only=tuple(arg.arg for arg in node.args.kwonlyargs),
181
+ )