redis-lua-py 0.10.0__tar.gz → 0.12.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 (96) hide show
  1. redis_lua_py-0.12.0/.release-please-manifest.json +3 -0
  2. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/CHANGELOG.md +20 -0
  3. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/PKG-INFO +1 -1
  4. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/async.md +5 -0
  5. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/build-time.md +2 -0
  6. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/installation.md +25 -0
  7. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/reference/api.md +2 -0
  8. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/reference/lua-vs-python.md +37 -2
  9. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/reference/supported-subset.md +11 -6
  10. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/pyproject.toml +1 -1
  11. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/__init__.py +1 -1
  12. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/base.py +13 -4
  13. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/calls.py +77 -15
  14. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/control.py +6 -4
  15. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/expressions.py +7 -7
  16. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/helpers.py +9 -0
  17. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/scope.py +36 -7
  18. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/statements.py +1 -0
  19. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/tables.py +31 -2
  20. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_library.py +7 -1
  21. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_lua.py +50 -5
  22. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_portable.py +14 -0
  23. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_runtime.py +7 -0
  24. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_script.py +11 -1
  25. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/codegen.py +2 -0
  26. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/generated_scripts.py +24 -0
  27. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_booleans_and_conversions.py +164 -1
  28. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_errors.py +34 -0
  29. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_strings_and_indexing.py +63 -0
  30. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_typing.py +25 -1
  31. redis_lua_py-0.10.0/.release-please-manifest.json +0 -3
  32. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/README.md +0 -0
  33. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/banner-dark.svg +0 -0
  34. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/banner-light.svg +0 -0
  35. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/generate.py +0 -0
  36. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/logo.svg +0 -0
  37. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/logomark.svg +0 -0
  38. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/social-preview.png +0 -0
  39. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/assets/social-preview.svg +0 -0
  40. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/workflows/ci.yml +0 -0
  41. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/workflows/docs.yml +0 -0
  42. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/workflows/pr-title.yml +0 -0
  43. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.github/workflows/release.yml +0 -0
  44. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.gitignore +0 -0
  45. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/.python-version +0 -0
  46. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/CONTRIBUTING.md +0 -0
  47. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/LICENSE +0 -0
  48. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/README.md +0 -0
  49. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/assets/logo.svg +0 -0
  50. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/contributing.md +0 -0
  51. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/development.md +0 -0
  52. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/examples.md +0 -0
  53. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/binary-values.md +0 -0
  54. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/binding-a-client.md +0 -0
  55. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/calling-redis-commands.md +0 -0
  56. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/constants.md +0 -0
  57. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/generated-lua.md +0 -0
  58. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/keys-and-arguments.md +0 -0
  59. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/redis-functions.md +0 -0
  60. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/return-values.md +0 -0
  61. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/guide/testing.md +0 -0
  62. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/index.md +0 -0
  63. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/quickstart.md +0 -0
  64. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/reference/errors.md +0 -0
  65. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/docs/stylesheets/extra.css +0 -0
  66. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/release-please-config.json +0 -0
  67. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/scripts/generate_commands.py +0 -0
  68. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/__main__.py +0 -0
  69. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_command_stubs.py +0 -0
  70. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_commands.py +0 -0
  71. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/__init__.py +0 -0
  72. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/analysis.py +0 -0
  73. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/source.py +0 -0
  74. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/errors.py +0 -0
  75. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/src/redis_lua_py/py.typed +0 -0
  76. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/codegen_scripts.py +0 -0
  77. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/conftest.py +0 -0
  78. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/module_with_client.py +0 -0
  79. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/module_without_import.py +0 -0
  80. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_binary.py +0 -0
  81. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_binding.py +0 -0
  82. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_codegen.py +0 -0
  83. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_commands.py +0 -0
  84. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_compile.py +0 -0
  85. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_constants.py +0 -0
  86. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_control_flow.py +0 -0
  87. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_coredis.py +0 -0
  88. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_execute.py +0 -0
  89. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_expressions.py +0 -0
  90. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_functions.py +0 -0
  91. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_mistranslations.py +0 -0
  92. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_namespace.py +0 -0
  93. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_semantics.py +0 -0
  94. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/tests/test_table_stakes.py +0 -0
  95. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/uv.lock +0 -0
  96. {redis_lua_py-0.10.0 → redis_lua_py-0.12.0}/zensical.toml +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.12.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.12.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.11.0...v0.12.0) (2026-09-25)
11
+
12
+
13
+ ### Added
14
+
15
+ * str.isdigit, partition and annotated locals ([#32](https://github.com/IgnaceMaes/redis-lua-py/issues/32)) ([38e863c](https://github.com/IgnaceMaes/redis-lua-py/commit/38e863c96a89cd0c2a82d0bc59d73771f295d164))
16
+
17
+ ## [0.11.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.10.0...v0.11.0) (2026-09-25)
18
+
19
+
20
+ ### Added
21
+
22
+ * compile isinstance and cjson.null in script bodies ([373c4b7](https://github.com/IgnaceMaes/redis-lua-py/commit/373c4b79d58994e7a67ada0db9572496839af8da))
23
+
24
+
25
+ ### Fixed
26
+
27
+ * rename locals that would shadow the Lua globals scripts rely on ([373c4b7](https://github.com/IgnaceMaes/redis-lua-py/commit/373c4b79d58994e7a67ada0db9572496839af8da))
28
+ * type a client wrapper with getattr forwarding as sync, not async ([373c4b7](https://github.com/IgnaceMaes/redis-lua-py/commit/373c4b79d58994e7a67ada0db9572496839af8da))
29
+
10
30
  ## [0.10.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.9.0...v0.10.0) (2026-09-14)
11
31
 
12
32
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.10.0
3
+ Version: 0.12.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/
@@ -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,
@@ -28,6 +28,31 @@ cluster, Sentinel, or an in-process [fakeredis](guide/testing.md).
28
28
  [coredis](https://github.com/alisaifee/coredis) clients work as well, and are
29
29
  tested against. See [Async](guide/async.md#coredis).
30
30
 
31
+ ### A client wrapper
32
+
33
+ An application that wraps its client, to prefix keys per tenant for example,
34
+ needs one method: `register_script(source)`. It returns a callable with the
35
+ shape of redis-py's `Script`, `(keys=..., args=..., client=...)`, and a script
36
+ runs through it:
37
+
38
+ ```python
39
+ class PrefixedScript:
40
+ def __init__(self, owner: "PrefixedClient", script: Script) -> None:
41
+ self._owner, self._script = owner, script
42
+
43
+ def __call__(self, keys=(), args=(), client=None):
44
+ keys = [f"{self._owner.prefix}:{key}" for key in keys]
45
+ return self._script(keys=keys, args=args)
46
+
47
+
48
+ class PrefixedClient:
49
+ def register_script(self, source: str) -> PrefixedScript:
50
+ return PrefixedScript(self, self._redis.register_script(source))
51
+ ```
52
+
53
+ The wrapper's other methods never come into it, so an `eval` with its own
54
+ signature is fine.
55
+
31
56
  ## For testing
32
57
 
33
58
  [fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
@@ -92,6 +92,8 @@ anything.
92
92
 
93
93
  The JSON library Redis exposes to scripts: `cjson.encode` and `cjson.decode`,
94
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.
95
97
 
96
98
  ## `CompiledScript`
97
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
@@ -37,9 +55,17 @@ that is not known, it looks at the subscript:
37
55
  A value's type is known from its annotation, a literal, the builtin, method or
38
56
  operator that produced it, a `range()` or `enumerate()` loop variable, or
39
57
  every assignment to the name agreeing. So after `counts = {}`, `counts[0]` is
40
- the key `0`. A table that comes from elsewhere -- a Redis reply,
58
+ the key `0`. A value that comes from elsewhere -- a Redis reply,
41
59
  `cjson.decode`, a helper's parameter -- is not known, and an integer subscript
42
- on it is taken to be a position.
60
+ on it is taken to be a table position. For a string, that reads `nil`, so
61
+ annotate the local to say what it holds:
62
+
63
+ ```python
64
+ text: str | None = redis.get(key)
65
+ ```
66
+
67
+ An annotation on a local is a declaration, not a conversion: it decides how
68
+ the value is indexed and added, and changes nothing at runtime.
43
69
 
44
70
  `items[-1]` compiles to `items[#items]`, which needs a name to count back from.
45
71
  Indexing a string gives a one-character string, as it does in Python, through
@@ -53,6 +79,15 @@ Python scopes a name to the whole function; Lua's `local` scopes it to the
53
79
  enclosing block. A name assigned inside an `if` and read after it is hoisted to
54
80
  the top of the script, so it does not silently read back `nil`.
55
81
 
82
+ ## Names Lua already uses are closed
83
+
84
+ The generated Lua calls globals such as `type`, `error`, `tostring` and
85
+ `pairs`, and reads `KEYS` and `ARGV`. A local of the same name would shadow
86
+ them for the rest of the script, so a parameter named `error` would break
87
+ every `raise` after it. Such a name is renamed with a trailing underscore
88
+ instead: `local error_ = ARGV[1]`. The keyword argument on the Python side
89
+ keeps its name.
90
+
56
91
  ## Strings are closed
57
92
 
58
93
  Lua's `+` is only arithmetic. It will add `"1" + "2"` to `3`. So `+`
@@ -5,8 +5,9 @@ language with a faithful Lua meaning is accepted.
5
5
 
6
6
  **Supported:**
7
7
 
8
- - **Statements:** assignment, including unpacking (`a, b = b, a`);
9
- augmented assignment; `if`/`elif`/`else`; `while`; `break`; `continue`;
8
+ - **Statements:** assignment, including unpacking (`a, b = b, a`) and
9
+ annotated assignment (`text: str = redis.get(key)`), whose annotation tells
10
+ the compiler the value's type; augmented assignment; `if`/`elif`/`else`; `while`; `break`; `continue`;
10
11
  `return`.
11
12
  - **Errors:** `try` with one `except` (bare or `Exception`), `else` and
12
13
  `finally`; `raise SomeError("message")`, and a bare `raise` inside `except`;
@@ -23,14 +24,18 @@ language with a faithful Lua meaning is accepted.
23
24
  a name, and slices of lists and strings, with a step or without.
24
25
  - **Strings:** f-strings with format specs; `%` formatting; `+` and `*` on a
25
26
  string; `str.join`, `upper`, `lower`, `strip`, `lstrip`, `rstrip`,
26
- `startswith`, `endswith`, `find`, `split` and `replace`; `encode()` and
27
- `decode()` in UTF-8, which leave the bytes as they are.
27
+ `startswith`, `endswith`, `find`, `split`, `partition` and `replace`;
28
+ `isdigit`, `isalpha`, `isalnum` and `isspace`, which test ASCII as they do
29
+ on bytes; `encode()` and `decode()` in UTF-8, which leave the bytes as they
30
+ are.
28
31
  - **Builtins and methods:** `len()`, `int()`, which truncates toward zero,
29
32
  `float()`, `str()`, `min()`, `max()`, `abs()`, `ord()`, `chr()`, `.append()`,
30
- `.insert()`, `.pop()` and `dict.get()`.
33
+ `.insert()`, `.pop()` and `dict.get()`; `isinstance()` against `str`,
34
+ `bytes`, `int`, `float`, `bool`, `dict` and `list`.
31
35
  - **The `math` module:** `floor`, `ceil`, `sqrt`, `fabs`, `fmod`, `exp`,
32
36
  `log`, `log10` and `pow`, imported either way.
33
- - **Calls:** into `redis` and `cjson`, with `*xs` allowed as the last argument.
37
+ - **Calls:** into `redis` and `cjson`, with `*xs` allowed as the last argument,
38
+ and `cjson.null` for a JSON null.
34
39
  - **Parameters:** `list[Key]` and `list[...]`, for a variable number of keys
35
40
  and arguments.
36
41
  - **Module-level constants.**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.10.0"
3
+ version = "0.12.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"
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.10.0" # x-release-please-version
70
+ __version__ = "0.12.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload
@@ -26,7 +26,7 @@ from .. import _lua as lua
26
26
  from .._runtime import _Namespace
27
27
  from ..errors import UnsupportedSyntax
28
28
  from .analysis import UNBOUND, as_literal, binop_kind, dotted_name, value_kind
29
- from .tables import MATH_BY_OBJECT, RECEIVER_FALLBACK, REDIS_CONSTANTS
29
+ from .tables import MATH_BY_OBJECT, RECEIVER_FALLBACK, REDIS_CONSTANTS, STRING_TESTS
30
30
 
31
31
 
32
32
  def is_redis_py(value: object) -> bool:
@@ -82,6 +82,9 @@ class CompilerBase(ABC):
82
82
  self.helpers: set[str] = set()
83
83
  # What a parameter or loop variable is known to be; see kind().
84
84
  self.kinds: dict[str, str] = {}
85
+ # Kinds that an annotation on a local declares. They are the author's
86
+ # word, so they win over what the assigned values suggest.
87
+ self.declared: dict[str, str] = {}
85
88
  # Every value assigned to each name, to work out what a local holds.
86
89
  self.values: dict[str, list[ast.expr]] = {}
87
90
  # Names bound in ways that say nothing about their type.
@@ -150,7 +153,9 @@ class CompilerBase(ABC):
150
153
  and parts[1] in REDIS_CONSTANTS
151
154
  and self.namespace_kind(parts[0]) == "redis"
152
155
  ):
153
- return lua.Index(lua.Name("redis"), lua.Str(parts[1]))
156
+ return lua.Index(lua.Global("redis"), lua.Str(parts[1]))
157
+ if parts[1:] == ("null",) and self.namespace_kind(parts[0]) == "cjson":
158
+ return lua.Index(lua.Global("cjson"), lua.Str("null"))
154
159
  root = self.globalns.get(parts[0], UNBOUND)
155
160
  # A namespace answers to every attribute with a call stub, and
156
161
  # redis-py has a clearer error of its own; neither is a constant.
@@ -285,6 +290,8 @@ class CompilerBase(ABC):
285
290
  case ast.Subscript(value=value):
286
291
  return "str" if self.kind(value) == "str" else None
287
292
  case ast.Call(func=ast.Name(id=name)) if not self.is_callable(name):
293
+ if name == "isinstance":
294
+ return "bool"
288
295
  if name in {"str", "tostring", "chr"}:
289
296
  return "str"
290
297
  if name in {"int", "float", "tonumber", "len", "abs", "ord"}:
@@ -299,9 +306,9 @@ class CompilerBase(ABC):
299
306
  return "str"
300
307
  if attr in {"encode", "decode"}:
301
308
  return "str"
302
- if attr in {"startswith", "endswith"}:
309
+ if attr in {"startswith", "endswith", *STRING_TESTS}:
303
310
  return "bool"
304
- if attr == "split":
311
+ if attr in {"split", "partition"}:
305
312
  return "list"
306
313
  return "num" if attr == "find" else None
307
314
  case ast.Attribute():
@@ -317,6 +324,8 @@ class CompilerBase(ABC):
317
324
  def name_kind(self, name: str) -> str | None:
318
325
  if name not in self.known:
319
326
  return value_kind(self.globalns.get(name, UNBOUND))
327
+ if name in self.declared:
328
+ return self.declared[name]
320
329
  if name in self._resolving:
321
330
  # A name whose value depends on itself, as in `n += 1`, is whatever
322
331
  # its other assignments make it.
@@ -7,15 +7,18 @@ import difflib
7
7
 
8
8
  from .. import _lua as lua
9
9
  from .._commands import COMMANDS, SUBCOMMANDS
10
+ from .analysis import is_cheap
10
11
  from .expressions import ExpressionCompiler
11
12
  from .tables import (
12
13
  BUILTIN_FUNCS,
13
14
  COMMAND_ALIASES,
14
15
  COMMAND_SPELLINGS,
16
+ ISINSTANCE_TYPES,
15
17
  MATH_FUNCS,
16
18
  METHOD_HINT,
17
19
  REDIS_DIRECT,
18
20
  STRING_METHODS,
21
+ STRING_TESTS,
19
22
  )
20
23
 
21
24
 
@@ -24,7 +27,19 @@ class CallCompiler(ExpressionCompiler):
24
27
 
25
28
  def call(self, node: ast.Call) -> lua.Expr:
26
29
  if node.keywords:
27
- self.fail(node, "keyword arguments are not supported in a script body")
30
+ self.fail(
31
+ node,
32
+ "keyword arguments are not supported in a script body",
33
+ hint="Pass a Redis command's options positionally, as its syntax "
34
+ "writes them: redis.set(key, value, 'EX', ttl), not ex=ttl.",
35
+ )
36
+ if (
37
+ isinstance(node.func, ast.Name)
38
+ and node.func.id == "isinstance"
39
+ and not self.is_callable("isinstance")
40
+ ):
41
+ # Its second argument names types, which have no value in Lua.
42
+ return self.isinstance_call(node)
28
43
  args = self.call_args(node)
29
44
 
30
45
  math_attr = self.math_attr(node.func)
@@ -45,11 +60,11 @@ class CallCompiler(ExpressionCompiler):
45
60
  case ast.Name(id="int"):
46
61
  return self.int_call(node, args)
47
62
  case ast.Name(id=name) if name in BUILTIN_FUNCS:
48
- return lua.Call(lua.Name(BUILTIN_FUNCS[name]), args)
63
+ return lua.Call(lua.Global(BUILTIN_FUNCS[name]), args)
49
64
  case ast.Attribute(value=receiver, attr="join") if not self.is_namespace(receiver):
50
65
  if len(args) != 1:
51
66
  self.fail(node, "join() takes exactly one argument")
52
- return lua.Call(lua.Name("table.concat"), (args[0], self.expr(receiver)))
67
+ return lua.Call(lua.Global("table.concat"), (args[0], self.expr(receiver)))
53
68
  case ast.Attribute(value=ast.Name(id=recv) as receiver, attr="pop" | "insert") if (
54
69
  recv in self.known
55
70
  ):
@@ -84,7 +99,7 @@ class CallCompiler(ExpressionCompiler):
84
99
  hint="Lua's unpack() only expands in the last position. Append the "
85
100
  "trailing values to the list first, then splat it.",
86
101
  )
87
- args.append(lua.Call(lua.Name("unpack"), (self.expr(arg.value),)))
102
+ args.append(lua.Call(lua.Global("unpack"), (self.expr(arg.value),)))
88
103
  else:
89
104
  args.append(self.expr(arg))
90
105
  return tuple(args)
@@ -100,6 +115,42 @@ class CallCompiler(ExpressionCompiler):
100
115
  self.helpers.add("__int")
101
116
  return lua.Call(lua.Name("__int"), args)
102
117
 
118
+ def isinstance_call(self, node: ast.Call) -> lua.Expr:
119
+ """``isinstance(x, T)``: a test of Lua's ``type()``, which has fewer types."""
120
+ if len(node.args) != 2 or isinstance(node.args[0], ast.Starred):
121
+ self.fail(node, "isinstance() takes exactly two arguments")
122
+ match node.args[1]:
123
+ case ast.Tuple(elts=elts):
124
+ classes = elts
125
+ case ast.BinOp(op=ast.BitOr()) as union:
126
+ classes = union_members(union)
127
+ case single:
128
+ classes = [single]
129
+ lua_types: list[str] = []
130
+ for cls in classes:
131
+ if not isinstance(cls, ast.Name) or cls.id not in ISINSTANCE_TYPES:
132
+ self.fail(
133
+ cls,
134
+ "isinstance() only tests for a builtin type",
135
+ hint=f"Available: {', '.join(ISINSTANCE_TYPES)}. Lua knows no classes.",
136
+ )
137
+ if ISINSTANCE_TYPES[cls.id] not in lua_types:
138
+ lua_types.append(ISINSTANCE_TYPES[cls.id])
139
+ subject = self.expr(node.args[0])
140
+ if len(lua_types) > 1 and not is_cheap(node.args[0]):
141
+ self.fail(
142
+ node.args[0],
143
+ "isinstance() against several types needs a name to test",
144
+ hint="Assign the value to a name first, so that it is evaluated once.",
145
+ )
146
+ tests = [
147
+ lua.BinOp("==", lua.Call(lua.Global("type"), (subject,)), lua.Str(t)) for t in lua_types
148
+ ]
149
+ test = tests[0]
150
+ for other in tests[1:]:
151
+ test = lua.BinOp("or", test, other)
152
+ return test
153
+
103
154
  def math_call(self, node: ast.Call, attr: str, args: tuple[lua.Expr, ...]) -> lua.Expr:
104
155
  if attr == "log":
105
156
  if len(args) != 1:
@@ -108,7 +159,7 @@ class CallCompiler(ExpressionCompiler):
108
159
  "math.log() with a base is not supported",
109
160
  hint="Lua 5.1's math.log takes no base; divide by math.log(base) instead.",
110
161
  )
111
- return lua.Call(lua.Name("math.log"), args)
162
+ return lua.Call(lua.Global("math.log"), args)
112
163
  target = MATH_FUNCS.get(attr)
113
164
  if target is None:
114
165
  self.fail(
@@ -116,7 +167,7 @@ class CallCompiler(ExpressionCompiler):
116
167
  f"math.{attr}() has no Lua counterpart",
117
168
  hint=f"Available: {listing(sorted([*MATH_FUNCS, 'log']), limit=10)}.",
118
169
  )
119
- return lua.Call(lua.Name(target), args)
170
+ return lua.Call(lua.Global(target), args)
120
171
 
121
172
  def list_method(self, node: ast.Call, receiver: ast.Name) -> lua.Expr:
122
173
  """``xs.pop()`` and ``xs.insert(i, x)``, with the index shifted to 1-based."""
@@ -130,16 +181,16 @@ class CallCompiler(ExpressionCompiler):
130
181
  target = self.expr(receiver)
131
182
  if node.func.attr == "pop":
132
183
  if not node.args:
133
- return lua.Call(lua.Name("table.remove"), (target,))
184
+ return lua.Call(lua.Global("table.remove"), (target,))
134
185
  if len(node.args) == 1:
135
186
  return lua.Call(
136
- lua.Name("table.remove"), (target, self.index(node.args[0], "list"))
187
+ lua.Global("table.remove"), (target, self.index(node.args[0], "list"))
137
188
  )
138
189
  self.fail(node, "pop() takes at most one argument")
139
190
  if len(node.args) != 2:
140
191
  self.fail(node, "insert() takes exactly two arguments")
141
192
  return lua.Call(
142
- lua.Name("table.insert"),
193
+ lua.Global("table.insert"),
143
194
  (target, self.index(node.args[0], "list"), self.expr(node.args[1])),
144
195
  )
145
196
 
@@ -160,7 +211,8 @@ class CallCompiler(ExpressionCompiler):
160
211
  return target
161
212
  allowed = {"get": (1, 2), "split": (0, 1), "replace": (2,)}.get(attr)
162
213
  if allowed is None:
163
- allowed = (0,) if attr in {"upper", "lower", "strip", "lstrip", "rstrip"} else (1,)
214
+ no_args = {"upper", "lower", "strip", "lstrip", "rstrip", *STRING_TESTS}
215
+ allowed = (0,) if attr in no_args else (1,)
164
216
  if len(args) not in allowed:
165
217
  if attr in {"strip", "lstrip", "rstrip"}:
166
218
  self.fail(
@@ -172,10 +224,13 @@ class CallCompiler(ExpressionCompiler):
172
224
  self.fail(node, f"{attr}() takes {expected} argument(s) inside a script")
173
225
 
174
226
  if attr in {"upper", "lower"}:
175
- return lua.Call(lua.Name(f"string.{attr}"), (target,))
227
+ return lua.Call(lua.Global(f"string.{attr}"), (target,))
228
+ if attr in STRING_TESTS:
229
+ found = lua.Call(lua.Global("string.find"), (target, lua.Str(STRING_TESTS[attr])))
230
+ return lua.BinOp("~=", found, lua.Nil())
176
231
  if attr in {"strip", "lstrip", "rstrip"}:
177
232
  pattern = {"strip": "^%s*(.-)%s*$", "lstrip": "^%s*(.*)$", "rstrip": "^(.-)%s*$"}
178
- return lua.Call(lua.Name("string.match"), (target, lua.Str(pattern[attr])))
233
+ return lua.Call(lua.Global("string.match"), (target, lua.Str(pattern[attr])))
179
234
  if attr == "get":
180
235
  self.helpers.add("__get")
181
236
  default = args[1] if len(args) == 2 else lua.Nil()
@@ -191,7 +246,7 @@ class CallCompiler(ExpressionCompiler):
191
246
  if kind == "cjson":
192
247
  if attr not in {"encode", "decode"}:
193
248
  self.fail(node, f"cjson has no {attr!r} function")
194
- return lua.Call(lua.Index(lua.Name("cjson"), lua.Str(attr)), args)
249
+ return lua.Call(lua.Index(lua.Global("cjson"), lua.Str(attr)), args)
195
250
  if kind == "redis":
196
251
  return self.redis_call(node, attr, args)
197
252
  self.fail(
@@ -202,11 +257,11 @@ class CallCompiler(ExpressionCompiler):
202
257
 
203
258
  def redis_call(self, node: ast.Call, attr: str, args: tuple[lua.Expr, ...]) -> lua.Expr:
204
259
  if attr in REDIS_DIRECT:
205
- return lua.Call(lua.Index(lua.Name("redis"), lua.Str(attr)), args)
260
+ return lua.Call(lua.Index(lua.Global("redis"), lua.Str(attr)), args)
206
261
  if attr.startswith("_"):
207
262
  self.fail(node, f"redis.{attr} is not a Redis command")
208
263
  tokens = tuple(lua.Str(token) for token in self.command_tokens(node, attr))
209
- return lua.Call(lua.Index(lua.Name("redis"), lua.Str("call")), tokens + args)
264
+ return lua.Call(lua.Index(lua.Global("redis"), lua.Str("call")), tokens + args)
210
265
 
211
266
  def command_tokens(self, node: ast.Call, attr: str) -> tuple[str, ...]:
212
267
  """Turn an attribute name into the command tokens Redis expects.
@@ -296,3 +351,10 @@ def did_you_mean(attr: str, options: set[str], prefix: str = "") -> str | None:
296
351
  if not matches:
297
352
  return None
298
353
  return f"Did you mean redis.{prefix}{matches[0]}()?"
354
+
355
+
356
+ def union_members(node: ast.expr) -> list[ast.expr]:
357
+ """The classes of an ``A | B | C`` union, left to right."""
358
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
359
+ return [*union_members(node.left), *union_members(node.right)]
360
+ return [node]
@@ -75,8 +75,8 @@ class ControlFlowCompiler(CallCompiler):
75
75
  the caller, or an except block, sees exactly what was raised.
76
76
  """
77
77
  if not isinstance(message, lua.Str):
78
- message = lua.Call(lua.Name("tostring"), (message,))
79
- return lua.ExprStat(lua.Call(lua.Name("error"), (message, lua.Num(0))))
78
+ message = lua.Call(lua.Global("tostring"), (message,))
79
+ return lua.ExprStat(lua.Call(lua.Global("error"), (message, lua.Num(0))))
80
80
 
81
81
  def raise_stmt(self, node: ast.Raise) -> lua.Stat:
82
82
  """``raise SomeError('message')``, which reaches the caller as an error reply."""
@@ -151,6 +151,8 @@ class ControlFlowCompiler(CallCompiler):
151
151
 
152
152
  failure: list[lua.Stat] = []
153
153
  if handler.name is not None:
154
+ if not lua.is_identifier(handler.name):
155
+ self.fail(handler, f"{handler.name!r} is a reserved word in Lua")
154
156
  self.helpers.add("__errmsg")
155
157
  message = lua.Call(lua.Name("__errmsg"), (lua.Name(result),))
156
158
  failure.append(lua.Local([handler.name], [message]))
@@ -182,7 +184,7 @@ class ControlFlowCompiler(CallCompiler):
182
184
  self.flow.pop()
183
185
  returns = contains_return(body)
184
186
  names = [ok, result, value] if returns else [ok, result]
185
- call = lua.Call(lua.Name("pcall"), (lua.Name(function),))
187
+ call = lua.Call(lua.Global("pcall"), (lua.Name(function),))
186
188
  return (
187
189
  [lua.LocalFunction(function, [], inner), lua.Local(names, [call])],
188
190
  ok,
@@ -287,7 +289,7 @@ class ControlFlowCompiler(CallCompiler):
287
289
  if method == "values":
288
290
  self._temp += 1
289
291
  names = [f"__k{self._temp}", names[0]]
290
- iterator = lua.Call(lua.Name("pairs"), (iterable,))
292
+ iterator = lua.Call(lua.Global("pairs"), (iterable,))
291
293
  return lua.GenericFor(names, iterator, self.loop_body(node.body))
292
294
 
293
295
  def enumerate_loop(self, node: ast.For, names: list[str], call: ast.Call) -> lua.Stat:
@@ -80,11 +80,11 @@ class ExpressionCompiler(ScopeCompiler):
80
80
  return self.percent_format(node)
81
81
  if isinstance(node.op, ast.Mult) and "str" in (left_kind, right_kind):
82
82
  text, count = (node.left, node.right) if left_kind == "str" else (node.right, node.left)
83
- return lua.Call(lua.Name("string.rep"), (self.expr(text), self.expr(count)))
83
+ return lua.Call(lua.Global("string.rep"), (self.expr(text), self.expr(count)))
84
84
 
85
85
  left, right = self.expr(node.left), self.expr(node.right)
86
86
  if isinstance(node.op, ast.FloorDiv):
87
- return lua.Call(lua.Name("math.floor"), (lua.BinOp("/", left, right),))
87
+ return lua.Call(lua.Global("math.floor"), (lua.BinOp("/", left, right),))
88
88
  op = BIN_OPS.get(type(node.op))
89
89
  if op is None:
90
90
  self.fail(node, f"the {type(node.op).__name__} operator is not supported")
@@ -104,7 +104,7 @@ class ExpressionCompiler(ScopeCompiler):
104
104
  self.fail(node.right, "%-formatting from a mapping is not supported")
105
105
  values = node.right.elts if isinstance(node.right, ast.Tuple) else [node.right]
106
106
  args = (self.expr(node.left), *(self.expr(v) for v in values))
107
- return lua.Call(lua.Name("string.format"), args)
107
+ return lua.Call(lua.Global("string.format"), args)
108
108
 
109
109
  def compare(self, node: ast.Compare) -> lua.Expr:
110
110
  if len(node.ops) != 1:
@@ -200,7 +200,7 @@ class ExpressionCompiler(ScopeCompiler):
200
200
  helper = "__inlist"
201
201
  elif kind == "str":
202
202
  find = lua.Call(
203
- lua.Name("string.find"),
203
+ lua.Global("string.find"),
204
204
  (self.expr(container), self.expr(item), lua.Num(1), lua.Bool(True)),
205
205
  )
206
206
  return lua.BinOp("==" if negate else "~=", find, lua.Nil())
@@ -236,10 +236,10 @@ class ExpressionCompiler(ScopeCompiler):
236
236
  )
237
237
  inner = self.expr(value.value)
238
238
  if value.format_spec is None:
239
- parts.append(lua.Call(lua.Name("tostring"), (inner,)))
239
+ parts.append(lua.Call(lua.Global("tostring"), (inner,)))
240
240
  else:
241
241
  spec = lua.Str(self.printf_spec(value))
242
- parts.append(lua.Call(lua.Name("string.format"), (spec, inner)))
242
+ parts.append(lua.Call(lua.Global("string.format"), (spec, inner)))
243
243
  else: # pragma: no cover - JoinedStr only holds these two kinds
244
244
  self.fail(value, "unsupported f-string component")
245
245
  if not parts:
@@ -309,7 +309,7 @@ class ExpressionCompiler(ScopeCompiler):
309
309
  if position is not None and position < 0
310
310
  else self.index(node.slice, container)
311
311
  )
312
- return lua.Call(lua.Name("string.sub"), (self.expr(node.value), at, at))
312
+ return lua.Call(lua.Global("string.sub"), (self.expr(node.value), at, at))
313
313
 
314
314
  if position is not None and position < 0:
315
315
  if not isinstance(node.value, ast.Name):
@@ -149,6 +149,15 @@ local function __find(s, sub)
149
149
  local i = string.find(s, sub, 1, true)
150
150
  if i == nil then return -1 end
151
151
  return i - 1
152
+ end""",
153
+ "__partition": """\
154
+ -- str.partition: before, separator and after the first plain match, or the
155
+ -- whole string and two empty strings when there is none.
156
+ local function __partition(s, sep)
157
+ if sep == '' then error('empty separator', 0) end
158
+ local i, j = string.find(s, sep, 1, true)
159
+ if i == nil then return {s, '', ''} end
160
+ return {string.sub(s, 1, i - 1), sep, string.sub(s, j + 1)}
152
161
  end""",
153
162
  "__split": """\
154
163
  -- str.split: on a plain separator, or on runs of whitespace without one.