redis-lua-py 0.9.0__tar.gz → 0.11.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.11.0/.release-please-manifest.json +3 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/CHANGELOG.md +20 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/CONTRIBUTING.md +3 -2
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/PKG-INFO +1 -1
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/development.md +3 -2
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/async.md +5 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/build-time.md +2 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/calling-redis-commands.md +16 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/api.md +6 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/lua-vs-python.md +27 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/supported-subset.md +4 -2
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/pyproject.toml +1 -1
- redis_lua_py-0.11.0/scripts/generate_commands.py +329 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/__init__.py +1 -1
- redis_lua_py-0.11.0/src/redis_lua_py/_command_stubs.py +7466 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/base.py +5 -1
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/calls.py +65 -13
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/control.py +6 -4
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/expressions.py +7 -7
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/scope.py +6 -6
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/tables.py +14 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_library.py +7 -1
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_lua.py +50 -5
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_portable.py +14 -0
- redis_lua_py-0.11.0/src/redis_lua_py/_runtime.py +215 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_script.py +11 -1
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/codegen.py +2 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/generated_scripts.py +24 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_booleans_and_conversions.py +164 -1
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_commands.py +72 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_errors.py +12 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_typing.py +51 -2
- redis_lua_py-0.9.0/.release-please-manifest.json +0 -3
- redis_lua_py-0.9.0/scripts/generate_commands.py +0 -105
- redis_lua_py-0.9.0/src/redis_lua_py/_runtime.py +0 -72
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/README.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/banner-dark.svg +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/banner-light.svg +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/generate.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/logo.svg +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/logomark.svg +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/social-preview.png +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/social-preview.svg +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/ci.yml +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/docs.yml +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/pr-title.yml +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/release.yml +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.gitignore +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.python-version +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/LICENSE +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/README.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/assets/logo.svg +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/contributing.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/examples.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/binary-values.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/binding-a-client.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/constants.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/generated-lua.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/keys-and-arguments.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/redis-functions.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/return-values.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/testing.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/index.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/installation.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/quickstart.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/errors.md +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/stylesheets/extra.css +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/release-please-config.json +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/__main__.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_commands.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/__init__.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/analysis.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/helpers.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/source.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/statements.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/errors.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/py.typed +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/codegen_scripts.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/conftest.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/module_with_client.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/module_without_import.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_binary.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_binding.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_codegen.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_compile.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_constants.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_control_flow.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_coredis.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_execute.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_expressions.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_functions.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_mistranslations.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_namespace.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_semantics.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_strings_and_indexing.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_table_stakes.py +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/uv.lock +0 -0
- {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/zensical.toml +0 -0
|
@@ -7,6 +7,26 @@ 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.11.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.10.0...v0.11.0) (2026-09-25)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
* compile isinstance and cjson.null in script bodies ([373c4b7](https://github.com/IgnaceMaes/redis-lua-py/commit/373c4b79d58994e7a67ada0db9572496839af8da))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* rename locals that would shadow the Lua globals scripts rely on ([373c4b7](https://github.com/IgnaceMaes/redis-lua-py/commit/373c4b79d58994e7a67ada0db9572496839af8da))
|
|
21
|
+
* type a client wrapper with getattr forwarding as sync, not async ([373c4b7](https://github.com/IgnaceMaes/redis-lua-py/commit/373c4b79d58994e7a67ada0db9572496839af8da))
|
|
22
|
+
|
|
23
|
+
## [0.10.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.9.0...v0.10.0) (2026-09-14)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
* document every redis command on hover in editors ([#28](https://github.com/IgnaceMaes/redis-lua-py/issues/28)) ([5d86e35](https://github.com/IgnaceMaes/redis-lua-py/commit/5d86e355c97d9a597cfc59985feaa88bc3a6fc17))
|
|
29
|
+
|
|
10
30
|
## [0.9.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.8.0...v0.9.0) (2026-09-13)
|
|
11
31
|
|
|
12
32
|
|
|
@@ -12,8 +12,9 @@ Redis.
|
|
|
12
12
|
|
|
13
13
|
`src/redis_lua_py/_commands.py` is generated, not written. It is the table
|
|
14
14
|
command names are checked against, and it comes from the command definitions
|
|
15
|
-
in the Redis source.
|
|
16
|
-
the
|
|
15
|
+
in the Redis source. The same run writes `src/redis_lua_py/_command_stubs.py`,
|
|
16
|
+
the typed method per command that editors show on hover. Refresh both when a
|
|
17
|
+
Redis release adds commands, and commit the result:
|
|
17
18
|
|
|
18
19
|
```sh
|
|
19
20
|
uv run python scripts/generate_commands.py 8.10.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: redis-lua-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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/
|
|
@@ -24,8 +24,9 @@ part you would most want a real server for.
|
|
|
24
24
|
|
|
25
25
|
`src/redis_lua_py/_commands.py` is generated, not written. It is the table
|
|
26
26
|
command names are checked against, and it comes from the command definitions
|
|
27
|
-
in the Redis source.
|
|
28
|
-
the
|
|
27
|
+
in the Redis source. The same run writes `src/redis_lua_py/_command_stubs.py`,
|
|
28
|
+
the typed method per command that editors show on hover. Refresh both when a
|
|
29
|
+
Redis release adds commands, and commit the result:
|
|
29
30
|
|
|
30
31
|
```bash
|
|
31
32
|
uv run python scripts/generate_commands.py 8.10.1
|
|
@@ -22,6 +22,11 @@ a = rate_limit(sync_client, key="u:1", limit=10, ttl=60) # int
|
|
|
22
22
|
b = rate_limit(async_client, key="u:1", limit=10, ttl=60) # Awaitable[int]
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
A client is typed as sync when it has `__enter__`, as every redis-py sync
|
|
26
|
+
client does, and as async when it has `__aenter__`. The sync overload comes
|
|
27
|
+
first, so a wrapper of your own that forwards attributes through `__getattr__`,
|
|
28
|
+
which mypy takes to provide both, is typed as the sync client it wraps.
|
|
29
|
+
|
|
25
30
|
Script caching, `EVALSHA`, and the `NOSCRIPT` reload are handled by redis-py's
|
|
26
31
|
own script machinery, which this defers to rather than reimplementing.
|
|
27
32
|
|
|
@@ -58,6 +58,22 @@ commands are checked down to the subcommand, and a hyphenated one is reached
|
|
|
58
58
|
through its underscores — `redis.client_no_evict("on")` compiles to
|
|
59
59
|
`redis.call('CLIENT', 'NO-EVICT', 'on')`.
|
|
60
60
|
|
|
61
|
+
## In your editor
|
|
62
|
+
|
|
63
|
+
Type checkers see every command as a method, generated from the same command
|
|
64
|
+
definitions as the table, so hovering `redis.hset` shows what the command does,
|
|
65
|
+
its syntax, its reply and its complexity, with a link to its page on redis.io.
|
|
66
|
+
The Lua API's own functions, such as `redis.pcall` and `redis.log`, and
|
|
67
|
+
`cjson.encode` / `cjson.decode`, are documented the same way.
|
|
68
|
+
|
|
69
|
+
The leading plain arguments are named, so a call with too few or too many is
|
|
70
|
+
flagged before the compiler sees it. Past the first option the order depends on
|
|
71
|
+
which options are given, so the rest is `*args` and the syntax in the hover says
|
|
72
|
+
what goes there. Every return type is `Any`: what a reply looks like inside Lua
|
|
73
|
+
is not something Python can know. A spelling that is not one method per
|
|
74
|
+
command, such as `redis.debug_object(k)`, still type-checks, and is left to the
|
|
75
|
+
compiler.
|
|
76
|
+
|
|
61
77
|
## The escape hatch
|
|
62
78
|
|
|
63
79
|
`redis.call(...)` is deliberately never checked. It is the escape hatch for
|
|
@@ -79,6 +79,10 @@ Names are checked at compile time against Redis' own command table;
|
|
|
79
79
|
The compiler identifies the namespace by value rather than by the name it is
|
|
80
80
|
imported under, so every alias works and nothing is reserved.
|
|
81
81
|
|
|
82
|
+
Type checkers see a method per command, documented from Redis' own command
|
|
83
|
+
definitions, so an editor shows each command's syntax on hover. See
|
|
84
|
+
[In your editor](../guide/calling-redis-commands.md#in-your-editor).
|
|
85
|
+
|
|
82
86
|
## `call`
|
|
83
87
|
|
|
84
88
|
An alias of [`redis`](#redis), for modules that would rather not rename
|
|
@@ -88,6 +92,8 @@ anything.
|
|
|
88
92
|
|
|
89
93
|
The JSON library Redis exposes to scripts: `cjson.encode` and `cjson.decode`,
|
|
90
94
|
which pass through under their own names.
|
|
95
|
+
`cjson.null` is JSON `null`, which `None` is not: a field set to `None` is
|
|
96
|
+
dropped from an encoded object.
|
|
91
97
|
|
|
92
98
|
## `CompiledScript`
|
|
93
99
|
|
|
@@ -19,6 +19,24 @@ never runs. `x is None` compiles to a helper accepting both, which also takes
|
|
|
19
19
|
twice. `x == None` and `x != None` compile to the same helper, since that is
|
|
20
20
|
plainly what they mean.
|
|
21
21
|
|
|
22
|
+
`cjson.decode` gives JSON `null` as `cjson.null`, which is neither: compare
|
|
23
|
+
with `== cjson.null`. Setting a field to `None` removes it, so write
|
|
24
|
+
`cjson.null` where the encoded object should keep the field as `null`.
|
|
25
|
+
|
|
26
|
+
## `isinstance` has Lua's types
|
|
27
|
+
|
|
28
|
+
`isinstance(x, dict)` compiles to `type(x) == 'table'`, the usual guard on a
|
|
29
|
+
decoded JSON value before reading its fields. Lua has fewer types than Python,
|
|
30
|
+
so some tests answer for more than their name says:
|
|
31
|
+
|
|
32
|
+
- `int` and `float` both test for a number, which may have a fraction;
|
|
33
|
+
- `dict` and `list` both test for a table;
|
|
34
|
+
- `str` and `bytes` both test for a string;
|
|
35
|
+
- `bool` is not a number, so `isinstance(True, int)` is false.
|
|
36
|
+
|
|
37
|
+
A tuple or a `|` union of types tests for any of them. A class of your own has
|
|
38
|
+
no Lua counterpart, and is refused.
|
|
39
|
+
|
|
22
40
|
## Indexing is closed
|
|
23
41
|
|
|
24
42
|
Lua tables are 1-based. `items[0]` compiles to `items[1]`. Write Python indices
|
|
@@ -53,6 +71,15 @@ Python scopes a name to the whole function; Lua's `local` scopes it to the
|
|
|
53
71
|
enclosing block. A name assigned inside an `if` and read after it is hoisted to
|
|
54
72
|
the top of the script, so it does not silently read back `nil`.
|
|
55
73
|
|
|
74
|
+
## Names Lua already uses are closed
|
|
75
|
+
|
|
76
|
+
The generated Lua calls globals such as `type`, `error`, `tostring` and
|
|
77
|
+
`pairs`, and reads `KEYS` and `ARGV`. A local of the same name would shadow
|
|
78
|
+
them for the rest of the script, so a parameter named `error` would break
|
|
79
|
+
every `raise` after it. Such a name is renamed with a trailing underscore
|
|
80
|
+
instead: `local error_ = ARGV[1]`. The keyword argument on the Python side
|
|
81
|
+
keeps its name.
|
|
82
|
+
|
|
56
83
|
## Strings are closed
|
|
57
84
|
|
|
58
85
|
Lua's `+` is only arithmetic. It will add `"1" + "2"` to `3`. So `+`
|
|
@@ -27,10 +27,12 @@ language with a faithful Lua meaning is accepted.
|
|
|
27
27
|
`decode()` in UTF-8, which leave the bytes as they are.
|
|
28
28
|
- **Builtins and methods:** `len()`, `int()`, which truncates toward zero,
|
|
29
29
|
`float()`, `str()`, `min()`, `max()`, `abs()`, `ord()`, `chr()`, `.append()`,
|
|
30
|
-
`.insert()`, `.pop()` and `dict.get()
|
|
30
|
+
`.insert()`, `.pop()` and `dict.get()`; `isinstance()` against `str`,
|
|
31
|
+
`bytes`, `int`, `float`, `bool`, `dict` and `list`.
|
|
31
32
|
- **The `math` module:** `floor`, `ceil`, `sqrt`, `fabs`, `fmod`, `exp`,
|
|
32
33
|
`log`, `log10` and `pow`, imported either way.
|
|
33
|
-
- **Calls:** into `redis` and `cjson`, with `*xs` allowed as the last argument
|
|
34
|
+
- **Calls:** into `redis` and `cjson`, with `*xs` allowed as the last argument,
|
|
35
|
+
and `cjson.null` for a JSON null.
|
|
34
36
|
- **Parameters:** `list[Key]` and `list[...]`, for a variable number of keys
|
|
35
37
|
and arguments.
|
|
36
38
|
- **Module-level constants.**
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Regenerate the command table and its typed stubs from the Redis source tree.
|
|
2
|
+
|
|
3
|
+
The compiler refuses a command Redis does not have, which means it needs to
|
|
4
|
+
know what Redis has. The authority for that is ``src/commands/*.json`` in the
|
|
5
|
+
Redis repository — the same files the server itself is built from — so this
|
|
6
|
+
downloads a tagged release and reads them rather than transcribing a list.
|
|
7
|
+
|
|
8
|
+
uv run python scripts/generate_commands.py [version]
|
|
9
|
+
|
|
10
|
+
Two files come out of it. ``src/redis_lua_py/_commands.py`` is the table the
|
|
11
|
+
compiler checks names against. ``src/redis_lua_py/_command_stubs.py`` is a class
|
|
12
|
+
with one method per command, carrying the summary, syntax and complexity from
|
|
13
|
+
the same JSON, which type checkers see in place of the namespace so that an
|
|
14
|
+
editor can show them on hover.
|
|
15
|
+
|
|
16
|
+
Run it when a Redis release adds commands, and commit the result. The command
|
|
17
|
+
table only gates the ``redis.<name>()`` spelling: ``redis.call('NEW.CMD', ...)``
|
|
18
|
+
is deliberately never checked, so a stale table can never block a caller.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import keyword
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tarfile
|
|
30
|
+
import textwrap
|
|
31
|
+
import urllib.request
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from redis_lua_py._compile.tables import COMMAND_ALIASES, REDIS_DIRECT
|
|
36
|
+
|
|
37
|
+
DEFAULT_VERSION = "8.10.1"
|
|
38
|
+
SOURCE = "https://github.com/redis/redis/archive/refs/tags/{version}.tar.gz"
|
|
39
|
+
PACKAGE = Path(__file__).resolve().parent.parent / "src" / "redis_lua_py"
|
|
40
|
+
TABLE = PACKAGE / "_commands.py"
|
|
41
|
+
STUBS = PACKAGE / "_command_stubs.py"
|
|
42
|
+
|
|
43
|
+
Spec = dict[str, Any]
|
|
44
|
+
|
|
45
|
+
#: Argument types that are a single value, and so can be a named parameter.
|
|
46
|
+
VALUE_TYPES = frozenset({"key", "string", "integer", "double", "unix-time", "pattern"})
|
|
47
|
+
|
|
48
|
+
#: Docstring text is wrapped to this, inside a method eight spaces deep.
|
|
49
|
+
WIDTH = 88
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch(version: str) -> tuple[dict[str, Spec], dict[str, dict[str, Spec]]]:
|
|
53
|
+
"""Read every command definition out of a released Redis tarball.
|
|
54
|
+
|
|
55
|
+
A subcommand's file names it bare -- ``hotkeys-get.json`` defines ``GET``
|
|
56
|
+
with ``"container": "HOTKEYS"`` -- so entries are separated as they are
|
|
57
|
+
read. Collecting them into one dict first would let ``HOTKEYS GET`` quietly
|
|
58
|
+
displace plain ``GET``.
|
|
59
|
+
"""
|
|
60
|
+
url = SOURCE.format(version=version)
|
|
61
|
+
print(f"fetching {url}", file=sys.stderr)
|
|
62
|
+
with urllib.request.urlopen(url) as response:
|
|
63
|
+
payload = response.read()
|
|
64
|
+
|
|
65
|
+
commands: dict[str, Spec] = {}
|
|
66
|
+
subcommands: dict[str, dict[str, Spec]] = {}
|
|
67
|
+
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
|
68
|
+
for member in archive.getmembers():
|
|
69
|
+
parts = Path(member.name).parts
|
|
70
|
+
if parts[1:3] != ("src", "commands") or not member.name.endswith(".json"):
|
|
71
|
+
continue
|
|
72
|
+
handle = archive.extractfile(member)
|
|
73
|
+
if handle is None: # pragma: no cover - directories were filtered out
|
|
74
|
+
continue
|
|
75
|
+
for name, spec in json.loads(handle.read()).items():
|
|
76
|
+
container = spec.get("container")
|
|
77
|
+
if isinstance(container, str):
|
|
78
|
+
subcommands.setdefault(container.upper(), {})[name.upper()] = spec
|
|
79
|
+
else:
|
|
80
|
+
commands[name.upper()] = spec
|
|
81
|
+
if not commands:
|
|
82
|
+
raise SystemExit(f"no command definitions found in {url}")
|
|
83
|
+
return commands, subcommands
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_table(version: str, commands: set[str], subcommands: dict[str, set[str]]) -> str:
|
|
87
|
+
lines = [
|
|
88
|
+
'"""The commands Redis accepts, by name.',
|
|
89
|
+
"",
|
|
90
|
+
f"Generated by scripts/generate_commands.py from Redis {version}. Do not edit.",
|
|
91
|
+
"",
|
|
92
|
+
"``COMMANDS`` holds every command that is not a subcommand; ``SUBCOMMANDS``",
|
|
93
|
+
"maps each container command to the subcommands it takes. Together they are",
|
|
94
|
+
"what ``redis.<name>()`` is checked against, so that a name Redis does not",
|
|
95
|
+
"have is refused at import rather than at the first execution of a branch.",
|
|
96
|
+
'"""',
|
|
97
|
+
"",
|
|
98
|
+
"from __future__ import annotations",
|
|
99
|
+
"",
|
|
100
|
+
f'REDIS_VERSION = "{version}"',
|
|
101
|
+
"",
|
|
102
|
+
"COMMANDS: frozenset[str] = frozenset(",
|
|
103
|
+
" {",
|
|
104
|
+
]
|
|
105
|
+
lines += [f' "{name}",' for name in sorted(commands)]
|
|
106
|
+
lines += [" }", ")", "", "SUBCOMMANDS: dict[str, frozenset[str]] = {"]
|
|
107
|
+
for container in sorted(subcommands):
|
|
108
|
+
lines.append(f' "{container}": frozenset(')
|
|
109
|
+
lines.append(" {")
|
|
110
|
+
lines += [f' "{name}",' for name in sorted(subcommands[container])]
|
|
111
|
+
lines += [" }", " ),"]
|
|
112
|
+
lines += ["}", ""]
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def syntax(arg: Spec) -> str:
|
|
117
|
+
"""Render one argument the way the Redis documentation writes it."""
|
|
118
|
+
kind = arg["type"]
|
|
119
|
+
token = arg.get("token")
|
|
120
|
+
if kind == "pure-token":
|
|
121
|
+
text = token or arg.get("display_text") or arg["name"].upper()
|
|
122
|
+
else:
|
|
123
|
+
if kind == "oneof":
|
|
124
|
+
text = " | ".join(syntax(child) for child in arg["arguments"])
|
|
125
|
+
if not arg.get("optional") or token or arg.get("multiple"):
|
|
126
|
+
text = f"<{text}>"
|
|
127
|
+
elif kind == "block":
|
|
128
|
+
text = " ".join(syntax(child) for child in arg["arguments"])
|
|
129
|
+
else:
|
|
130
|
+
text = arg.get("display", arg["name"])
|
|
131
|
+
if arg.get("multiple"):
|
|
132
|
+
if token and arg.get("multiple_token"):
|
|
133
|
+
text = f"{token} {text} [{token} {text} ...]"
|
|
134
|
+
else:
|
|
135
|
+
text = f"{text} [{text} ...]"
|
|
136
|
+
if token:
|
|
137
|
+
text = f"{token} {text}"
|
|
138
|
+
elif token:
|
|
139
|
+
text = f"{token} {text}"
|
|
140
|
+
return f"[{text}]" if arg.get("optional") else text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def identifier(name: str, taken: set[str]) -> str:
|
|
144
|
+
base = re.sub(r"\W", "_", name.lower())
|
|
145
|
+
if keyword.iskeyword(base) or base in {"self", "args"} or base[0].isdigit():
|
|
146
|
+
base += "_"
|
|
147
|
+
candidate, n = base, 2
|
|
148
|
+
while candidate in taken:
|
|
149
|
+
candidate, n = f"{base}{n}", n + 1
|
|
150
|
+
taken.add(candidate)
|
|
151
|
+
return candidate
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parameters(tokens: tuple[str, ...], spec: Spec) -> tuple[list[str], bool]:
|
|
155
|
+
"""The named parameters a command takes, and whether more may follow.
|
|
156
|
+
|
|
157
|
+
Only the leading run of plain required values is named. Past the first
|
|
158
|
+
token, option or group, the order and count depend on which options are
|
|
159
|
+
given, so the rest is left to ``*args`` and the syntax in the docstring.
|
|
160
|
+
Redis' own arity settles the count: a signature that disagrees with it is
|
|
161
|
+
widened to ``*args`` rather than trusted.
|
|
162
|
+
"""
|
|
163
|
+
arguments = spec.get("arguments", [])
|
|
164
|
+
names: list[str] = []
|
|
165
|
+
taken: set[str] = set()
|
|
166
|
+
rest = False
|
|
167
|
+
for arg in arguments:
|
|
168
|
+
if arg["type"] not in VALUE_TYPES or arg.get("token") or arg.get("optional"):
|
|
169
|
+
rest = True
|
|
170
|
+
break
|
|
171
|
+
names.append(identifier(arg["name"], taken))
|
|
172
|
+
if arg.get("multiple"):
|
|
173
|
+
rest = True
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
arity = spec["arity"]
|
|
177
|
+
fixed = len(tokens) + len(names)
|
|
178
|
+
if arity < 0:
|
|
179
|
+
if fixed > -arity:
|
|
180
|
+
names = names[: -arity - len(tokens)]
|
|
181
|
+
return names, True
|
|
182
|
+
if rest or fixed != arity:
|
|
183
|
+
return names[: max(arity - len(tokens), 0)], True
|
|
184
|
+
return names, False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def plain(text: str) -> str:
|
|
188
|
+
"""Turn the JSON's Markdown code spans into reST ones: `!GET` -> ``GET``."""
|
|
189
|
+
return re.sub(r"`!?([^`]+)`", r"``\1``", " ".join(text.split()))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def replies(schema: Spec) -> list[str]:
|
|
193
|
+
if "description" in schema:
|
|
194
|
+
return [schema["description"]]
|
|
195
|
+
for key in ("oneOf", "anyOf"):
|
|
196
|
+
if key in schema:
|
|
197
|
+
return [option["description"] for option in schema[key] if "description" in option]
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def docstring(tokens: tuple[str, ...], spec: Spec, has_args: bool) -> list[str]:
|
|
202
|
+
paragraphs: list[list[str]] = [textwrap.wrap(plain(spec["summary"]), WIDTH)]
|
|
203
|
+
|
|
204
|
+
if "deprecated_since" in spec:
|
|
205
|
+
note = f"Deprecated since Redis {spec['deprecated_since']}"
|
|
206
|
+
if "replaced_by" in spec:
|
|
207
|
+
note += f", replaced by {plain(spec['replaced_by'])}"
|
|
208
|
+
paragraphs.append(textwrap.wrap(note + ".", WIDTH))
|
|
209
|
+
|
|
210
|
+
usage = " ".join([*tokens, *(syntax(arg) for arg in spec.get("arguments", []))])
|
|
211
|
+
wrapped = textwrap.wrap(
|
|
212
|
+
usage,
|
|
213
|
+
WIDTH - 4,
|
|
214
|
+
subsequent_indent=" ",
|
|
215
|
+
break_long_words=False,
|
|
216
|
+
break_on_hyphens=False,
|
|
217
|
+
)
|
|
218
|
+
paragraphs.append(["Syntax::"])
|
|
219
|
+
paragraphs.append([" " + line for line in wrapped])
|
|
220
|
+
|
|
221
|
+
reply = [plain(text) for text in replies(spec.get("reply_schema", {}))]
|
|
222
|
+
if len(reply) == 1:
|
|
223
|
+
paragraphs.append(textwrap.wrap(f"Reply: {reply[0]}", WIDTH))
|
|
224
|
+
elif reply:
|
|
225
|
+
bullets = ["Reply, one of:", ""]
|
|
226
|
+
for text in reply:
|
|
227
|
+
bullets += textwrap.wrap(text, WIDTH, initial_indent="- ", subsequent_indent=" ")
|
|
228
|
+
paragraphs.append(bullets)
|
|
229
|
+
|
|
230
|
+
if "complexity" in spec:
|
|
231
|
+
paragraphs.append(textwrap.wrap(f"Time complexity: {plain(spec['complexity'])}", WIDTH))
|
|
232
|
+
|
|
233
|
+
call = ", ".join([*(f"'{token}'" for token in tokens), *(["..."] if has_args else [])])
|
|
234
|
+
paragraphs.append(
|
|
235
|
+
textwrap.wrap(
|
|
236
|
+
f"Available since Redis {spec['since']}. Compiles to ``redis.call({call})``.", WIDTH
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
if spec["group"] != "sentinel": # Sentinel commands have no page of their own.
|
|
240
|
+
page = "-".join(tokens).lower()
|
|
241
|
+
paragraphs.append([f"https://redis.io/docs/latest/commands/{page}/"])
|
|
242
|
+
|
|
243
|
+
lines: list[str] = []
|
|
244
|
+
for paragraph in paragraphs:
|
|
245
|
+
if lines:
|
|
246
|
+
lines.append("")
|
|
247
|
+
lines += paragraph
|
|
248
|
+
return lines
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def method(name: str, tokens: tuple[str, ...], spec: Spec) -> list[str]:
|
|
252
|
+
names, rest = parameters(tokens, spec)
|
|
253
|
+
params = ["self", *(f"{param}: Any" for param in names)]
|
|
254
|
+
if names:
|
|
255
|
+
params.append("/")
|
|
256
|
+
if rest:
|
|
257
|
+
params.append("*args: Any")
|
|
258
|
+
|
|
259
|
+
doc = [line.replace("\\", "\\\\") for line in docstring(tokens, spec, bool(names) or rest)]
|
|
260
|
+
lines = [f" def {name}({', '.join(params)}) -> Any:"]
|
|
261
|
+
lines.append(f' """{doc[0]}')
|
|
262
|
+
lines += [f" {line}".rstrip() for line in doc[1:]]
|
|
263
|
+
lines.append(' """')
|
|
264
|
+
return lines
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def render_stubs(
|
|
268
|
+
version: str, commands: dict[str, Spec], subcommands: dict[str, dict[str, Spec]]
|
|
269
|
+
) -> str:
|
|
270
|
+
entries: dict[str, tuple[tuple[str, ...], Spec]] = {}
|
|
271
|
+
for command, spec in commands.items():
|
|
272
|
+
if command in subcommands:
|
|
273
|
+
continue # A container needs a subcommand; it is not callable bare.
|
|
274
|
+
if "-" in command and command.split("-")[0] in commands:
|
|
275
|
+
# redis.restore_asking reaches RESTORE with an ASKING argument, not
|
|
276
|
+
# RESTORE-ASKING, so a stub under that name would describe the wrong command.
|
|
277
|
+
continue
|
|
278
|
+
entries[command.lower().replace("-", "_")] = ((command,), spec)
|
|
279
|
+
for container, members in subcommands.items():
|
|
280
|
+
for sub, spec in members.items():
|
|
281
|
+
entries[f"{container}_{sub}".lower().replace("-", "_")] = ((container, sub), spec)
|
|
282
|
+
for alias, tokens in COMMAND_ALIASES.items():
|
|
283
|
+
entries[alias] = (tokens, commands[tokens[0]])
|
|
284
|
+
for name in [*entries]:
|
|
285
|
+
if keyword.iskeyword(name) or name in REDIS_DIRECT:
|
|
286
|
+
del entries[name]
|
|
287
|
+
|
|
288
|
+
lines = [
|
|
289
|
+
'"""A typed method for every Redis command a script body can call.',
|
|
290
|
+
"",
|
|
291
|
+
f"Generated by scripts/generate_commands.py from Redis {version}. Do not edit.",
|
|
292
|
+
"",
|
|
293
|
+
"Nothing imports this at runtime. Type checkers see the ``redis`` namespace as",
|
|
294
|
+
"a subclass of ``RedisCommands``, so an editor can show each command's syntax",
|
|
295
|
+
"and documentation on hover, while the compiler goes on resolving the",
|
|
296
|
+
"namespace by value.",
|
|
297
|
+
'"""',
|
|
298
|
+
"",
|
|
299
|
+
"from __future__ import annotations",
|
|
300
|
+
"",
|
|
301
|
+
"from typing import Any",
|
|
302
|
+
"",
|
|
303
|
+
"",
|
|
304
|
+
"class RedisCommands:",
|
|
305
|
+
' """The Redis commands, as ``redis.<name>(...)`` spells them."""',
|
|
306
|
+
]
|
|
307
|
+
for name in sorted(entries):
|
|
308
|
+
lines.append("")
|
|
309
|
+
lines += method(name, *entries[name])
|
|
310
|
+
lines.append("")
|
|
311
|
+
return "\n".join(lines)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main() -> None:
|
|
315
|
+
version = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_VERSION
|
|
316
|
+
commands, subcommands = fetch(version)
|
|
317
|
+
names = {container: set(members) for container, members in subcommands.items()}
|
|
318
|
+
TABLE.write_text(render_table(version, set(commands), names))
|
|
319
|
+
STUBS.write_text(render_stubs(version, commands, subcommands))
|
|
320
|
+
subprocess.run([sys.executable, "-m", "ruff", "format", str(STUBS)], check=True)
|
|
321
|
+
print(
|
|
322
|
+
f"wrote {TABLE.relative_to(Path.cwd())} and {STUBS.relative_to(Path.cwd())}: "
|
|
323
|
+
f"{len(commands)} commands, {len(subcommands)} containers",
|
|
324
|
+
file=sys.stderr,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
main()
|