redis-lua-py 0.11.0__tar.gz → 0.13.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.11.0 → redis_lua_py-0.13.0}/.github/workflows/ci.yml +3 -0
  2. redis_lua_py-0.13.0/.release-please-manifest.json +3 -0
  3. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/CHANGELOG.md +14 -0
  4. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/PKG-INFO +2 -2
  5. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/README.md +1 -1
  6. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/examples.md +2 -2
  7. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/build-time.md +7 -1
  8. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/generated-lua.md +1 -1
  9. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/keys-and-arguments.md +17 -8
  10. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/redis-functions.md +1 -1
  11. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/installation.md +27 -1
  12. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/quickstart.md +1 -1
  13. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/reference/lua-vs-python.md +10 -2
  14. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/reference/supported-subset.md +7 -4
  15. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/pyproject.toml +5 -1
  16. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/__init__.py +1 -1
  17. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/analysis.py +27 -1
  18. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/base.py +15 -3
  19. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/calls.py +12 -2
  20. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/helpers.py +9 -0
  21. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/scope.py +47 -4
  22. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/statements.py +1 -0
  23. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/tables.py +17 -2
  24. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_portable.py +11 -6
  25. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/codegen.py +1 -1
  26. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/generated_scripts.py +8 -8
  27. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_binary.py +2 -1
  28. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_codegen.py +117 -0
  29. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_compile.py +21 -1
  30. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_errors.py +22 -0
  31. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_expressions.py +14 -0
  32. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_semantics.py +29 -0
  33. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_strings_and_indexing.py +63 -0
  34. redis_lua_py-0.11.0/.release-please-manifest.json +0 -3
  35. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/README.md +0 -0
  36. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/banner-dark.svg +0 -0
  37. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/banner-light.svg +0 -0
  38. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/generate.py +0 -0
  39. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/logo.svg +0 -0
  40. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/logomark.svg +0 -0
  41. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/social-preview.png +0 -0
  42. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/assets/social-preview.svg +0 -0
  43. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/workflows/docs.yml +0 -0
  44. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/workflows/pr-title.yml +0 -0
  45. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.github/workflows/release.yml +0 -0
  46. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.gitignore +0 -0
  47. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/.python-version +0 -0
  48. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/CONTRIBUTING.md +0 -0
  49. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/LICENSE +0 -0
  50. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/assets/logo.svg +0 -0
  51. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/contributing.md +0 -0
  52. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/development.md +0 -0
  53. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/async.md +0 -0
  54. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/binary-values.md +0 -0
  55. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/binding-a-client.md +0 -0
  56. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/calling-redis-commands.md +0 -0
  57. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/constants.md +0 -0
  58. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/return-values.md +0 -0
  59. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/guide/testing.md +0 -0
  60. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/index.md +0 -0
  61. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/reference/api.md +0 -0
  62. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/reference/errors.md +0 -0
  63. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/docs/stylesheets/extra.css +0 -0
  64. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/release-please-config.json +0 -0
  65. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/scripts/generate_commands.py +0 -0
  66. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/__main__.py +0 -0
  67. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_command_stubs.py +0 -0
  68. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_commands.py +0 -0
  69. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/__init__.py +0 -0
  70. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/control.py +0 -0
  71. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/expressions.py +0 -0
  72. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/source.py +0 -0
  73. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_library.py +0 -0
  74. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_lua.py +0 -0
  75. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_runtime.py +0 -0
  76. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_script.py +0 -0
  77. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/errors.py +0 -0
  78. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/src/redis_lua_py/py.typed +0 -0
  79. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/codegen_scripts.py +0 -0
  80. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/conftest.py +0 -0
  81. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/module_with_client.py +0 -0
  82. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/module_without_import.py +0 -0
  83. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_binding.py +0 -0
  84. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_booleans_and_conversions.py +0 -0
  85. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_commands.py +0 -0
  86. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_constants.py +0 -0
  87. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_control_flow.py +0 -0
  88. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_coredis.py +0 -0
  89. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_execute.py +0 -0
  90. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_functions.py +0 -0
  91. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_mistranslations.py +0 -0
  92. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_namespace.py +0 -0
  93. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_table_stakes.py +0 -0
  94. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/tests/test_typing.py +0 -0
  95. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/uv.lock +0 -0
  96. {redis_lua_py-0.11.0 → redis_lua_py-0.13.0}/zensical.toml +0 -0
@@ -32,6 +32,9 @@ jobs:
32
32
  with:
33
33
  enable-cache: true
34
34
  - run: uv sync --dev --python ${{ matrix.python-version }}
35
+ # Generated modules must still run on 3.9, which libraries vendoring
36
+ # them may support; tests/test_codegen.py runs one there when it can.
37
+ - run: uv python install 3.9
35
38
  - run: uv run pytest
36
39
 
37
40
  oldest:
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.13.0"
3
+ }
@@ -7,6 +7,20 @@ 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.13.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.12.0...v0.13.0) (2026-09-26)
11
+
12
+
13
+ ### Added
14
+
15
+ * run generated modules on python 3.9 and keep forwarded numbers exact ([#34](https://github.com/IgnaceMaes/redis-lua-py/issues/34)) ([7178211](https://github.com/IgnaceMaes/redis-lua-py/commit/7178211f73e2503188240d62ebe8ab18278bdba2))
16
+
17
+ ## [0.12.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.11.0...v0.12.0) (2026-09-25)
18
+
19
+
20
+ ### Added
21
+
22
+ * str.isdigit, partition and annotated locals ([#32](https://github.com/IgnaceMaes/redis-lua-py/issues/32)) ([38e863c](https://github.com/IgnaceMaes/redis-lua-py/commit/38e863c96a89cd0c2a82d0bc59d73771f295d164))
23
+
10
24
  ## [0.11.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.10.0...v0.11.0) (2026-09-25)
11
25
 
12
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.11.0
3
+ Version: 0.13.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/
@@ -103,7 +103,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
103
103
  -- Generated by redis-lua-py from src/limits.py:6. Do not edit.
104
104
  local key = KEYS[1]
105
105
  local limit = tonumber(ARGV[1])
106
- local ttl = tonumber(ARGV[2])
106
+ local ttl = ARGV[2]
107
107
  local current = redis.call('INCR', key)
108
108
  if current == 1 then
109
109
  redis.call('EXPIRE', key, ttl)
@@ -78,7 +78,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
78
78
  -- Generated by redis-lua-py from src/limits.py:6. Do not edit.
79
79
  local key = KEYS[1]
80
80
  local limit = tonumber(ARGV[1])
81
- local ttl = tonumber(ARGV[2])
81
+ local ttl = ARGV[2]
82
82
  local current = redis.call('INCR', key)
83
83
  if current == 1 then
84
84
  redis.call('EXPIRE', key, ttl)
@@ -42,8 +42,8 @@ def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[bytes]
42
42
  ```lua
43
43
  local queue = KEYS[1]
44
44
  local processing = KEYS[2]
45
- local now = tonumber(ARGV[1])
46
- local limit = tonumber(ARGV[2])
45
+ local now = ARGV[1]
46
+ local limit = ARGV[2]
47
47
  local ids = redis.call('ZRANGEBYSCORE', queue, 0, now, 'LIMIT', 0, limit)
48
48
  local claimed = {}
49
49
  for __i1 = 1, #ids do
@@ -12,6 +12,12 @@ develop. What you ship is that module: plain Python that needs only the
12
12
  standard library, with a typed function per script, called exactly the way
13
13
  the `@script` would be.
14
14
 
15
+ redis-lua-py itself needs Python 3.10 to generate the module, but what it
16
+ generates runs on Python 3.9 as well, so a library that still supports 3.9 can
17
+ ship it. Its annotations may use `X | Y`, since `from __future__ import
18
+ annotations` keeps them from being evaluated; everything that runs is 3.9
19
+ code.
20
+
15
21
  ## Generate a module
16
22
 
17
23
  Keep the scripts outside the package you ship, and redis-lua-py in your
@@ -68,7 +74,7 @@ RATE_LIMIT = """\
68
74
  -- Generated by redis-lua-py from redis_scripts/limits.py:6. Do not edit.
69
75
  local key = KEYS[1]
70
76
  local limit = tonumber(ARGV[1])
71
- local ttl = tonumber(ARGV[2])
77
+ local ttl = ARGV[2]
72
78
  local current = redis.call('INCR', key)
73
79
  if current == 1 then
74
80
  redis.call('EXPIRE', key, ttl)
@@ -11,7 +11,7 @@ Nothing is hidden. Every script exposes the Lua it produced:
11
11
  -- Generated by redis-lua-py from src/limits.py:6. Do not edit.
12
12
  local key = KEYS[1]
13
13
  local limit = tonumber(ARGV[1])
14
- local ttl = tonumber(ARGV[2])
14
+ local ttl = ARGV[2]
15
15
  local current = redis.call('INCR', key)
16
16
  if current == 1 then
17
17
  redis.call('EXPIRE', key, ttl)
@@ -11,7 +11,7 @@ def rate_limit(key: Key, limit: int, ttl: int) -> int: ...
11
11
  ```lua
12
12
  local key = KEYS[1]
13
13
  local limit = tonumber(ARGV[1])
14
- local ttl = tonumber(ARGV[2])
14
+ local ttl = ARGV[2]
15
15
  ```
16
16
 
17
17
  !!! warning "Annotate every key"
@@ -28,18 +28,27 @@ to it on the way in.
28
28
  | Annotation | In the script |
29
29
  | --- | --- |
30
30
  | `Key` | `KEYS[n]`, and what the cluster routes on |
31
- | `int`, `float` | wrapped in `tonumber`, so it is a number by the time your comparison runs |
31
+ | `int`, `float` | wrapped in `tonumber`, so it is a number by the time your comparison runs; left as text when the body only hands it to Redis |
32
32
  | `str` | passed through as the string it already is |
33
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
+ ### Numbers only handed to Redis stay text
37
+
38
+ In `rate_limit`, `ttl` is never compared or added to, only passed to `EXPIRE`,
39
+ so it is left as the text it arrived as: `local ttl = ARGV[2]`. A Lua number is
40
+ a double, which rounds an integer past 2^53, and which older servers write back
41
+ with 17 digits, `0.1` as `0.10000000000000001`. Left as text, the value reaches
42
+ Redis exactly as the caller passed it. The parameter is still an `int` to the
43
+ caller, and it is converted as soon as the body does anything else with it.
44
+
36
45
  ### `float` carries a caveat
37
46
 
38
- Annotate `float` for arithmetic, not for a value you mean to write back
39
- unchanged. `tonumber` makes it a Lua number, and Lua renders a number back to
40
- text with `%.14g`, so a value with more significant digits than that does not
41
- come back as it went in. Annotate `str` and call `str()` at the call site when
42
- the value is only being carried.
47
+ Annotate `float` for arithmetic, not for a value you mean to return unchanged.
48
+ Unless the body only hands it to Redis, it is a Lua number, and Lua renders a
49
+ number back to text with `%.14g`, so a value with more significant digits than that
50
+ does not come back as it went in. Annotate `str` and call `str()` at the call
51
+ site when the value is only being carried.
43
52
 
44
53
  ### `bool` is a boolean in the body
45
54
 
@@ -70,7 +79,7 @@ delete_tagged(client, tag="purged", keys=["a", "b", "c"], stamp=1700000000)
70
79
 
71
80
  ```lua
72
81
  local tag = KEYS[1]
73
- local stamp = tonumber(ARGV[1])
82
+ local stamp = ARGV[1]
74
83
  local keys = {}
75
84
  for __i1 = 2, #KEYS do
76
85
  keys[#keys + 1] = KEYS[__i1]
@@ -82,7 +82,7 @@ redis.register_function{
82
82
  function_name = 'hit',
83
83
  callback = function(KEYS, ARGV)
84
84
  local key = KEYS[1]
85
- local ttl = tonumber(ARGV[1])
85
+ local ttl = ARGV[1]
86
86
  local current = redis.call('INCR', key)
87
87
  if current == 1 then
88
88
  redis.call('EXPIRE', key, ttl)
@@ -12,7 +12,8 @@ pip install redis-lua-py
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Python 3.10 or newer
15
+ - Python 3.10 or newer. Modules [generated ahead of time](guide/build-time.md)
16
+ run on Python 3.9 too, without this package installed.
16
17
  - A Redis client: [redis-py](https://github.com/redis/redis-py) 4.2 or newer,
17
18
  or [coredis](https://github.com/alisaifee/coredis)
18
19
 
@@ -28,6 +29,31 @@ cluster, Sentinel, or an in-process [fakeredis](guide/testing.md).
28
29
  [coredis](https://github.com/alisaifee/coredis) clients work as well, and are
29
30
  tested against. See [Async](guide/async.md#coredis).
30
31
 
32
+ ### A client wrapper
33
+
34
+ An application that wraps its client, to prefix keys per tenant for example,
35
+ needs one method: `register_script(source)`. It returns a callable with the
36
+ shape of redis-py's `Script`, `(keys=..., args=..., client=...)`, and a script
37
+ runs through it:
38
+
39
+ ```python
40
+ class PrefixedScript:
41
+ def __init__(self, owner: "PrefixedClient", script: Script) -> None:
42
+ self._owner, self._script = owner, script
43
+
44
+ def __call__(self, keys=(), args=(), client=None):
45
+ keys = [f"{self._owner.prefix}:{key}" for key in keys]
46
+ return self._script(keys=keys, args=args)
47
+
48
+
49
+ class PrefixedClient:
50
+ def register_script(self, source: str) -> PrefixedScript:
51
+ return PrefixedScript(self, self._redis.register_script(source))
52
+ ```
53
+
54
+ The wrapper's other methods never come into it, so an `eval` with its own
55
+ signature is fine.
56
+
31
57
  ## For testing
32
58
 
33
59
  [fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
@@ -56,7 +56,7 @@ print(rate_limit.lua)
56
56
  -- Generated by redis-lua-py from limits.py:5. Do not edit.
57
57
  local key = KEYS[1]
58
58
  local limit = tonumber(ARGV[1])
59
- local ttl = tonumber(ARGV[2])
59
+ local ttl = ARGV[2]
60
60
  local current = redis.call('INCR', key)
61
61
  if current == 1 then
62
62
  redis.call('EXPIRE', key, ttl)
@@ -55,9 +55,17 @@ that is not known, it looks at the subscript:
55
55
  A value's type is known from its annotation, a literal, the builtin, method or
56
56
  operator that produced it, a `range()` or `enumerate()` loop variable, or
57
57
  every assignment to the name agreeing. So after `counts = {}`, `counts[0]` is
58
- the key `0`. A table that comes from elsewhere -- a Redis reply,
58
+ the key `0`. A value that comes from elsewhere -- a Redis reply,
59
59
  `cjson.decode`, a helper's parameter -- is not known, and an integer subscript
60
- on it is taken to be a position.
60
+ on it is taken to be a table position. For a string, that reads `nil`, so
61
+ annotate the local to say what it holds:
62
+
63
+ ```python
64
+ text: str | None = redis.get(key)
65
+ ```
66
+
67
+ An annotation on a local is a declaration, not a conversion: it decides how
68
+ the value is indexed and added, and changes nothing at runtime.
61
69
 
62
70
  `items[-1]` compiles to `items[#items]`, which needs a name to count back from.
63
71
  Indexing a string gives a one-character string, as it does in Python, through
@@ -5,8 +5,9 @@ language with a faithful Lua meaning is accepted.
5
5
 
6
6
  **Supported:**
7
7
 
8
- - **Statements:** assignment, including unpacking (`a, b = b, a`);
9
- augmented assignment; `if`/`elif`/`else`; `while`; `break`; `continue`;
8
+ - **Statements:** assignment, including unpacking (`a, b = b, a`) and
9
+ annotated assignment (`text: str = redis.get(key)`), whose annotation tells
10
+ the compiler the value's type; augmented assignment; `if`/`elif`/`else`; `while`; `break`; `continue`;
10
11
  `return`.
11
12
  - **Errors:** `try` with one `except` (bare or `Exception`), `else` and
12
13
  `finally`; `raise SomeError("message")`, and a bare `raise` inside `except`;
@@ -23,8 +24,10 @@ language with a faithful Lua meaning is accepted.
23
24
  a name, and slices of lists and strings, with a step or without.
24
25
  - **Strings:** f-strings with format specs; `%` formatting; `+` and `*` on a
25
26
  string; `str.join`, `upper`, `lower`, `strip`, `lstrip`, `rstrip`,
26
- `startswith`, `endswith`, `find`, `split` and `replace`; `encode()` and
27
- `decode()` in UTF-8, which leave the bytes as they are.
27
+ `startswith`, `endswith`, `find`, `split`, `partition` and `replace`;
28
+ `isdigit`, `isalpha`, `isalnum` and `isspace`, which test ASCII as they do
29
+ on bytes; `encode()` and `decode()` in UTF-8, which leave the bytes as they
30
+ are.
28
31
  - **Builtins and methods:** `len()`, `int()`, which truncates toward zero,
29
32
  `float()`, `str()`, `min()`, `max()`, `abs()`, `ord()`, `chr()`, `.append()`,
30
33
  `.insert()`, `.pop()` and `dict.get()`; `isinstance()` against `str`,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.11.0"
3
+ version = "0.13.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"
@@ -65,6 +65,10 @@ addopts = "-q"
65
65
  src = ["src", "tests"]
66
66
  line-length = 100
67
67
  target-version = "py310"
68
+ # Generated modules must import on Python 3.9, which libraries that vendor them
69
+ # may still support. Linting what they are built from as 3.9 code keeps a
70
+ # 3.10-only construct out of them, and keeps ruff from asking for one.
71
+ per-file-target-version = { "src/redis_lua_py/_portable.py" = "py39", "tests/generated_scripts.py" = "py39" }
68
72
 
69
73
  [tool.ruff.lint]
70
74
  select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "N", "C4", "PT"]
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.11.0" # x-release-please-version
70
+ __version__ = "0.13.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import ast
6
- from collections.abc import Iterator
6
+ from collections.abc import Callable, Iterator
7
7
 
8
8
  from .. import _lua as lua
9
9
 
@@ -134,6 +134,32 @@ def walk_scope(root: ast.AST) -> Iterator[ast.AST]:
134
134
  pending.extend(ast.iter_child_nodes(node))
135
135
 
136
136
 
137
+ def only_forwarded(
138
+ root: ast.FunctionDef, name: str, is_command: Callable[[ast.Call], bool]
139
+ ) -> bool:
140
+ """True if every use of a name is a whole argument to a Redis command.
141
+
142
+ Such a value is text going back to Redis, whatever it is annotated as.
143
+ Anything else -- arithmetic, a comparison, an assignment to it, a use in
144
+ a nested function that may shadow it -- makes the answer False, as does
145
+ no use at all.
146
+ """
147
+ forwarded = {
148
+ id(arg)
149
+ for statement in root.body
150
+ for node in ast.walk(statement)
151
+ if isinstance(node, ast.Call) and is_command(node)
152
+ for arg in node.args
153
+ }
154
+ uses = [
155
+ node
156
+ for statement in root.body
157
+ for node in ast.walk(statement)
158
+ if isinstance(node, ast.Name) and node.id == name
159
+ ]
160
+ return bool(uses) and all(id(use) in forwarded for use in uses)
161
+
162
+
137
163
  def target_names(target: ast.expr) -> list[str]:
138
164
  """The names an assignment or loop target binds."""
139
165
  if isinstance(target, ast.Name):
@@ -26,7 +26,7 @@ from .. import _lua as lua
26
26
  from .._runtime import _Namespace
27
27
  from ..errors import UnsupportedSyntax
28
28
  from .analysis import UNBOUND, as_literal, binop_kind, dotted_name, value_kind
29
- from .tables import MATH_BY_OBJECT, RECEIVER_FALLBACK, REDIS_CONSTANTS
29
+ from .tables import MATH_BY_OBJECT, RECEIVER_FALLBACK, REDIS_CONSTANTS, STRING_TESTS
30
30
 
31
31
 
32
32
  def is_redis_py(value: object) -> bool:
@@ -82,6 +82,9 @@ class CompilerBase(ABC):
82
82
  self.helpers: set[str] = set()
83
83
  # What a parameter or loop variable is known to be; see kind().
84
84
  self.kinds: dict[str, str] = {}
85
+ # Kinds that an annotation on a local declares. They are the author's
86
+ # word, so they win over what the assigned values suggest.
87
+ self.declared: dict[str, str] = {}
85
88
  # Every value assigned to each name, to work out what a local holds.
86
89
  self.values: dict[str, list[ast.expr]] = {}
87
90
  # Names bound in ways that say nothing about their type.
@@ -293,6 +296,13 @@ class CompilerBase(ABC):
293
296
  return "str"
294
297
  if name in {"int", "float", "tonumber", "len", "abs", "ord"}:
295
298
  return "num"
299
+ if name in {"min", "max"} and node.args:
300
+ # Python also takes the least of strings, or of one list,
301
+ # so only numbers in make a number out.
302
+ sides = {self.kind(arg) for arg in node.args}
303
+ if sides <= {"num", "?"}:
304
+ return "?" if sides == {"?"} else "num"
305
+ return None
296
306
  return "num" if self.math_attr(node.func) is not None else None
297
307
  case ast.Call(func=ast.Attribute(value=receiver, attr=attr)):
298
308
  if self.math_attr(node.func) is not None:
@@ -303,9 +313,9 @@ class CompilerBase(ABC):
303
313
  return "str"
304
314
  if attr in {"encode", "decode"}:
305
315
  return "str"
306
- if attr in {"startswith", "endswith"}:
316
+ if attr in {"startswith", "endswith", *STRING_TESTS}:
307
317
  return "bool"
308
- if attr == "split":
318
+ if attr in {"split", "partition"}:
309
319
  return "list"
310
320
  return "num" if attr == "find" else None
311
321
  case ast.Attribute():
@@ -321,6 +331,8 @@ class CompilerBase(ABC):
321
331
  def name_kind(self, name: str) -> str | None:
322
332
  if name not in self.known:
323
333
  return value_kind(self.globalns.get(name, UNBOUND))
334
+ if name in self.declared:
335
+ return self.declared[name]
324
336
  if name in self._resolving:
325
337
  # A name whose value depends on itself, as in `n += 1`, is whatever
326
338
  # its other assignments make it.
@@ -18,6 +18,7 @@ from .tables import (
18
18
  METHOD_HINT,
19
19
  REDIS_DIRECT,
20
20
  STRING_METHODS,
21
+ STRING_TESTS,
21
22
  )
22
23
 
23
24
 
@@ -26,7 +27,12 @@ class CallCompiler(ExpressionCompiler):
26
27
 
27
28
  def call(self, node: ast.Call) -> lua.Expr:
28
29
  if node.keywords:
29
- self.fail(node, "keyword arguments are not supported in a script body")
30
+ self.fail(
31
+ node,
32
+ "keyword arguments are not supported in a script body",
33
+ hint="Pass a Redis command's options positionally, as its syntax "
34
+ "writes them: redis.set(key, value, 'EX', ttl), not ex=ttl.",
35
+ )
30
36
  if (
31
37
  isinstance(node.func, ast.Name)
32
38
  and node.func.id == "isinstance"
@@ -205,7 +211,8 @@ class CallCompiler(ExpressionCompiler):
205
211
  return target
206
212
  allowed = {"get": (1, 2), "split": (0, 1), "replace": (2,)}.get(attr)
207
213
  if allowed is None:
208
- allowed = (0,) if attr in {"upper", "lower", "strip", "lstrip", "rstrip"} else (1,)
214
+ no_args = {"upper", "lower", "strip", "lstrip", "rstrip", *STRING_TESTS}
215
+ allowed = (0,) if attr in no_args else (1,)
209
216
  if len(args) not in allowed:
210
217
  if attr in {"strip", "lstrip", "rstrip"}:
211
218
  self.fail(
@@ -218,6 +225,9 @@ class CallCompiler(ExpressionCompiler):
218
225
 
219
226
  if attr in {"upper", "lower"}:
220
227
  return lua.Call(lua.Global(f"string.{attr}"), (target,))
228
+ if attr in STRING_TESTS:
229
+ found = lua.Call(lua.Global("string.find"), (target, lua.Str(STRING_TESTS[attr])))
230
+ return lua.BinOp("~=", found, lua.Nil())
221
231
  if attr in {"strip", "lstrip", "rstrip"}:
222
232
  pattern = {"strip": "^%s*(.-)%s*$", "lstrip": "^%s*(.*)$", "rstrip": "^(.-)%s*$"}
223
233
  return lua.Call(lua.Global("string.match"), (target, lua.Str(pattern[attr])))
@@ -149,6 +149,15 @@ local function __find(s, sub)
149
149
  local i = string.find(s, sub, 1, true)
150
150
  if i == nil then return -1 end
151
151
  return i - 1
152
+ end""",
153
+ "__partition": """\
154
+ -- str.partition: before, separator and after the first plain match, or the
155
+ -- whole string and two empty strings when there is none.
156
+ local function __partition(s, sep)
157
+ if sep == '' then error('empty separator', 0) end
158
+ local i, j = string.find(s, sep, 1, true)
159
+ if i == nil then return {s, '', ''} end
160
+ return {string.sub(s, 1, i - 1), sep, string.sub(s, j + 1)}
152
161
  end""",
153
162
  "__split": """\
154
163
  -- str.split: on a plain separator, or on runs of whitespace without one.
@@ -5,8 +5,9 @@ from __future__ import annotations
5
5
  import ast
6
6
 
7
7
  from .. import _lua as lua
8
- from .analysis import declares_exactly, target_names, walk_scope
8
+ from .analysis import declares_exactly, only_forwarded, target_names, walk_scope
9
9
  from .base import CompilerBase
10
+ from .tables import REDIS_DIRECT
10
11
 
11
12
 
12
13
  class ScopeCompiler(CompilerBase):
@@ -44,6 +45,10 @@ class ScopeCompiler(CompilerBase):
44
45
  node.target, ast.Name
45
46
  ):
46
47
  record(node.target.id, node)
48
+ if isinstance(node, ast.AnnAssign):
49
+ declared = self._annotation_kind(self._without_none(node.annotation))
50
+ if declared is not None:
51
+ self.declared.setdefault(node.target.id, declared)
47
52
  if isinstance(node, ast.AugAssign):
48
53
  value: ast.expr = ast.BinOp(left=node.target, op=node.op, right=node.value)
49
54
  self.values.setdefault(node.target.id, []).append(value)
@@ -118,7 +123,12 @@ class ScopeCompiler(CompilerBase):
118
123
  self.variadic_key = name
119
124
  else:
120
125
  if self.variadic_arg is not None:
121
- self.fail(arg, "only one list parameter of arguments is supported")
126
+ self.fail(
127
+ arg,
128
+ "only one list parameter of arguments is supported",
129
+ hint="Interleave the values in one list and index it "
130
+ "with a stride, as in values[3 * i + 1].",
131
+ )
122
132
  self.variadic_arg = name
123
133
  if self._is_numeric(item):
124
134
  self.numeric_args.add(name)
@@ -132,9 +142,12 @@ class ScopeCompiler(CompilerBase):
132
142
  source = lua.Index(lua.Global("ARGV"), lua.Num(len(self.args)))
133
143
  if self._is_numeric(arg.annotation):
134
144
  # ARGV always arrives as strings; an int/float annotation
135
- # is the author asking for the conversion.
145
+ # is the author asking for the conversion. Unless the value
146
+ # only goes back to Redis: a Lua number is a double, so
147
+ # converting would round an integer past 2^53 on the way.
136
148
  self.numeric_args.add(name)
137
- source = lua.Call(lua.Global("tonumber"), (source,))
149
+ if not only_forwarded(self.func, name, self._is_command):
150
+ source = lua.Call(lua.Global("tonumber"), (source,))
138
151
  elif self._annotation_name(arg.annotation) == "bool":
139
152
  # A bool arrives as "1" or "0", and "0" is a string Lua and
140
153
  # Python both count as true. Comparing makes it a boolean.
@@ -176,6 +189,16 @@ class ScopeCompiler(CompilerBase):
176
189
  return node.value.rsplit(".", 1)[-1].split("[", 1)[0]
177
190
  return None
178
191
 
192
+ def _is_command(self, node: ast.Call) -> bool:
193
+ """A call that sends its arguments to Redis as they are."""
194
+ func = node.func
195
+ return (
196
+ isinstance(func, ast.Attribute)
197
+ and isinstance(func.value, ast.Name)
198
+ and self.namespace_kind(func.value.id) == "redis"
199
+ and (func.attr not in REDIS_DIRECT or func.attr in {"call", "pcall"})
200
+ )
201
+
179
202
  def _is_key(self, node: ast.expr | None) -> bool:
180
203
  return self._annotation_name(node) == "Key"
181
204
 
@@ -192,6 +215,26 @@ class ScopeCompiler(CompilerBase):
192
215
  return "str"
193
216
  return None
194
217
 
218
+ @staticmethod
219
+ def _without_none(node: ast.expr | None) -> ast.expr | None:
220
+ """``X`` from ``X | None`` or ``Optional[X]``: None is no kind of its own."""
221
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
222
+ try:
223
+ node = ast.parse(node.value, mode="eval").body
224
+ except SyntaxError:
225
+ return None
226
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
227
+ sides = [
228
+ side
229
+ for side in (node.left, node.right)
230
+ if not (isinstance(side, ast.Constant) and side.value is None)
231
+ ]
232
+ return sides[0] if len(sides) == 1 else None
233
+ name = ScopeCompiler._annotation_name(node)
234
+ if isinstance(node, ast.Subscript) and name == "Optional":
235
+ return node.slice
236
+ return node
237
+
195
238
  @staticmethod
196
239
  def _list_item(node: ast.expr | None) -> ast.expr | None:
197
240
  """The element annotation of a ``list[...]`` parameter, if it is one."""
@@ -272,6 +272,7 @@ class Compiler(ControlFlowCompiler):
272
272
  # What the script's names hold is known inside the function too, except
273
273
  # for its parameters, which say nothing about their type.
274
274
  child.kinds = {k: v for k, v in self.kinds.items() if k not in params}
275
+ child.declared = {k: v for k, v in self.declared.items() if k not in params}
275
276
  child.values = {k: list(v) for k, v in self.values.items() if k not in params}
276
277
  child.opaque = self.opaque - set(params)
277
278
  child._temp = self._temp
@@ -133,8 +133,8 @@ MATH_BY_OBJECT: dict[object, str] = {getattr(math, name): name for name in [*MAT
133
133
 
134
134
  METHOD_HINT = (
135
135
  "Available: the redis and cjson namespaces; list append, insert and pop; str join, "
136
- "upper, lower, strip, lstrip, rstrip, startswith, endswith, find, split, replace, "
137
- "encode and decode; and dict get."
136
+ "upper, lower, strip, lstrip, rstrip, startswith, endswith, find, split, partition, "
137
+ "replace, isdigit, isalpha, isalnum, isspace, encode and decode; and dict get."
138
138
  )
139
139
 
140
140
 
@@ -152,9 +152,24 @@ STRING_METHODS = frozenset(
152
152
  "endswith",
153
153
  "find",
154
154
  "split",
155
+ "partition",
155
156
  "replace",
157
+ "isdigit",
158
+ "isalpha",
159
+ "isalnum",
160
+ "isspace",
156
161
  "encode",
157
162
  "decode",
158
163
  "get",
159
164
  }
160
165
  )
166
+
167
+
168
+ # The str.is* tests, as Lua patterns over the whole string. Lua's classes are
169
+ # ASCII, which is exactly what the same methods on bytes test.
170
+ STRING_TESTS: dict[str, str] = {
171
+ "isdigit": "^%d+$",
172
+ "isalpha": "^%a+$",
173
+ "isalnum": "^%w+$",
174
+ "isspace": "^%s+$",
175
+ }
@@ -8,19 +8,24 @@ stay in step: there is only one copy of this code to get right.
8
8
  So this module imports only the standard library, and nothing from the
9
9
  package, and every name it defines starts with an underscore, to stay out of
10
10
  the way of the scripts in a generated module.
11
+
12
+ It is also the one module here that runs on Python 3.9, because a library
13
+ that vendors generated code may still support it. Annotations are never
14
+ evaluated, so they can use ``X | Y``; anything evaluated cannot, which is why
15
+ the aliases below spell ``Union`` and ``isinstance`` takes tuples.
11
16
  """
12
17
 
13
18
  from __future__ import annotations
14
19
 
15
20
  from collections.abc import Iterable
16
- from typing import Any, Protocol
21
+ from typing import Any, Protocol, Union
17
22
  from weakref import WeakKeyDictionary
18
23
 
19
24
  #: What a generated signature accepts for a key, the same as redis-py does.
20
- _Key = str | bytes | memoryview
25
+ _Key = Union[str, bytes, memoryview]
21
26
 
22
27
  #: What a generated signature accepts for an argument it has no better type for.
23
- _Arg = str | bytes | memoryview | int | float
28
+ _Arg = Union[str, bytes, memoryview, int, float]
24
29
 
25
30
 
26
31
  class _SyncClient(Protocol):
@@ -61,9 +66,9 @@ def _encode(
61
66
  """
62
67
  if isinstance(value, bool):
63
68
  return "1" if value else "0"
64
- if isinstance(value, str | bytes | memoryview):
69
+ if isinstance(value, (str, bytes, memoryview)):
65
70
  return value
66
- if isinstance(value, int | float):
71
+ if isinstance(value, (int, float)):
67
72
  return repr(value) if isinstance(value, float) else str(value)
68
73
  raise error(
69
74
  f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
@@ -77,7 +82,7 @@ def _items(name: str, value: object, error: type[Exception] = TypeError) -> list
77
82
  A string is iterable too, and splitting a key into characters is never
78
83
  what was meant, so it is refused rather than spread.
79
84
  """
80
- if isinstance(value, str | bytes | memoryview) or not isinstance(value, Iterable):
85
+ if isinstance(value, (str, bytes, memoryview)) or not isinstance(value, Iterable):
81
86
  raise error(
82
87
  f"argument {name!r} takes a list, got a {type(value).__name__}. "
83
88
  "Wrap a single value in a list."
@@ -62,7 +62,7 @@ _IMPORTS = """\
62
62
  from __future__ import annotations
63
63
 
64
64
  from collections.abc import Awaitable, Iterable
65
- from typing import Any, Protocol, overload
65
+ from typing import Any, Protocol, Union, overload
66
66
  from weakref import WeakKeyDictionary
67
67
  """
68
68