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.
Files changed (98) hide show
  1. redis_lua_py-0.11.0/.release-please-manifest.json +3 -0
  2. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/CHANGELOG.md +20 -0
  3. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/CONTRIBUTING.md +3 -2
  4. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/PKG-INFO +1 -1
  5. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/development.md +3 -2
  6. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/async.md +5 -0
  7. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/build-time.md +2 -0
  8. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/calling-redis-commands.md +16 -0
  9. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/api.md +6 -0
  10. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/lua-vs-python.md +27 -0
  11. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/supported-subset.md +4 -2
  12. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/pyproject.toml +1 -1
  13. redis_lua_py-0.11.0/scripts/generate_commands.py +329 -0
  14. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/__init__.py +1 -1
  15. redis_lua_py-0.11.0/src/redis_lua_py/_command_stubs.py +7466 -0
  16. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/base.py +5 -1
  17. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/calls.py +65 -13
  18. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/control.py +6 -4
  19. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/expressions.py +7 -7
  20. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/scope.py +6 -6
  21. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/tables.py +14 -0
  22. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_library.py +7 -1
  23. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_lua.py +50 -5
  24. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_portable.py +14 -0
  25. redis_lua_py-0.11.0/src/redis_lua_py/_runtime.py +215 -0
  26. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_script.py +11 -1
  27. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/codegen.py +2 -0
  28. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/generated_scripts.py +24 -0
  29. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_booleans_and_conversions.py +164 -1
  30. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_commands.py +72 -0
  31. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_errors.py +12 -0
  32. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_typing.py +51 -2
  33. redis_lua_py-0.9.0/.release-please-manifest.json +0 -3
  34. redis_lua_py-0.9.0/scripts/generate_commands.py +0 -105
  35. redis_lua_py-0.9.0/src/redis_lua_py/_runtime.py +0 -72
  36. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/README.md +0 -0
  37. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/banner-dark.svg +0 -0
  38. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/banner-light.svg +0 -0
  39. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/generate.py +0 -0
  40. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/logo.svg +0 -0
  41. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/logomark.svg +0 -0
  42. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/social-preview.png +0 -0
  43. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/assets/social-preview.svg +0 -0
  44. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/ci.yml +0 -0
  45. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/docs.yml +0 -0
  46. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/pr-title.yml +0 -0
  47. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.github/workflows/release.yml +0 -0
  48. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.gitignore +0 -0
  49. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/.python-version +0 -0
  50. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/LICENSE +0 -0
  51. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/README.md +0 -0
  52. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/assets/logo.svg +0 -0
  53. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/contributing.md +0 -0
  54. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/examples.md +0 -0
  55. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/binary-values.md +0 -0
  56. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/binding-a-client.md +0 -0
  57. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/constants.md +0 -0
  58. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/generated-lua.md +0 -0
  59. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/keys-and-arguments.md +0 -0
  60. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/redis-functions.md +0 -0
  61. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/return-values.md +0 -0
  62. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/guide/testing.md +0 -0
  63. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/index.md +0 -0
  64. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/installation.md +0 -0
  65. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/quickstart.md +0 -0
  66. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/reference/errors.md +0 -0
  67. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/docs/stylesheets/extra.css +0 -0
  68. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/release-please-config.json +0 -0
  69. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/__main__.py +0 -0
  70. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_commands.py +0 -0
  71. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/__init__.py +0 -0
  72. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/analysis.py +0 -0
  73. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/helpers.py +0 -0
  74. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/source.py +0 -0
  75. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/_compile/statements.py +0 -0
  76. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/errors.py +0 -0
  77. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/src/redis_lua_py/py.typed +0 -0
  78. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/codegen_scripts.py +0 -0
  79. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/conftest.py +0 -0
  80. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/module_with_client.py +0 -0
  81. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/module_without_import.py +0 -0
  82. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_binary.py +0 -0
  83. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_binding.py +0 -0
  84. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_codegen.py +0 -0
  85. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_compile.py +0 -0
  86. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_constants.py +0 -0
  87. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_control_flow.py +0 -0
  88. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_coredis.py +0 -0
  89. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_execute.py +0 -0
  90. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_expressions.py +0 -0
  91. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_functions.py +0 -0
  92. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_mistranslations.py +0 -0
  93. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_namespace.py +0 -0
  94. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_semantics.py +0 -0
  95. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_strings_and_indexing.py +0 -0
  96. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/tests/test_table_stakes.py +0 -0
  97. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/uv.lock +0 -0
  98. {redis_lua_py-0.9.0 → redis_lua_py-0.11.0}/zensical.toml +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.11.0"
3
+ }
@@ -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. Refresh it when a Redis release adds commands, and commit
16
- the result:
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.9.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. Refresh it when a Redis release adds commands, and commit
28
- the result:
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
 
@@ -81,6 +81,8 @@ return limit - current
81
81
  _RATE_LIMIT_CLIENTS: WeakKeyDictionary[Any, Any] = WeakKeyDictionary()
82
82
 
83
83
 
84
+ @overload
85
+ def rate_limit(client: _SyncClient, /, key: _Key, limit: int, ttl: int) -> int: ...
84
86
  @overload
85
87
  def rate_limit(
86
88
  client: _AsyncClient,
@@ -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.**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.9.0"
3
+ version = "0.11.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"
@@ -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()
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.9.0" # x-release-please-version
70
+ __version__ = "0.11.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload