redis-lua-py 0.11.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.11.0 → redis_lua_py-0.12.0}/CHANGELOG.md +7 -0
  3. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/PKG-INFO +1 -1
  4. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/installation.md +25 -0
  5. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/reference/lua-vs-python.md +10 -2
  6. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/reference/supported-subset.md +7 -4
  7. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/pyproject.toml +1 -1
  8. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/__init__.py +1 -1
  9. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/base.py +8 -3
  10. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/calls.py +12 -2
  11. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/helpers.py +9 -0
  12. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/scope.py +30 -1
  13. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/statements.py +1 -0
  14. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/tables.py +17 -2
  15. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_errors.py +22 -0
  16. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_strings_and_indexing.py +63 -0
  17. redis_lua_py-0.11.0/.release-please-manifest.json +0 -3
  18. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/README.md +0 -0
  19. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/banner-dark.svg +0 -0
  20. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/banner-light.svg +0 -0
  21. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/generate.py +0 -0
  22. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/logo.svg +0 -0
  23. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/logomark.svg +0 -0
  24. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/social-preview.png +0 -0
  25. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/assets/social-preview.svg +0 -0
  26. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/workflows/ci.yml +0 -0
  27. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/workflows/docs.yml +0 -0
  28. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/workflows/pr-title.yml +0 -0
  29. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.github/workflows/release.yml +0 -0
  30. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.gitignore +0 -0
  31. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/.python-version +0 -0
  32. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/CONTRIBUTING.md +0 -0
  33. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/LICENSE +0 -0
  34. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/README.md +0 -0
  35. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/assets/logo.svg +0 -0
  36. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/contributing.md +0 -0
  37. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/development.md +0 -0
  38. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/examples.md +0 -0
  39. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/async.md +0 -0
  40. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/binary-values.md +0 -0
  41. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/binding-a-client.md +0 -0
  42. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/build-time.md +0 -0
  43. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/calling-redis-commands.md +0 -0
  44. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/constants.md +0 -0
  45. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/generated-lua.md +0 -0
  46. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/keys-and-arguments.md +0 -0
  47. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/redis-functions.md +0 -0
  48. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/return-values.md +0 -0
  49. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/guide/testing.md +0 -0
  50. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/index.md +0 -0
  51. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/quickstart.md +0 -0
  52. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/reference/api.md +0 -0
  53. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/reference/errors.md +0 -0
  54. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/docs/stylesheets/extra.css +0 -0
  55. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/release-please-config.json +0 -0
  56. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/scripts/generate_commands.py +0 -0
  57. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/__main__.py +0 -0
  58. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_command_stubs.py +0 -0
  59. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_commands.py +0 -0
  60. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/__init__.py +0 -0
  61. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/analysis.py +0 -0
  62. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/control.py +0 -0
  63. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/expressions.py +0 -0
  64. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_compile/source.py +0 -0
  65. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_library.py +0 -0
  66. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_lua.py +0 -0
  67. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_portable.py +0 -0
  68. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_runtime.py +0 -0
  69. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/_script.py +0 -0
  70. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/codegen.py +0 -0
  71. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/errors.py +0 -0
  72. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/src/redis_lua_py/py.typed +0 -0
  73. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/codegen_scripts.py +0 -0
  74. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/conftest.py +0 -0
  75. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/generated_scripts.py +0 -0
  76. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/module_with_client.py +0 -0
  77. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/module_without_import.py +0 -0
  78. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_binary.py +0 -0
  79. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_binding.py +0 -0
  80. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_booleans_and_conversions.py +0 -0
  81. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_codegen.py +0 -0
  82. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_commands.py +0 -0
  83. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_compile.py +0 -0
  84. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_constants.py +0 -0
  85. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_control_flow.py +0 -0
  86. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_coredis.py +0 -0
  87. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_execute.py +0 -0
  88. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_expressions.py +0 -0
  89. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_functions.py +0 -0
  90. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_mistranslations.py +0 -0
  91. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_namespace.py +0 -0
  92. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_semantics.py +0 -0
  93. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_table_stakes.py +0 -0
  94. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/tests/test_typing.py +0 -0
  95. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/uv.lock +0 -0
  96. {redis_lua_py-0.11.0 → redis_lua_py-0.12.0}/zensical.toml +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.12.0"
3
+ }
@@ -7,6 +7,13 @@ 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
+
10
17
  ## [0.11.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.10.0...v0.11.0) (2026-09-25)
11
18
 
12
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.11.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/
@@ -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
@@ -55,9 +55,17 @@ that is not known, it looks at the subscript:
55
55
  A value's type is known from its annotation, a literal, the builtin, method or
56
56
  operator that produced it, a `range()` or `enumerate()` loop variable, or
57
57
  every assignment to the name agreeing. So after `counts = {}`, `counts[0]` is
58
- 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,
59
59
  `cjson.decode`, a helper's parameter -- is not known, and an integer subscript
60
- 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.
61
69
 
62
70
  `items[-1]` compiles to `items[#items]`, which needs a name to count back from.
63
71
  Indexing a string gives a one-character string, as it does in Python, through
@@ -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,8 +24,10 @@ 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
33
  `.insert()`, `.pop()` and `dict.get()`; `isinstance()` against `str`,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.11.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.11.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.
@@ -303,9 +306,9 @@ class CompilerBase(ABC):
303
306
  return "str"
304
307
  if attr in {"encode", "decode"}:
305
308
  return "str"
306
- if attr in {"startswith", "endswith"}:
309
+ if attr in {"startswith", "endswith", *STRING_TESTS}:
307
310
  return "bool"
308
- if attr == "split":
311
+ if attr in {"split", "partition"}:
309
312
  return "list"
310
313
  return "num" if attr == "find" else None
311
314
  case ast.Attribute():
@@ -321,6 +324,8 @@ class CompilerBase(ABC):
321
324
  def name_kind(self, name: str) -> str | None:
322
325
  if name not in self.known:
323
326
  return value_kind(self.globalns.get(name, UNBOUND))
327
+ if name in self.declared:
328
+ return self.declared[name]
324
329
  if name in self._resolving:
325
330
  # A name whose value depends on itself, as in `n += 1`, is whatever
326
331
  # its other assignments make it.
@@ -18,6 +18,7 @@ from .tables import (
18
18
  METHOD_HINT,
19
19
  REDIS_DIRECT,
20
20
  STRING_METHODS,
21
+ STRING_TESTS,
21
22
  )
22
23
 
23
24
 
@@ -26,7 +27,12 @@ class CallCompiler(ExpressionCompiler):
26
27
 
27
28
  def call(self, node: ast.Call) -> lua.Expr:
28
29
  if node.keywords:
29
- 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
+ )
30
36
  if (
31
37
  isinstance(node.func, ast.Name)
32
38
  and node.func.id == "isinstance"
@@ -205,7 +211,8 @@ class CallCompiler(ExpressionCompiler):
205
211
  return target
206
212
  allowed = {"get": (1, 2), "split": (0, 1), "replace": (2,)}.get(attr)
207
213
  if allowed is None:
208
- 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,)
209
216
  if len(args) not in allowed:
210
217
  if attr in {"strip", "lstrip", "rstrip"}:
211
218
  self.fail(
@@ -218,6 +225,9 @@ class CallCompiler(ExpressionCompiler):
218
225
 
219
226
  if attr in {"upper", "lower"}:
220
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())
221
231
  if attr in {"strip", "lstrip", "rstrip"}:
222
232
  pattern = {"strip": "^%s*(.-)%s*$", "lstrip": "^%s*(.*)$", "rstrip": "^(.-)%s*$"}
223
233
  return lua.Call(lua.Global("string.match"), (target, lua.Str(pattern[attr])))
@@ -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.
@@ -44,6 +44,10 @@ class ScopeCompiler(CompilerBase):
44
44
  node.target, ast.Name
45
45
  ):
46
46
  record(node.target.id, node)
47
+ if isinstance(node, ast.AnnAssign):
48
+ declared = self._annotation_kind(self._without_none(node.annotation))
49
+ if declared is not None:
50
+ self.declared.setdefault(node.target.id, declared)
47
51
  if isinstance(node, ast.AugAssign):
48
52
  value: ast.expr = ast.BinOp(left=node.target, op=node.op, right=node.value)
49
53
  self.values.setdefault(node.target.id, []).append(value)
@@ -118,7 +122,12 @@ class ScopeCompiler(CompilerBase):
118
122
  self.variadic_key = name
119
123
  else:
120
124
  if self.variadic_arg is not None:
121
- self.fail(arg, "only one list parameter of arguments is supported")
125
+ self.fail(
126
+ arg,
127
+ "only one list parameter of arguments is supported",
128
+ hint="Interleave the values in one list and index it "
129
+ "with a stride, as in values[3 * i + 1].",
130
+ )
122
131
  self.variadic_arg = name
123
132
  if self._is_numeric(item):
124
133
  self.numeric_args.add(name)
@@ -192,6 +201,26 @@ class ScopeCompiler(CompilerBase):
192
201
  return "str"
193
202
  return None
194
203
 
204
+ @staticmethod
205
+ def _without_none(node: ast.expr | None) -> ast.expr | None:
206
+ """``X`` from ``X | None`` or ``Optional[X]``: None is no kind of its own."""
207
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
208
+ try:
209
+ node = ast.parse(node.value, mode="eval").body
210
+ except SyntaxError:
211
+ return None
212
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
213
+ sides = [
214
+ side
215
+ for side in (node.left, node.right)
216
+ if not (isinstance(side, ast.Constant) and side.value is None)
217
+ ]
218
+ return sides[0] if len(sides) == 1 else None
219
+ name = ScopeCompiler._annotation_name(node)
220
+ if isinstance(node, ast.Subscript) and name == "Optional":
221
+ return node.slice
222
+ return node
223
+
195
224
  @staticmethod
196
225
  def _list_item(node: ast.expr | None) -> ast.expr | None:
197
226
  """The element annotation of a ``list[...]`` parameter, if it is one."""
@@ -272,6 +272,7 @@ class Compiler(ControlFlowCompiler):
272
272
  # What the script's names hold is known inside the function too, except
273
273
  # for its parameters, which say nothing about their type.
274
274
  child.kinds = {k: v for k, v in self.kinds.items() if k not in params}
275
+ child.declared = {k: v for k, v in self.declared.items() if k not in params}
275
276
  child.values = {k: list(v) for k, v in self.values.items() if k not in params}
276
277
  child.opaque = self.opaque - set(params)
277
278
  child._temp = self._temp
@@ -133,8 +133,8 @@ MATH_BY_OBJECT: dict[object, str] = {getattr(math, name): name for name in [*MAT
133
133
 
134
134
  METHOD_HINT = (
135
135
  "Available: the redis and cjson namespaces; list append, insert and pop; str join, "
136
- "upper, lower, strip, lstrip, rstrip, startswith, endswith, find, split, replace, "
137
- "encode and decode; and dict get."
136
+ "upper, lower, strip, lstrip, rstrip, startswith, endswith, find, split, partition, "
137
+ "replace, isdigit, isalpha, isalnum, isspace, encode and decode; and dict get."
138
138
  )
139
139
 
140
140
 
@@ -152,9 +152,24 @@ STRING_METHODS = frozenset(
152
152
  "endswith",
153
153
  "find",
154
154
  "split",
155
+ "partition",
155
156
  "replace",
157
+ "isdigit",
158
+ "isalpha",
159
+ "isalnum",
160
+ "isspace",
156
161
  "encode",
157
162
  "decode",
158
163
  "get",
159
164
  }
160
165
  )
166
+
167
+
168
+ # The str.is* tests, as Lua patterns over the whole string. Lua's classes are
169
+ # ASCII, which is exactly what the same methods on bytes test.
170
+ STRING_TESTS: dict[str, str] = {
171
+ "isdigit": "^%d+$",
172
+ "isalpha": "^%a+$",
173
+ "isalnum": "^%w+$",
174
+ "isspace": "^%s+$",
175
+ }
@@ -172,3 +172,25 @@ def test_an_except_name_that_is_a_lua_keyword_is_rejected() -> None:
172
172
  except Exception as end:
173
173
  return len(end)
174
174
  return 0
175
+
176
+
177
+ def test_keyword_arguments_hint_at_positional_options() -> None:
178
+ with pytest.raises(UnsupportedSyntax) as info:
179
+
180
+ @script
181
+ def put(key: Key, ttl: int) -> None:
182
+ redis.set(key, "v", ex=ttl)
183
+
184
+ assert info.value.hint is not None
185
+ assert "'EX', ttl" in info.value.hint
186
+
187
+
188
+ def test_a_second_list_of_arguments_hints_at_a_stride() -> None:
189
+ with pytest.raises(UnsupportedSyntax) as info:
190
+
191
+ @script
192
+ def check(keys: list[Key], limits: list[int], ttls: list[int]) -> None:
193
+ pass
194
+
195
+ assert info.value.hint is not None
196
+ assert "3 * i" in info.value.hint
@@ -303,6 +303,69 @@ class TestStringMethods:
303
303
 
304
304
  assert shifted(client, c="a") == b"b"
305
305
 
306
+ def test_character_class_tests(self, client: Any) -> None:
307
+ @script
308
+ def classes(text: str) -> list[bool]:
309
+ return [text.isdigit(), text.isalpha(), text.isalnum(), text.isspace()]
310
+
311
+ assert "__" not in classes.lua # a pattern match inline, no helper
312
+ assert classes(client, text="0042") == [1, None, 1, None]
313
+ assert classes(client, text="abC") == [None, 1, 1, None]
314
+ assert classes(client, text=" \t\n") == [None, None, None, 1]
315
+ assert classes(client, text="") == [None, None, None, None]
316
+ assert classes(client, text="-1") == [None, None, None, None]
317
+
318
+ def test_partition(self, client: Any) -> None:
319
+ @script
320
+ def parts(text: str) -> list[list[bytes]]:
321
+ return [text.partition(":"), text.partition("?")]
322
+
323
+ assert parts(client, text="5:a:b") == [[b"5", b":", b"a:b"], [b"5:a:b", b"", b""]]
324
+
325
+ def test_partition_and_isdigit_parse_a_version_prefix(self, client: Any) -> None:
326
+ @script
327
+ def version_of(key: Key) -> int:
328
+ raw: str | None = redis.get(key)
329
+ if raw is None:
330
+ return 0
331
+ prefix, sep, _body = raw.partition(":")
332
+ if sep and prefix.isdigit() and len(prefix) <= 15:
333
+ return int(prefix)
334
+ return 0
335
+
336
+ cases = {b"12:[]": 12, b"x:[]": 0, b"12": 0, b":[]": 0, b"1e3:[]": 0, b"-1:[]": 0}
337
+ for stored, expected in cases.items():
338
+ client.set("v", stored)
339
+ assert version_of(client, key="v") == expected, stored
340
+ client.delete("v")
341
+ assert version_of(client, key="v") == 0
342
+
343
+
344
+ class TestAnnotatedLocals:
345
+ def test_an_annotation_makes_a_reply_a_string(self, client: Any) -> None:
346
+ @script
347
+ def first_char(key: Key) -> bytes:
348
+ text: str = redis.get(key)
349
+ return text[0]
350
+
351
+ assert "string.sub" in first_char.lua
352
+ client.set("t", "hello")
353
+ assert first_char(client, key="t") == b"h"
354
+
355
+ def test_an_optional_annotation_declares_the_inner_kind(self, client: Any) -> None:
356
+ @script
357
+ def digits_of(key: Key) -> list[bytes]:
358
+ text: str | None = redis.get(key)
359
+ out = []
360
+ if text is not None:
361
+ for i in range(len(text)):
362
+ out.append(text[i])
363
+ return out
364
+
365
+ assert "string.sub" in digits_of.lua
366
+ client.set("t", "42")
367
+ assert digits_of(client, key="t") == [b"4", b"2"]
368
+
306
369
 
307
370
  class TestFormatting:
308
371
  def test_percent_formatting(self, client: Any) -> None:
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.11.0"
3
- }
File without changes
File without changes
File without changes
File without changes