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