redis-lua-py 0.12.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.12.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.12.0 → redis_lua_py-0.13.0}/CHANGELOG.md +7 -0
  4. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/PKG-INFO +2 -2
  5. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/README.md +1 -1
  6. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/examples.md +2 -2
  7. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/build-time.md +7 -1
  8. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/generated-lua.md +1 -1
  9. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/keys-and-arguments.md +17 -8
  10. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/redis-functions.md +1 -1
  11. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/installation.md +2 -1
  12. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/quickstart.md +1 -1
  13. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/pyproject.toml +5 -1
  14. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/__init__.py +1 -1
  15. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/analysis.py +27 -1
  16. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/base.py +7 -0
  17. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/scope.py +17 -3
  18. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_portable.py +11 -6
  19. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/codegen.py +1 -1
  20. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/generated_scripts.py +8 -8
  21. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_binary.py +2 -1
  22. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_codegen.py +117 -0
  23. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_compile.py +21 -1
  24. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_expressions.py +14 -0
  25. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_semantics.py +29 -0
  26. redis_lua_py-0.12.0/.release-please-manifest.json +0 -3
  27. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/README.md +0 -0
  28. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/banner-dark.svg +0 -0
  29. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/banner-light.svg +0 -0
  30. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/generate.py +0 -0
  31. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/logo.svg +0 -0
  32. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/logomark.svg +0 -0
  33. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/social-preview.png +0 -0
  34. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/assets/social-preview.svg +0 -0
  35. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/docs.yml +0 -0
  36. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/pr-title.yml +0 -0
  37. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.github/workflows/release.yml +0 -0
  38. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.gitignore +0 -0
  39. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/.python-version +0 -0
  40. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/CONTRIBUTING.md +0 -0
  41. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/LICENSE +0 -0
  42. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/assets/logo.svg +0 -0
  43. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/contributing.md +0 -0
  44. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/development.md +0 -0
  45. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/async.md +0 -0
  46. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/binary-values.md +0 -0
  47. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/binding-a-client.md +0 -0
  48. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/calling-redis-commands.md +0 -0
  49. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/constants.md +0 -0
  50. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/return-values.md +0 -0
  51. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/guide/testing.md +0 -0
  52. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/index.md +0 -0
  53. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/api.md +0 -0
  54. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/errors.md +0 -0
  55. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/lua-vs-python.md +0 -0
  56. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/reference/supported-subset.md +0 -0
  57. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/docs/stylesheets/extra.css +0 -0
  58. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/release-please-config.json +0 -0
  59. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/scripts/generate_commands.py +0 -0
  60. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/__main__.py +0 -0
  61. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_command_stubs.py +0 -0
  62. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_commands.py +0 -0
  63. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/__init__.py +0 -0
  64. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/calls.py +0 -0
  65. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/control.py +0 -0
  66. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/expressions.py +0 -0
  67. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/helpers.py +0 -0
  68. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/source.py +0 -0
  69. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/statements.py +0 -0
  70. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_compile/tables.py +0 -0
  71. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_library.py +0 -0
  72. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_lua.py +0 -0
  73. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_runtime.py +0 -0
  74. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/_script.py +0 -0
  75. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/errors.py +0 -0
  76. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/src/redis_lua_py/py.typed +0 -0
  77. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/codegen_scripts.py +0 -0
  78. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/conftest.py +0 -0
  79. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/module_with_client.py +0 -0
  80. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/module_without_import.py +0 -0
  81. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_binding.py +0 -0
  82. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_booleans_and_conversions.py +0 -0
  83. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_commands.py +0 -0
  84. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_constants.py +0 -0
  85. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_control_flow.py +0 -0
  86. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_coredis.py +0 -0
  87. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_errors.py +0 -0
  88. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_execute.py +0 -0
  89. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_functions.py +0 -0
  90. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_mistranslations.py +0 -0
  91. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_namespace.py +0 -0
  92. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_strings_and_indexing.py +0 -0
  93. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_table_stakes.py +0 -0
  94. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/tests/test_typing.py +0 -0
  95. {redis_lua_py-0.12.0 → redis_lua_py-0.13.0}/uv.lock +0 -0
  96. {redis_lua_py-0.12.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,13 @@ 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
+
10
17
  ## [0.12.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.11.0...v0.12.0) (2026-09-25)
11
18
 
12
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.12.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
 
@@ -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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.12.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.12.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):
@@ -296,6 +296,13 @@ class CompilerBase(ABC):
296
296
  return "str"
297
297
  if name in {"int", "float", "tonumber", "len", "abs", "ord"}:
298
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
299
306
  return "num" if self.math_attr(node.func) is not None else None
300
307
  case ast.Call(func=ast.Attribute(value=receiver, attr=attr)):
301
308
  if self.math_attr(node.func) is not None:
@@ -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):
@@ -141,9 +142,12 @@ class ScopeCompiler(CompilerBase):
141
142
  source = lua.Index(lua.Global("ARGV"), lua.Num(len(self.args)))
142
143
  if self._is_numeric(arg.annotation):
143
144
  # ARGV always arrives as strings; an int/float annotation
144
- # 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.
145
148
  self.numeric_args.add(name)
146
- 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,))
147
151
  elif self._annotation_name(arg.annotation) == "bool":
148
152
  # A bool arrives as "1" or "0", and "0" is a string Lua and
149
153
  # Python both count as true. Comparing makes it a boolean.
@@ -185,6 +189,16 @@ class ScopeCompiler(CompilerBase):
185
189
  return node.value.rsplit(".", 1)[-1].split("[", 1)[0]
186
190
  return None
187
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
+
188
202
  def _is_key(self, node: ast.expr | None) -> bool:
189
203
  return self._annotation_name(node) == "Key"
190
204
 
@@ -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
 
@@ -12,7 +12,7 @@ Regenerate with:
12
12
  from __future__ import annotations
13
13
 
14
14
  from collections.abc import Awaitable, Iterable
15
- from typing import Any, Protocol, overload
15
+ from typing import Any, Protocol, Union, overload
16
16
  from weakref import WeakKeyDictionary
17
17
 
18
18
  __all__ = [
@@ -30,10 +30,10 @@ __all__ = [
30
30
 
31
31
 
32
32
  #: What a generated signature accepts for a key, the same as redis-py does.
33
- _Key = str | bytes | memoryview
33
+ _Key = Union[str, bytes, memoryview]
34
34
 
35
35
  #: What a generated signature accepts for an argument it has no better type for.
36
- _Arg = str | bytes | memoryview | int | float
36
+ _Arg = Union[str, bytes, memoryview, int, float]
37
37
 
38
38
 
39
39
  class _SyncClient(Protocol):
@@ -74,9 +74,9 @@ def _encode(
74
74
  """
75
75
  if isinstance(value, bool):
76
76
  return "1" if value else "0"
77
- if isinstance(value, str | bytes | memoryview):
77
+ if isinstance(value, (str, bytes, memoryview)):
78
78
  return value
79
- if isinstance(value, int | float):
79
+ if isinstance(value, (int, float)):
80
80
  return repr(value) if isinstance(value, float) else str(value)
81
81
  raise error(
82
82
  f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
@@ -90,7 +90,7 @@ def _items(name: str, value: object, error: type[Exception] = TypeError) -> list
90
90
  A string is iterable too, and splitting a key into characters is never
91
91
  what was meant, so it is refused rather than spread.
92
92
  """
93
- if isinstance(value, str | bytes | memoryview) or not isinstance(value, Iterable):
93
+ if isinstance(value, (str, bytes, memoryview)) or not isinstance(value, Iterable):
94
94
  raise error(
95
95
  f"argument {name!r} takes a list, got a {type(value).__name__}. "
96
96
  "Wrap a single value in a list."
@@ -149,7 +149,7 @@ RATE_LIMIT = """\
149
149
  -- Generated by redis-lua-py from tests/codegen_scripts.py:19. Do not edit.
150
150
  local key = KEYS[1]
151
151
  local limit = tonumber(ARGV[1])
152
- local ttl = tonumber(ARGV[2])
152
+ local ttl = ARGV[2]
153
153
  local current = redis.call('INCR', key)
154
154
  if current == 1 then
155
155
  redis.call('EXPIRE', key, ttl)
@@ -188,7 +188,7 @@ def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> Any:
188
188
  TOUCH_ALL = """\
189
189
  -- touch_all
190
190
  -- Generated by redis-lua-py from tests/codegen_scripts.py:29. Do not edit.
191
- local ttl = tonumber(ARGV[1])
191
+ local ttl = ARGV[1]
192
192
  local keys = {}
193
193
  for __i1 = 1, #KEYS do
194
194
  keys[#keys + 1] = KEYS[__i1]
@@ -40,7 +40,8 @@ class TestArgv:
40
40
  """No tonumber, no tostring: a bytes annotation is a passthrough."""
41
41
  emitted = body(append_chunk)
42
42
  assert "local chunk = ARGV[1]" in emitted
43
- assert "local ttl = tonumber(ARGV[2])" in emitted
43
+ # Only handed back to Redis, so the int is left as text too.
44
+ assert "local ttl = ARGV[2]" in emitted
44
45
 
45
46
  def test_non_utf8_argv_arrives_intact(self, client: Any) -> None:
46
47
  size, count = append_chunk(client, buffer="buf", counter="chunks", chunk=BLOB, ttl=60)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import ast
6
6
  import importlib.util
7
7
  import inspect
8
+ import shutil
8
9
  import subprocess
9
10
  import sys
10
11
  from pathlib import Path
@@ -199,6 +200,122 @@ class TestGeneratedFunctions:
199
200
  codegen.generate(module_of(_run=codegen_scripts.rate_limit), tmp_path / "_lua.py")
200
201
 
201
202
 
203
+ def python39() -> str | None:
204
+ """A Python 3.9 interpreter, if uv knows of one. CI installs one for this."""
205
+ uv = shutil.which("uv")
206
+ if uv is None:
207
+ return None
208
+ found = subprocess.run(
209
+ [uv, "python", "find", "3.9"], capture_output=True, text=True, check=False
210
+ )
211
+ if found.returncode != 0:
212
+ return None
213
+ return found.stdout.strip() or None
214
+
215
+
216
+ #: Imports a generated module on its own, with nothing else installed.
217
+ LOAD = """
218
+ import importlib.util, sys
219
+
220
+ spec = importlib.util.spec_from_file_location("generated_lua", sys.argv[1])
221
+ module = importlib.util.module_from_spec(spec)
222
+ spec.loader.exec_module(module)
223
+ """
224
+
225
+ #: Calls the scripts of codegen_scripts through a stand-in for redis-py that
226
+ #: records what it is asked to run. Python 3.9, and standard library only.
227
+ CALL = """
228
+ class Client:
229
+ def __init__(self):
230
+ self.calls = []
231
+ def register_script(self, lua):
232
+ def run(keys, args, client):
233
+ self.calls.append((lua, keys, args))
234
+ return len(self.calls)
235
+ return run
236
+
237
+ client = Client()
238
+ assert module.rate_limit(client, key="k", limit=1, ttl=2) == 1
239
+ assert module.touch_all(client, keys=iter([b"a", "b"]), ttl=2.5) == 2
240
+ assert module.echo(client, True, suffix=memoryview(b"x")) == 3
241
+ assert [call[1:] for call in client.calls] == [
242
+ (["k"], ["1", "2"]),
243
+ ([b"a", "b"], ["2.5"]),
244
+ ([], ["1", memoryview(b"x")]),
245
+ ]
246
+ assert client.calls[0][0] == module.RATE_LIMIT
247
+ for script, args in ((module.echo, (None, "")), (module.touch_all, ("ab", 1))):
248
+ try:
249
+ script(client, *args)
250
+ except TypeError:
251
+ pass
252
+ else:
253
+ raise AssertionError("accepted a value Redis cannot represent")
254
+ print("ok")
255
+ """
256
+
257
+
258
+ class TestPython39:
259
+ """A generated module imports and runs on Python 3.9.
260
+
261
+ This package needs 3.10, but a library vendoring the generated code may
262
+ still support 3.9, and the module is all of this package it ships.
263
+ """
264
+
265
+ @pytest.fixture
266
+ def widened(self, tmp_path):
267
+ out = tmp_path / "_lua.py"
268
+ codegen.generate(module_of(opaque=opaque, maybe=maybe), out)
269
+ return out
270
+
271
+ @pytest.mark.parametrize("which", ["checked_in", "widened"])
272
+ def test_parses_as_python_39(self, request, which):
273
+ path = GENERATED if which == "checked_in" else request.getfixturevalue("widened")
274
+ ast.parse(path.read_text(), feature_version=(3, 9))
275
+
276
+ @pytest.mark.parametrize("which", ["checked_in", "widened"])
277
+ def test_evaluates_no_union_operator(self, request, which):
278
+ # `X | Y` parses on 3.9 but fails there when evaluated. Annotations
279
+ # never are, under `from __future__ import annotations`.
280
+ path = GENERATED if which == "checked_in" else request.getfixturevalue("widened")
281
+ tree = ast.parse(path.read_text())
282
+ annotations = {
283
+ id(inner)
284
+ for node in ast.walk(tree)
285
+ for annotation in (getattr(node, "annotation", None), getattr(node, "returns", None))
286
+ if annotation is not None
287
+ for inner in ast.walk(annotation)
288
+ }
289
+ evaluated = [
290
+ ast.unparse(node)
291
+ for node in ast.walk(tree)
292
+ if isinstance(node, ast.BinOp)
293
+ and isinstance(node.op, ast.BitOr)
294
+ and id(node) not in annotations
295
+ ]
296
+
297
+ assert "from __future__ import annotations" in path.read_text()
298
+ assert evaluated == []
299
+
300
+ def test_runs_on_python_39(self, widened):
301
+ interpreter = python39()
302
+ if interpreter is None:
303
+ pytest.skip("no Python 3.9 available through uv")
304
+
305
+ def run(code: str, path: Path) -> subprocess.CompletedProcess[str]:
306
+ return subprocess.run(
307
+ [interpreter, "-I", "-c", code, str(path)],
308
+ capture_output=True,
309
+ text=True,
310
+ check=False,
311
+ )
312
+
313
+ imported = run(LOAD, widened)
314
+ assert imported.returncode == 0, imported.stderr
315
+ called = run(LOAD + CALL, GENERATED)
316
+ assert called.stdout == "ok\n", called.stderr
317
+
318
+
202
319
  class TestPythonModule:
203
320
  def test_holds_each_script_exactly(self):
204
321
  assert codegen_scripts.rate_limit.lua == generated_scripts.RATE_LIMIT
@@ -33,6 +33,26 @@ def test_numeric_annotations_get_tonumber(body: Body) -> None:
33
33
  assert "local text = ARGV[3]" in emitted
34
34
 
35
35
 
36
+ def test_a_number_only_handed_to_redis_stays_text(body: Body) -> None:
37
+ @script
38
+ def s(k: Key, ttl: int, score: float, n: int, limit: int, step: int) -> int:
39
+ redis.expire(k, ttl)
40
+ redis.call("ZADD", k, score, "member")
41
+ redis.incrby(k, n)
42
+ if redis.incrby(k, limit) > limit:
43
+ return 0
44
+ step = step + 1
45
+ return redis.incrby(k, step)
46
+
47
+ emitted = body(s)
48
+ assert "local ttl = ARGV[1]" in emitted
49
+ assert "local score = ARGV[2]" in emitted
50
+ assert "local n = ARGV[3]" in emitted
51
+ # Compared, or assigned to: a number in the body, so converted as before.
52
+ assert "local limit = tonumber(ARGV[4])" in emitted
53
+ assert "local step = tonumber(ARGV[5])" in emitted
54
+
55
+
36
56
  def test_full_script_emission(body: Body) -> None:
37
57
  @script
38
58
  def rate_limit(key: Key, limit: int, ttl: int) -> int:
@@ -46,7 +66,7 @@ def test_full_script_emission(body: Body) -> None:
46
66
  assert body(rate_limit) == (
47
67
  "local key = KEYS[1]\n"
48
68
  "local limit = tonumber(ARGV[1])\n"
49
- "local ttl = tonumber(ARGV[2])\n"
69
+ "local ttl = ARGV[2]\n"
50
70
  "local current = redis.call('INCR', key)\n"
51
71
  "if current == 1 then\n"
52
72
  " redis.call('EXPIRE', key, ttl)\n"
@@ -172,3 +172,17 @@ class TestAddition:
172
172
  client.set("n", "5")
173
173
  assert "__add" not in bump.lua
174
174
  assert bump(client, k="n") == 11
175
+
176
+ def test_the_least_or_greatest_of_numbers_is_a_number(self, client: Any) -> None:
177
+ @script
178
+ def refill(key: Key, capacity: float, rate: float, now: float) -> int:
179
+ tokens = float(redis.get(key))
180
+ delta = max(0, now)
181
+ tokens = min(capacity, tokens + delta * rate)
182
+ return tokens
183
+
184
+ client.set("bucket", "2")
185
+ assert "__add" not in refill.lua
186
+ assert "tokens + delta * rate" in refill.lua
187
+ assert refill(client, key="bucket", capacity=10, rate=2, now=3) == 8
188
+ assert refill(client, key="bucket", capacity=5, rate=2, now=3) == 5
@@ -383,3 +383,32 @@ class TestDefiniteAssignment:
383
383
  if n > 0:
384
384
  head = 1
385
385
  return head
386
+
387
+
388
+ class TestNumbersHandedToRedis:
389
+ """A Lua number is a double, which has no exact form past 2^53."""
390
+
391
+ def test_a_large_integer_arrives_exactly(self, client: Any) -> None:
392
+ @script
393
+ def store(k: Key, n: int) -> bytes:
394
+ redis.set(k, n)
395
+ return redis.get(k)
396
+
397
+ assert store(client, k="n", n=2**53 + 1) == b"9007199254740993"
398
+
399
+ def test_a_float_is_not_rewritten_with_seventeen_digits(self, client: Any) -> None:
400
+ @script
401
+ def store(k: Key, x: float) -> bytes:
402
+ redis.set(k, x)
403
+ return redis.get(k)
404
+
405
+ assert store(client, k="f", x=0.1) == b"0.1"
406
+
407
+ def test_arithmetic_still_sees_a_number(self, client: Any) -> None:
408
+ @script
409
+ def twice(k: Key, n: int) -> int:
410
+ redis.set(k, n)
411
+ return n * 2 + 1
412
+
413
+ assert twice(client, k="n", n=20) == 41
414
+ assert client.get("n") == b"20"
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.12.0"
3
- }
File without changes
File without changes
File without changes