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.
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/ci.yml +15 -1
- redis_lua_py-0.6.0/.release-please-manifest.json +3 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/CHANGELOG.md +14 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/PKG-INFO +4 -3
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/README.md +3 -2
- redis_lua_py-0.6.0/docs/guide/build-time.md +196 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/api.md +6 -2
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/pyproject.toml +4 -3
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/__init__.py +1 -1
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_compile.py +15 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_library.py +20 -1
- redis_lua_py-0.6.0/src/redis_lua_py/_portable.py +116 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_script.py +18 -74
- redis_lua_py-0.6.0/src/redis_lua_py/codegen.py +429 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/codegen_scripts.py +21 -2
- redis_lua_py-0.6.0/tests/generated_scripts.py +296 -0
- redis_lua_py-0.6.0/tests/test_codegen.py +378 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_functions.py +22 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_typing.py +11 -0
- redis_lua_py-0.5.0/.release-please-manifest.json +0 -3
- redis_lua_py-0.5.0/docs/guide/build-time.md +0 -152
- redis_lua_py-0.5.0/src/redis_lua_py/codegen.py +0 -221
- redis_lua_py-0.5.0/tests/test_codegen.py +0 -229
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/README.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/banner-dark.svg +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/banner-light.svg +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/generate.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/logo.svg +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/logomark.svg +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/social-preview.png +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/assets/social-preview.svg +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/docs.yml +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/pr-title.yml +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.github/workflows/release.yml +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.gitignore +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/.python-version +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/CONTRIBUTING.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/LICENSE +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/assets/logo.svg +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/contributing.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/development.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/examples.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/async.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/binary-values.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/binding-a-client.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/calling-redis-commands.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/constants.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/generated-lua.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/keys-and-arguments.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/redis-functions.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/return-values.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/guide/testing.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/index.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/installation.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/quickstart.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/errors.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/lua-vs-python.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/reference/supported-subset.md +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/docs/stylesheets/extra.css +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/release-please-config.json +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/scripts/generate_commands.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/__main__.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_commands.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_lua.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/_runtime.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/errors.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/src/redis_lua_py/py.typed +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/conftest.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/module_with_client.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/module_without_import.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_binary.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_binding.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_commands.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_compile.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_constants.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_control_flow.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_errors.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_execute.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_mistranslations.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_namespace.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_semantics.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_strings_and_indexing.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/tests/test_table_stakes.py +0 -0
- {redis_lua_py-0.5.0 → redis_lua_py-0.6.0}/uv.lock +0 -0
- {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
|
|
@@ -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.
|
|
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
|
|
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`
|
|
@@ -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
|
|
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`
|
|
@@ -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
|
|
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`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "redis-lua-py"
|
|
3
|
-
version = "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
|
-
|
|
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
|
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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(
|
|
67
|
+
argv.extend(_encode_all(a, values[a], ScriptArgumentError))
|
|
114
68
|
else:
|
|
115
|
-
argv.append(
|
|
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
|
-
|
|
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)
|