redis-lua-py 0.5.0__tar.gz → 0.6.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 (85) hide show
  1. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/ci.yml +15 -1
  2. redis_lua_py-0.6.0/.release-please-manifest.json +3 -0
  3. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/CHANGELOG.md +14 -0
  4. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/PKG-INFO +4 -3
  5. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/README.md +3 -2
  6. redis_lua_py-0.6.0/docs/guide/build-time.md +196 -0
  7. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/api.md +6 -2
  8. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/pyproject.toml +4 -3
  9. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/__init__.py +1 -1
  10. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_compile.py +15 -0
  11. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_library.py +20 -1
  12. redis_lua_py-0.6.0/src/redis_lua_py/_portable.py +116 -0
  13. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_script.py +18 -74
  14. redis_lua_py-0.6.0/src/redis_lua_py/codegen.py +429 -0
  15. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/codegen_scripts.py +21 -2
  16. redis_lua_py-0.6.0/tests/generated_scripts.py +296 -0
  17. redis_lua_py-0.6.0/tests/test_codegen.py +378 -0
  18. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_functions.py +22 -0
  19. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_typing.py +11 -0
  20. redis_lua_py-0.5.0/.release-please-manifest.json +0 -3
  21. redis_lua_py-0.5.0/docs/guide/build-time.md +0 -152
  22. redis_lua_py-0.5.0/src/redis_lua_py/codegen.py +0 -221
  23. redis_lua_py-0.5.0/tests/test_codegen.py +0 -229
  24. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/README.md +0 -0
  25. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/banner-dark.svg +0 -0
  26. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/banner-light.svg +0 -0
  27. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/generate.py +0 -0
  28. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/logo.svg +0 -0
  29. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/logomark.svg +0 -0
  30. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/social-preview.png +0 -0
  31. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/social-preview.svg +0 -0
  32. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/docs.yml +0 -0
  33. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/pr-title.yml +0 -0
  34. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/release.yml +0 -0
  35. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.gitignore +0 -0
  36. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.python-version +0 -0
  37. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/CONTRIBUTING.md +0 -0
  38. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/LICENSE +0 -0
  39. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/assets/logo.svg +0 -0
  40. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/contributing.md +0 -0
  41. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/development.md +0 -0
  42. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/examples.md +0 -0
  43. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/async.md +0 -0
  44. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/binary-values.md +0 -0
  45. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/binding-a-client.md +0 -0
  46. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/calling-redis-commands.md +0 -0
  47. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/constants.md +0 -0
  48. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/generated-lua.md +0 -0
  49. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/keys-and-arguments.md +0 -0
  50. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/redis-functions.md +0 -0
  51. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/return-values.md +0 -0
  52. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/testing.md +0 -0
  53. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/index.md +0 -0
  54. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/installation.md +0 -0
  55. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/quickstart.md +0 -0
  56. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/errors.md +0 -0
  57. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/lua-vs-python.md +0 -0
  58. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/supported-subset.md +0 -0
  59. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/stylesheets/extra.css +0 -0
  60. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/release-please-config.json +0 -0
  61. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/scripts/generate_commands.py +0 -0
  62. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/__main__.py +0 -0
  63. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_commands.py +0 -0
  64. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_lua.py +0 -0
  65. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_runtime.py +0 -0
  66. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/errors.py +0 -0
  67. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/py.typed +0 -0
  68. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/conftest.py +0 -0
  69. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/module_with_client.py +0 -0
  70. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/module_without_import.py +0 -0
  71. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_binary.py +0 -0
  72. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_binding.py +0 -0
  73. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_commands.py +0 -0
  74. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_compile.py +0 -0
  75. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_constants.py +0 -0
  76. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_control_flow.py +0 -0
  77. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_errors.py +0 -0
  78. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_execute.py +0 -0
  79. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_mistranslations.py +0 -0
  80. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_namespace.py +0 -0
  81. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_semantics.py +0 -0
  82. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_strings_and_indexing.py +0 -0
  83. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_table_stakes.py +0 -0
  84. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/uv.lock +0 -0
  85. {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/zensical.toml +0 -0
@@ -37,6 +37,17 @@ jobs:
37
37
  oldest:
38
38
  name: Tests (oldest supported Python and redis-py)
39
39
  runs-on: ubuntu-latest
40
+ # A real server as well, since what fakeredis cannot run -- Redis Functions
41
+ # and script flags -- is exactly what old redis-py spells differently.
42
+ services:
43
+ redis:
44
+ image: redis:7
45
+ ports: ["6379:6379"]
46
+ options: >-
47
+ --health-cmd "redis-cli ping"
48
+ --health-interval 10s
49
+ --health-timeout 5s
50
+ --health-retries 5
40
51
  steps:
41
52
  - uses: actions/checkout@v4
42
53
  - uses: astral-sh/setup-uv@v5
@@ -46,8 +57,11 @@ jobs:
46
57
  # The floor in pyproject.toml is only a claim until something runs on it.
47
58
  # fakeredis declares redis>=4.3 but works on 4.2, the first release with
48
59
  # redis.asyncio, so the pin goes in after the resolver is done.
49
- - run: uv pip install --no-deps "redis==4.2.0" async-timeout deprecated
60
+ - run: uv pip install --no-deps "redis==4.2.0" async-timeout deprecated packaging
50
61
  - run: uv run --no-sync pytest
62
+ - run: uv run --no-sync pytest
63
+ env:
64
+ REDIS_URL: redis://localhost:6379/0
51
65
 
52
66
  live:
53
67
  name: Tests against a real Redis
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.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.6.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.5.1...v0.6.0) (2026-09-12)
11
+
12
+
13
+ ### Added
14
+
15
+ * generate typed functions that call scripts the way `@script` does ([#18](https://github.com/IgnaceMaes/redis-lua-py/issues/18)) ([8f602f7](https://github.com/IgnaceMaes/redis-lua-py/commit/8f602f7bdf4afaa22c83c63e215ef5d965fbc584))
16
+
17
+ ## [0.5.1](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.5.0...v0.5.1) (2026-09-12)
18
+
19
+
20
+ ### Fixed
21
+
22
+ * load redis functions libraries on redis-py 4.2 ([#16](https://github.com/IgnaceMaes/redis-lua-py/issues/16)) ([85c2ba2](https://github.com/IgnaceMaes/redis-lua-py/commit/85c2ba20056224af7b2c9720176f799cf54b163f))
23
+
10
24
  ## [0.5.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.4.0...v0.5.0) (2026-09-12)
11
25
 
12
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.5.0
3
+ Version: 0.6.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/
@@ -145,8 +145,9 @@ script cache is cold once per script rather than once per environment.
145
145
  **[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
146
146
  passing the client every time gets repetitive.
147
147
  - **[Build-time generation](https://ignacemaes.com/redis-lua-py/guide/build-time/)**
148
- for libraries — `python -m redis_lua_py generate` writes the Lua to a module
149
- of plain strings, so your users never depend on this package, and `--check`
148
+ for libraries — `python -m redis_lua_py generate` writes your scripts to a
149
+ module of typed functions that needs only the standard library, called just
150
+ like the `@script`, so your users never depend on this package. `--check`
150
151
  keeps it current in CI.
151
152
  - **[Redis Functions](https://ignacemaes.com/redis-lua-py/guide/redis-functions/)** —
152
153
  the same Python compiles into a function library, loaded with `FUNCTION LOAD`
@@ -119,8 +119,9 @@ script cache is cold once per script rather than once per environment.
119
119
  **[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
120
120
  passing the client every time gets repetitive.
121
121
  - **[Build-time generation](https://ignacemaes.com/redis-lua-py/guide/build-time/)**
122
- for libraries — `python -m redis_lua_py generate` writes the Lua to a module
123
- of plain strings, so your users never depend on this package, and `--check`
122
+ for libraries — `python -m redis_lua_py generate` writes your scripts to a
123
+ module of typed functions that needs only the standard library, called just
124
+ like the `@script`, so your users never depend on this package. `--check`
124
125
  keeps it current in CI.
125
126
  - **[Redis Functions](https://ignacemaes.com/redis-lua-py/guide/redis-functions/)** —
126
127
  the same Python compiles into a function library, loaded with `FUNCTION LOAD`
@@ -0,0 +1,196 @@
1
+ # Shipping without the dependency
2
+
3
+ Calling a script needs redis-lua-py at runtime: it compiles the body when the
4
+ module is imported, and resolves keys and arguments on every call. In an
5
+ application that is a dependency you chose. In a library it is a dependency
6
+ every one of your users inherits, along with the import-time compile, for Lua
7
+ that never changes between your releases.
8
+
9
+ So a library can compile ahead of time instead. You write the scripts with
10
+ `@script`, as anywhere else, and generate a module from them while you
11
+ develop. What you ship is that module: plain Python that needs only the
12
+ standard library, with a typed function per script, called exactly the way
13
+ the `@script` would be.
14
+
15
+ ## Generate a module
16
+
17
+ Keep the scripts outside the package you ship, and redis-lua-py in your
18
+ development dependencies:
19
+
20
+ ```bash
21
+ uv add --dev redis-lua-py
22
+ ```
23
+
24
+ ```
25
+ myproj/
26
+ ├── pyproject.toml
27
+ ├── redis_scripts/
28
+ │ └── limits.py # the @script functions; never shipped
29
+ └── src/myproj/
30
+ ├── _lua.py # generated, checked in, shipped
31
+ └── limits.py # the code that runs them
32
+ ```
33
+
34
+ Then, from the project root:
35
+
36
+ ```bash
37
+ python -m redis_lua_py generate redis_scripts.limits --out src/myproj/_lua.py
38
+ ```
39
+
40
+ Your library calls the generated functions the way it would call the scripts
41
+ themselves:
42
+
43
+ ```python
44
+ from redis import Redis
45
+
46
+ from ._lua import rate_limit
47
+
48
+
49
+ def hit(client: Redis, key: str, limit: int, ttl: int) -> int:
50
+ return rate_limit(client, key=key, limit=limit, ttl=ttl)
51
+ ```
52
+
53
+ Moving from `@script` to generated code, or back, is a change of import. A sync
54
+ client gets a value and an async one an awaitable, `EVALSHA` falls back to
55
+ `EVAL` when the server has dropped the script, and a cluster pipeline is sent
56
+ the source: the generated module carries a copy of the code the package itself
57
+ calls scripts with.
58
+
59
+ ## What the module holds
60
+
61
+ For each script, its Lua as a constant named after it in capitals, and a
62
+ function with the signature it was written with:
63
+
64
+ ```python
65
+ # rate_limit -- KEYS: key; ARGV: limit, ttl
66
+ RATE_LIMIT = """\
67
+ -- rate_limit
68
+ -- Generated by redis-lua-py from redis_scripts/limits.py:6. Do not edit.
69
+ local key = KEYS[1]
70
+ local limit = tonumber(ARGV[1])
71
+ local ttl = tonumber(ARGV[2])
72
+ local current = redis.call('INCR', key)
73
+ if current == 1 then
74
+ redis.call('EXPIRE', key, ttl)
75
+ end
76
+ if current > limit then
77
+ return -1
78
+ end
79
+ return limit - current
80
+ """
81
+ _RATE_LIMIT_CLIENTS: WeakKeyDictionary[Any, Any] = WeakKeyDictionary()
82
+
83
+
84
+ @overload
85
+ def rate_limit(
86
+ client: _AsyncClient,
87
+ /,
88
+ key: _Key,
89
+ limit: int,
90
+ ttl: int,
91
+ ) -> Awaitable[int]: ...
92
+ @overload
93
+ def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> int: ...
94
+ def rate_limit(client: Any, /, key: _Key, limit: int, ttl: int) -> Any:
95
+ return _run(
96
+ RATE_LIMIT,
97
+ _RATE_LIMIT_CLIENTS,
98
+ client,
99
+ [key],
100
+ [_encode("limit", limit), _encode("ttl", ttl)],
101
+ )
102
+ ```
103
+
104
+ Because it is a real signature, Python itself refuses a missing, misspelled or
105
+ duplicated argument, and your type checker sees every call, including what it
106
+ returns from a sync client and from an async one. Parameter names, their
107
+ order, keyword-only parameters and the docstring all come from the script.
108
+
109
+ The types come from the script's annotations, as far as a module that imports
110
+ nothing of yours can repeat them:
111
+
112
+ | Parameter | Accepts |
113
+ | --- | --- |
114
+ | annotated `Key` | `str \| bytes \| memoryview`, as redis-py does for a key |
115
+ | `list[Key]`, or `list[...]` of arguments | any iterable of those, as `@script` does |
116
+ | an argument annotated with builtins only, such as `int` or `str \| bytes` | exactly that |
117
+ | an argument annotated with anything else | `str \| bytes \| memoryview \| int \| float` |
118
+
119
+ A return annotation made only of builtins, such as `list[bytes] | None`, is kept
120
+ as written; anything else becomes `Any`. A value Redis has no representation
121
+ for, such as `None`, raises `TypeError`, as does a string passed where a list
122
+ belongs.
123
+
124
+ Above the scripts sits that copied call path, about a hundred lines, every name
125
+ in it private. A script or parameter named like one of them would shadow it, so
126
+ generation refuses one and names it.
127
+
128
+ Every path in the file is relative to the project root, so it comes out
129
+ byte-for-byte the same on every machine, and regenerating it without a change
130
+ to the scripts leaves it untouched. It is laid out the way black and ruff
131
+ format code, and passes strict mypy.
132
+
133
+ If you would rather call redis-py yourself, the constants are there for that:
134
+ `client.register_script(RATE_LIMIT)`, with `keys` and `args` in the order the
135
+ comment above each one records.
136
+
137
+ ## Or one `.lua` file per script
138
+
139
+ Pass a directory instead of a `.py` path:
140
+
141
+ ```bash
142
+ python -m redis_lua_py generate redis_scripts.limits --out src/myproj/lua/
143
+ ```
144
+
145
+ That writes `rate_limit.lua` and a file for every other script, for a library
146
+ that already loads its Lua from files. When a script is renamed or removed, its
147
+ old file is deleted. Only files carrying the generated header are ever
148
+ deleted, so hand-written Lua in the same directory is safe. The flip side is
149
+ that a script compiled with `header=False` leaves its old file behind when it
150
+ is renamed.
151
+
152
+ ## Keep it current
153
+
154
+ Checking generated code in only works if something notices when it goes stale.
155
+ `--check` writes nothing, and exits 1 with a diff if the output no longer
156
+ matches the scripts. Run it in CI:
157
+
158
+ ```yaml
159
+ - run: uv run python -m redis_lua_py generate redis_scripts.limits --out src/myproj/_lua.py --check
160
+ ```
161
+
162
+ Or as a test, which fails with the same diff:
163
+
164
+ ```python
165
+ from redis_lua_py import codegen
166
+
167
+
168
+ def test_generated_lua_is_current():
169
+ codegen.check("redis_scripts.limits", "src/myproj/_lua.py")
170
+ ```
171
+
172
+ A test imports `redis_scripts.limits` like any other module, so the project
173
+ root has to be on the path. With pytest, set `pythonpath = ["."]` under
174
+ `[tool.pytest.ini_options]`.
175
+
176
+ ## What you give up
177
+
178
+ The generated Lua is identical to what a `@script` call would send, and the
179
+ call path is the same code. What differs is at the edges:
180
+
181
+ - **Errors.** A bad argument raises `TypeError`, not
182
+ [`ScriptArgumentError`](../reference/errors.md#scriptargumenterror), which
183
+ lives in this package.
184
+ - **`bind`.** A generated function takes the client on every call. Wrap it in a
185
+ function of your own if that gets repetitive.
186
+ - **Your own types.** An annotation that names a type from your module is
187
+ widened, as in the table above.
188
+ - **Redis Functions.** Only `@script` functions are generated, not a
189
+ [`Library`](redis-functions.md).
190
+
191
+ Keep testing the scripts against
192
+ [fakeredis](testing.md#run-the-behaviour-without-a-server), through the
193
+ generated module or through `@script`: both run the same Lua.
194
+
195
+ The script module itself is ordinary Python, so point your type checker and
196
+ linter at it along with the rest of the project.
@@ -112,6 +112,9 @@ script(client, /, *positional, **keyword) -> Awaitable[R] # async client
112
112
  | `args` | `tuple[str, ...]` | everything else, in `ARGV` order |
113
113
  | `variadic_key` | `str \| None` | the `list[Key]` parameter, which fills the rest of `KEYS` |
114
114
  | `variadic_arg` | `str \| None` | the list parameter that fills the rest of `ARGV` |
115
+ | `param_annotations` | `tuple[str \| None, ...]` | each parameter's annotation as written, in `params` order |
116
+ | `return_annotation` | `str \| None` | the return annotation as written |
117
+ | `keyword_only` | `tuple[str, ...]` | the parameters declared after a bare `*` |
115
118
  | `doc` | `str \| None` | the function's docstring |
116
119
  | `source` | `str` | where it was defined, repo-relative |
117
120
 
@@ -227,8 +230,9 @@ package at runtime. See
227
230
  [Shipping without the dependency](../guide/build-time.md). Every function takes
228
231
  the module as a module object or a dotted name, and `out` as a path:
229
232
 
230
- - ending in `.py`, for one module holding every script as a string constant
231
- and importing nothing
233
+ - ending in `.py`, for one module importing only the standard library, with a
234
+ typed function per script, called the way the `@script` is, and its Lua as a
235
+ string constant
232
236
  - anything else, for a directory with one `.lua` file per script
233
237
 
234
238
  ### `codegen.generate`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.5.0"
3
+ version = "0.6.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"
@@ -77,8 +77,9 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "N", "C4", "PT"]
77
77
  python_version = "3.10"
78
78
  strict = true
79
79
  # test_typing.py carries assert_type() claims about what a call returns, which
80
- # are only worth anything if mypy actually reads them.
81
- files = ["src", "tests/test_typing.py"]
80
+ # are only worth anything if mypy actually reads them. generated_scripts.py is
81
+ # codegen output, checked in so that strict mode reads what adopters ship.
82
+ files = ["src", "tests/test_typing.py", "tests/generated_scripts.py"]
82
83
  mypy_path = "tests"
83
84
 
84
85
  # A script body returns Lua-side values, whose Python type is Any by
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.5.0" # x-release-please-version
70
+ __version__ = "0.6.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload
@@ -2353,6 +2353,10 @@ class CompiledBody:
2353
2353
  doc: str | None
2354
2354
  filename: str
2355
2355
  first_lineno: int
2356
+ #: Each parameter's annotation as written, in the order of ``params``.
2357
+ param_annotations: tuple[str | None, ...] = ()
2358
+ return_annotation: str | None = None
2359
+ keyword_only: tuple[str, ...] = ()
2356
2360
 
2357
2361
  @property
2358
2362
  def provenance(self) -> str:
@@ -2389,6 +2393,9 @@ def compile_function(
2389
2393
  variadic_arg=compiled.variadic_arg,
2390
2394
  doc=compiled.doc,
2391
2395
  source=f"{compiled.filename}:{compiled.first_lineno}",
2396
+ param_annotations=compiled.param_annotations,
2397
+ return_annotation=compiled.return_annotation,
2398
+ keyword_only=compiled.keyword_only,
2392
2399
  )
2393
2400
 
2394
2401
 
@@ -2454,4 +2461,12 @@ def compile_body(func: Callable[..., Any], *, name: str | None = None) -> Compil
2454
2461
  doc=doc,
2455
2462
  filename=filename,
2456
2463
  first_lineno=first_lineno,
2464
+ # Kept as source text: the compiler does not need them, but a generated
2465
+ # function repeats them in its signature.
2466
+ param_annotations=tuple(
2467
+ None if arg.annotation is None else ast.unparse(arg.annotation)
2468
+ for arg in [*node.args.args, *node.args.kwonlyargs]
2469
+ ),
2470
+ return_annotation=None if node.returns is None else ast.unparse(node.returns),
2471
+ keyword_only=tuple(arg.arg for arg in node.args.kwonlyargs),
2457
2472
  )
@@ -43,6 +43,23 @@ def _function_missing(error: BaseException) -> bool:
43
43
  return "Function not found" in str(error)
44
44
 
45
45
 
46
+ def _modern_function_load(client: object) -> Any:
47
+ """The client's function_load, if it takes the library code first.
48
+
49
+ Early redis-py releases, 4.2.0 among them, still have the Redis 7
50
+ release-candidate signature, function_load(engine, library, code), which
51
+ no released Redis accepts.
52
+ """
53
+ function_load = getattr(client, "function_load", None)
54
+ if function_load is None:
55
+ return None
56
+ try:
57
+ parameters = list(inspect.signature(function_load).parameters)
58
+ except (TypeError, ValueError): # a callable without an inspectable signature
59
+ return None
60
+ return function_load if parameters[:1] == ["code"] else None
61
+
62
+
46
63
  class Library:
47
64
  """A Redis Functions library, built from Python functions.
48
65
 
@@ -130,8 +147,10 @@ class Library:
130
147
  is only needed ahead of a pipeline, or to load at deploy time. Returns
131
148
  what the client returns: an awaitable for an async client.
132
149
  """
133
- function_load = getattr(client, "function_load", None)
150
+ function_load = _modern_function_load(client)
134
151
  if function_load is not None:
152
+ # Preferred where it exists, since redis-py routes it to every
153
+ # primary of a cluster.
135
154
  return function_load(self.lua, replace=True)
136
155
  return client.execute_command("FUNCTION", "LOAD", "REPLACE", self.lua)
137
156
 
@@ -0,0 +1,116 @@
1
+ """Calling a compiled script: the part that has to work without this package.
2
+
3
+ Everything below the imports is copied, verbatim, into every module that
4
+ :mod:`redis_lua_py.codegen` generates, and :mod:`._script` calls the same
5
+ functions at runtime. That is how a generated wrapper and a ``CompiledScript``
6
+ stay in step: there is only one copy of this code to get right.
7
+
8
+ So this module imports only the standard library, and nothing from the
9
+ package, and every name it defines starts with an underscore, to stay out of
10
+ the way of the scripts in a generated module.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable
16
+ from typing import Any, Protocol
17
+ from weakref import WeakKeyDictionary
18
+
19
+ #: What a generated signature accepts for a key, the same as redis-py does.
20
+ _Key = str | bytes | memoryview
21
+
22
+ #: What a generated signature accepts for an argument it has no better type for.
23
+ _Arg = str | bytes | memoryview | int | float
24
+
25
+
26
+ class _AsyncClient(Protocol):
27
+ """Enough of an async redis-py client to tell it from a sync one.
28
+
29
+ Only used to type the two shapes of call. ``__aenter__`` is the
30
+ discriminator because every async client has one and no sync client does,
31
+ on every supported redis-py -- ``aclose`` only arrived in redis-py 5.
32
+ """
33
+
34
+ async def __aenter__(self) -> Any: ...
35
+
36
+ def register_script(self, script: str) -> Any: ...
37
+
38
+
39
+ def _encode(
40
+ name: str, value: object, error: type[Exception] = TypeError
41
+ ) -> str | bytes | memoryview:
42
+ """Render a Python value as a Redis argument.
43
+
44
+ Redis has no argument types: everything on the wire is a byte string. This
45
+ only accepts values whose string form is unambiguous, so that a stray None
46
+ or object fails here rather than arriving in Lua as something surprising.
47
+ """
48
+ if isinstance(value, bool):
49
+ return "1" if value else "0"
50
+ if isinstance(value, str | bytes | memoryview):
51
+ return value
52
+ if isinstance(value, int | float):
53
+ return repr(value) if isinstance(value, float) else str(value)
54
+ raise error(
55
+ f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
56
+ "Pass a str, bytes, int, float or bool."
57
+ )
58
+
59
+
60
+ def _items(name: str, value: object, error: type[Exception] = TypeError) -> list[Any]:
61
+ """The elements passed for a list parameter.
62
+
63
+ A string is iterable too, and splitting a key into characters is never
64
+ what was meant, so it is refused rather than spread.
65
+ """
66
+ if isinstance(value, str | bytes | memoryview) or not isinstance(value, Iterable):
67
+ raise error(
68
+ f"argument {name!r} takes a list, got a {type(value).__name__}. "
69
+ "Wrap a single value in a list."
70
+ )
71
+ return list(value)
72
+
73
+
74
+ def _encode_all(
75
+ name: str, value: object, error: type[Exception] = TypeError
76
+ ) -> list[str | bytes | memoryview]:
77
+ """The elements passed for a list of arguments, each rendered for Redis."""
78
+ return [_encode(name, item, error) for item in _items(name, value, error)]
79
+
80
+
81
+ def _is_cluster_pipeline(client: object) -> bool:
82
+ cls = type(client)
83
+ return cls.__name__ == "ClusterPipeline" and cls.__module__.startswith("redis.")
84
+
85
+
86
+ def _registered(lua: str, registry: WeakKeyDictionary[Any, Any], client: Any) -> Any:
87
+ """The redis-py Script for this source on this client, registered once.
88
+
89
+ redis-py's own Script object already implements the EVALSHA-then-EVAL
90
+ dance and the NOSCRIPT retry, so this defers to it rather than
91
+ reimplementing script caching.
92
+ """
93
+ try:
94
+ registered = registry.get(client)
95
+ except TypeError: # a client that does not support weak references
96
+ return client.register_script(lua)
97
+ if registered is None:
98
+ registered = client.register_script(lua)
99
+ registry[client] = registered
100
+ return registered
101
+
102
+
103
+ def _run(
104
+ lua: str,
105
+ registry: WeakKeyDictionary[Any, Any],
106
+ client: Any,
107
+ keys: list[Any],
108
+ argv: list[Any],
109
+ ) -> Any:
110
+ """Run a script: a value from a sync client, an awaitable from an async one."""
111
+ if _is_cluster_pipeline(client):
112
+ # redis-py refuses EVALSHA on a cluster pipeline, and a queued
113
+ # EVALSHA could not recover from NOSCRIPT at execute time anyway,
114
+ # so the source travels with the command.
115
+ return client.eval(lua, len(keys), *keys, *argv)
116
+ return _registered(lua, registry, client)(keys=keys, args=argv, client=client)
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import difflib
6
- from collections.abc import Awaitable, Iterable
6
+ from collections.abc import Awaitable
7
7
  from dataclasses import dataclass, field
8
8
  from typing import Any, Generic, Protocol, TypeVar, overload
9
9
  from weakref import WeakKeyDictionary
10
10
 
11
+ from ._portable import _AsyncClient, _encode, _encode_all, _items, _registered, _run
11
12
  from .errors import ScriptArgumentError
12
13
 
13
14
  #: What a script's return annotation describes: the value the *caller* gets
@@ -17,56 +18,9 @@ R = TypeVar("R")
17
18
  #: What calling a bound script produces -- ``R``, or an awaitable of it.
18
19
  T = TypeVar("T")
19
20
 
20
-
21
- class AsyncClient(Protocol):
22
- """Enough of an async redis-py client to tell it from a sync one.
23
-
24
- Only used to type the two shapes of call. ``__aenter__`` is the
25
- discriminator because every async client has one and no sync client does,
26
- on every supported redis-py -- ``aclose`` only arrived in redis-py 5.
27
- """
28
-
29
- async def __aenter__(self) -> Any: ... # pragma: no cover - a typing shape
30
-
31
- def register_script(self, script: str) -> Any: ... # pragma: no cover
32
-
33
-
34
- def encode(name: str, value: object) -> str | bytes | memoryview:
35
- """Render a Python value as a Redis argument.
36
-
37
- Redis has no argument types: everything on the wire is a byte string. This
38
- only accepts values whose string form is unambiguous, so that a stray None
39
- or object fails here rather than arriving in Lua as something surprising.
40
- """
41
- if isinstance(value, bool):
42
- return "1" if value else "0"
43
- if isinstance(value, str | bytes | memoryview):
44
- return value
45
- if isinstance(value, int | float):
46
- return repr(value) if isinstance(value, float) else str(value)
47
- raise ScriptArgumentError(
48
- f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
49
- "Pass a str, bytes, int, float or bool."
50
- )
51
-
52
-
53
- def _items(name: str, value: object) -> list[object]:
54
- """The elements passed for a list parameter.
55
-
56
- A string is iterable too, and splitting a key into characters is never
57
- what was meant, so it is refused rather than spread.
58
- """
59
- if isinstance(value, str | bytes | memoryview) or not isinstance(value, Iterable):
60
- raise ScriptArgumentError(
61
- f"argument {name!r} takes a list, got a {type(value).__name__}. "
62
- "Wrap a single value in a list."
63
- )
64
- return list(value)
65
-
66
-
67
- def _is_cluster_pipeline(client: object) -> bool:
68
- cls = type(client)
69
- return cls.__name__ == "ClusterPipeline" and cls.__module__.startswith("redis.")
21
+ #: Enough of an async redis-py client to tell it from a sync one. It lives with
22
+ #: the rest of the call path in ``_portable``, which generated modules copy.
23
+ AsyncClient = _AsyncClient
70
24
 
71
25
 
72
26
  def resolve_arguments(
@@ -104,15 +58,15 @@ def resolve_arguments(
104
58
  resolved_keys: list[Any] = []
105
59
  for k in keys:
106
60
  if k == variadic_key:
107
- resolved_keys.extend(_items(k, values[k]))
61
+ resolved_keys.extend(_items(k, values[k], ScriptArgumentError))
108
62
  else:
109
63
  resolved_keys.append(values[k])
110
64
  argv: list[Any] = []
111
65
  for a in args:
112
66
  if a == variadic_arg:
113
- argv.extend(encode(a, item) for item in _items(a, values[a]))
67
+ argv.extend(_encode_all(a, values[a], ScriptArgumentError))
114
68
  else:
115
- argv.append(encode(a, values[a]))
69
+ argv.append(_encode(a, values[a], ScriptArgumentError))
116
70
  return resolved_keys, argv
117
71
 
118
72
 
@@ -165,6 +119,13 @@ class CompiledScript(Generic[R]):
165
119
  variadic_key: str | None = None
166
120
  #: The list parameter whose elements fill the rest of ARGV.
167
121
  variadic_arg: str | None = None
122
+ #: Each parameter's annotation as written in the source, or None, in the
123
+ #: order of ``params``; what a generated function's signature is built from.
124
+ param_annotations: tuple[str | None, ...] = ()
125
+ #: The return annotation as written in the source, or None.
126
+ return_annotation: str | None = None
127
+ #: The parameters declared after a bare ``*``.
128
+ keyword_only: tuple[str, ...] = ()
168
129
  _registry: WeakKeyDictionary[Any, Any] = field(
169
130
  default_factory=WeakKeyDictionary, compare=False, repr=False
170
131
  )
@@ -179,12 +140,7 @@ class CompiledScript(Generic[R]):
179
140
 
180
141
  def __call__(self, client: Any, /, *positional: object, **keyword: object) -> Any:
181
142
  keys, argv = self.resolve(*positional, **keyword)
182
- if _is_cluster_pipeline(client):
183
- # redis-py refuses EVALSHA on a cluster pipeline, and a queued
184
- # EVALSHA could not recover from NOSCRIPT at execute time anyway,
185
- # so the source travels with the command.
186
- return client.eval(self.lua, len(keys), *keys, *argv)
187
- return self._for(client)(keys=keys, args=argv, client=client)
143
+ return _run(self.lua, self._registry, client, keys, argv)
188
144
 
189
145
  @overload
190
146
  def bind(self, client: AsyncClient) -> BoundScript[Awaitable[R]]: ...
@@ -215,20 +171,8 @@ class CompiledScript(Generic[R]):
215
171
  )
216
172
 
217
173
  def _for(self, client: Any) -> Any:
218
- """Get the redis-py Script bound to this client, registering it once.
219
-
220
- redis-py's own Script object already implements the EVALSHA-then-EVAL
221
- dance and the NOSCRIPT retry, so this defers to it rather than
222
- reimplementing script caching.
223
- """
224
- try:
225
- registered = self._registry.get(client)
226
- except TypeError: # a client that does not support weak references
227
- return client.register_script(self.lua)
228
- if registered is None:
229
- registered = client.register_script(self.lua)
230
- self._registry[client] = registered
231
- return registered
174
+ """Get the redis-py Script bound to this client, registering it once."""
175
+ return _registered(self.lua, self._registry, client)
232
176
 
233
177
  def __repr__(self) -> str:
234
178
  signature = ", ".join(self.params)