redis-lua-py 0.7.0__tar.gz → 0.9.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 (95) hide show
  1. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/workflows/ci.yml +1 -1
  2. redis_lua_py-0.9.0/.release-please-manifest.json +3 -0
  3. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/CHANGELOG.md +22 -0
  4. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/PKG-INFO +2 -3
  5. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/README.md +1 -1
  6. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/keys-and-arguments.md +9 -4
  7. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/return-values.md +3 -1
  8. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/installation.md +7 -4
  9. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/reference/lua-vs-python.md +5 -3
  10. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/reference/supported-subset.md +5 -4
  11. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/pyproject.toml +5 -2
  12. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/__init__.py +1 -1
  13. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/analysis.py +1 -1
  14. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/base.py +16 -7
  15. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/calls.py +30 -0
  16. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/expressions.py +7 -2
  17. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/helpers.py +9 -0
  18. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/scope.py +7 -2
  19. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/tables.py +4 -2
  20. redis_lua_py-0.9.0/tests/test_booleans_and_conversions.py +162 -0
  21. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/uv.lock +3 -5
  22. redis_lua_py-0.7.0/.release-please-manifest.json +0 -3
  23. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/README.md +0 -0
  24. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/banner-dark.svg +0 -0
  25. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/banner-light.svg +0 -0
  26. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/generate.py +0 -0
  27. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/logo.svg +0 -0
  28. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/logomark.svg +0 -0
  29. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/social-preview.png +0 -0
  30. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/assets/social-preview.svg +0 -0
  31. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/workflows/docs.yml +0 -0
  32. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/workflows/pr-title.yml +0 -0
  33. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.github/workflows/release.yml +0 -0
  34. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.gitignore +0 -0
  35. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/.python-version +0 -0
  36. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/CONTRIBUTING.md +0 -0
  37. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/LICENSE +0 -0
  38. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/assets/logo.svg +0 -0
  39. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/contributing.md +0 -0
  40. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/development.md +0 -0
  41. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/examples.md +0 -0
  42. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/async.md +0 -0
  43. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/binary-values.md +0 -0
  44. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/binding-a-client.md +0 -0
  45. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/build-time.md +0 -0
  46. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/calling-redis-commands.md +0 -0
  47. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/constants.md +0 -0
  48. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/generated-lua.md +0 -0
  49. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/redis-functions.md +0 -0
  50. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/guide/testing.md +0 -0
  51. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/index.md +0 -0
  52. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/quickstart.md +0 -0
  53. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/reference/api.md +0 -0
  54. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/reference/errors.md +0 -0
  55. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/docs/stylesheets/extra.css +0 -0
  56. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/release-please-config.json +0 -0
  57. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/scripts/generate_commands.py +0 -0
  58. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/__main__.py +0 -0
  59. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_commands.py +0 -0
  60. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/__init__.py +0 -0
  61. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/control.py +0 -0
  62. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/source.py +0 -0
  63. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_compile/statements.py +0 -0
  64. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_library.py +0 -0
  65. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_lua.py +0 -0
  66. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_portable.py +0 -0
  67. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_runtime.py +0 -0
  68. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/_script.py +0 -0
  69. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/codegen.py +0 -0
  70. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/errors.py +0 -0
  71. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/src/redis_lua_py/py.typed +0 -0
  72. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/codegen_scripts.py +0 -0
  73. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/conftest.py +0 -0
  74. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/generated_scripts.py +0 -0
  75. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/module_with_client.py +0 -0
  76. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/module_without_import.py +0 -0
  77. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_binary.py +0 -0
  78. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_binding.py +0 -0
  79. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_codegen.py +0 -0
  80. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_commands.py +0 -0
  81. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_compile.py +0 -0
  82. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_constants.py +0 -0
  83. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_control_flow.py +0 -0
  84. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_coredis.py +0 -0
  85. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_errors.py +0 -0
  86. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_execute.py +0 -0
  87. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_expressions.py +0 -0
  88. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_functions.py +0 -0
  89. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_mistranslations.py +0 -0
  90. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_namespace.py +0 -0
  91. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_semantics.py +0 -0
  92. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_strings_and_indexing.py +0 -0
  93. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_table_stakes.py +0 -0
  94. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/tests/test_typing.py +0 -0
  95. {redis_lua_py-0.7.0 → redis_lua_py-0.9.0}/zensical.toml +0 -0
@@ -54,7 +54,7 @@ jobs:
54
54
  with:
55
55
  enable-cache: true
56
56
  - run: uv sync --dev --python 3.10
57
- # The floor in pyproject.toml is only a claim until something runs on it.
57
+ # The redis-py floor the docs name is only a claim until something runs on it.
58
58
  # fakeredis declares redis>=4.3 but works on 4.2, the first release with
59
59
  # redis.asyncio, so the pin goes in after the resolver is done.
60
60
  - run: uv pip install --no-deps "redis==4.2.0" async-timeout deprecated packaging
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.9.0"
3
+ }
@@ -7,6 +7,28 @@ 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.9.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.8.0...v0.9.0) (2026-09-13)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * installing redis-lua-py no longer installs redis-py. Add redis-py or coredis to your own dependencies.
16
+
17
+ ### Added
18
+
19
+ * drop the redis-py dependency ([#26](https://github.com/IgnaceMaes/redis-lua-py/issues/26)) ([e6e40e9](https://github.com/IgnaceMaes/redis-lua-py/commit/e6e40e914afb4043468d9b5b15f970940effcd63))
20
+
21
+ ## [0.8.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.7.0...v0.8.0) (2026-09-13)
22
+
23
+
24
+ ### ⚠ BREAKING CHANGES
25
+
26
+ * a bool parameter is a boolean in the body rather than "1" or "0", and integer conversion of a non-number raises instead of giving nil.
27
+
28
+ ### Fixed
29
+
30
+ * give bool arguments, int() and known booleans their python meaning ([#24](https://github.com/IgnaceMaes/redis-lua-py/issues/24)) ([27b3de8](https://github.com/IgnaceMaes/redis-lua-py/commit/27b3de8e7c12aea9076b940e6a7f369b054876f3))
31
+
10
32
  ## [0.7.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.6.0...v0.7.0) (2026-09-12)
11
33
 
12
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-lua-py
3
- Version: 0.7.0
3
+ Version: 0.9.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/
@@ -21,7 +21,6 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Database
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: redis>=4.2
25
24
  Description-Content-Type: text/markdown
26
25
 
27
26
  <p align="center">
@@ -89,7 +88,7 @@ for the script namespace, so the two never collide.
89
88
  uv add redis-lua-py
90
89
  ```
91
90
 
92
- Python 3.10+, and redis-py 4.2+ as the only dependency.
91
+ Python 3.10+, and no dependencies: bring your own client, redis-py 4.2+ or coredis.
93
92
 
94
93
  ## What it compiles to
95
94
 
@@ -63,7 +63,7 @@ for the script namespace, so the two never collide.
63
63
  uv add redis-lua-py
64
64
  ```
65
65
 
66
- Python 3.10+, and redis-py 4.2+ as the only dependency.
66
+ Python 3.10+, and no dependencies: bring your own client, redis-py 4.2+ or coredis.
67
67
 
68
68
  ## What it compiles to
69
69
 
@@ -30,7 +30,7 @@ to it on the way in.
30
30
  | `Key` | `KEYS[n]`, and what the cluster routes on |
31
31
  | `int`, `float` | wrapped in `tonumber`, so it is a number by the time your comparison runs |
32
32
  | `str` | passed through as the string it already is |
33
- | `bool` | encoded as `"1"` or `"0"` |
33
+ | `bool` | encoded as `"1"` or `"0"`, and a Lua boolean in the body |
34
34
  | `bytes`, `memoryview` | passed through untouched — see [Binary values](binary-values.md) |
35
35
 
36
36
  ### `float` carries a caveat
@@ -41,10 +41,15 @@ text with `%.14g`, so a value with more significant digits than that does not
41
41
  come back as it went in. Annotate `str` and call `str()` at the call site when
42
42
  the value is only being carried.
43
43
 
44
- ### `bool` deletes a conditional
44
+ ### `bool` is a boolean in the body
45
45
 
46
- `bool` encodes to `"1"` or `"0"`. Paired with an `int` annotation that deletes
47
- the `1 if flag else 0` from the call site: pass `True`, and the body gets `1`.
46
+ `bool` travels as `"1"` or `"0"`, and arrives as `ARGV[n] == '1'`, so
47
+ `if flag:` means what it says. `"0"` is a non-empty string, which both Lua and
48
+ Python count as true, so passing the string through would make every flag set.
49
+
50
+ A Lua boolean cannot be a command argument, as in redis-py. Write `int(flag)`
51
+ where the body hands it to Redis, or annotate the parameter `int` instead:
52
+ pass `True`, and the body gets `1`, with no `1 if flag else 0` at the call site.
48
53
 
49
54
  ## A variable number of keys or arguments
50
55
 
@@ -30,7 +30,9 @@ def preview(doc: Key) -> list[int | bytes]:
30
30
 
31
31
  `b""` and `""` compile to the same Lua string; only one of them also describes
32
32
  what the caller receives, which keeps the body and the signature agreeing
33
- about the same thing.
33
+ about the same thing. For a string built in the body, `str(n).encode()` does
34
+ the same: Lua strings are already bytes, so `encode()` compiles to nothing, and
35
+ the body type-checks against `-> bytes`.
34
36
 
35
37
  Return values follow Redis' own conversion rules: `True` becomes `1`, `False`
36
38
  and `None` become nil, floats are truncated to integers. Return a string, or
@@ -13,8 +13,12 @@ pip install redis-lua-py
13
13
  ## Requirements
14
14
 
15
15
  - Python 3.10 or newer
16
- - [redis-py](https://github.com/redis/redis-py) 4.2 or newer — the only
17
- dependency, and installed with the package
16
+ - A Redis client: [redis-py](https://github.com/redis/redis-py) 4.2 or newer,
17
+ or [coredis](https://github.com/alisaifee/coredis)
18
+
19
+ The package has no dependencies of its own. It only calls the client you pass
20
+ it, so it never installs one: add redis-py or coredis yourself, as you would
21
+ to talk to Redis at all.
18
22
 
19
23
  Script caching, `EVALSHA` and the `NOSCRIPT` reload are handled by redis-py's
20
24
  own script machinery, which this library defers to rather than reimplementing.
@@ -22,8 +26,7 @@ Anything redis-py can talk to, this can run against: a standalone server, a
22
26
  cluster, Sentinel, or an in-process [fakeredis](guide/testing.md).
23
27
 
24
28
  [coredis](https://github.com/alisaifee/coredis) clients work as well, and are
25
- tested against, but coredis is not a dependency: install it yourself. See
26
- [Async](guide/async.md#coredis).
29
+ tested against. See [Async](guide/async.md#coredis).
27
30
 
28
31
  ## For testing
29
32
 
@@ -4,9 +4,11 @@ These are the gaps that matter. Most are closed for you; the rest are refused.
4
4
 
5
5
  ## Truthiness is closed
6
6
 
7
- Lua counts `0` and `''` as true. Any condition that is not already a boolean is
8
- routed through a generated `__truthy` helper, so `if count:` means what it
9
- means in Python.
7
+ Lua counts `0` and `''` as true. Any condition that is not known to be a
8
+ boolean is routed through a generated `__truthy` helper, so `if count:` means
9
+ what it means in Python. A comparison, `not`, `startswith()`, `endswith()`, a
10
+ `bool` parameter, and a name only ever assigned one of those are used as they
11
+ are, and `and`/`or` between them compile to Lua's own operators.
10
12
 
11
13
  ## Missing values are closed
12
14
 
@@ -23,10 +23,11 @@ language with a faithful Lua meaning is accepted.
23
23
  a name, and slices of lists and strings, with a step or without.
24
24
  - **Strings:** f-strings with format specs; `%` formatting; `+` and `*` on a
25
25
  string; `str.join`, `upper`, `lower`, `strip`, `lstrip`, `rstrip`,
26
- `startswith`, `endswith`, `find`, `split` and `replace`.
27
- - **Builtins and methods:** `len()`, `int()`, `float()`, `str()`, `min()`,
28
- `max()`, `abs()`, `ord()`, `chr()`, `.append()`, `.insert()`, `.pop()` and
29
- `dict.get()`.
26
+ `startswith`, `endswith`, `find`, `split` and `replace`; `encode()` and
27
+ `decode()` in UTF-8, which leave the bytes as they are.
28
+ - **Builtins and methods:** `len()`, `int()`, which truncates toward zero,
29
+ `float()`, `str()`, `min()`, `max()`, `abs()`, `ord()`, `chr()`, `.append()`,
30
+ `.insert()`, `.pop()` and `dict.get()`.
30
31
  - **The `math` module:** `floor`, `ceil`, `sqrt`, `fabs`, `fmod`, `exp`,
31
32
  `log`, `log10` and `pow`, imported either way.
32
33
  - **Calls:** into `redis` and `cjson`, with `*xs` allowed as the last argument.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-lua-py"
3
- version = "0.7.0"
3
+ version = "0.9.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"
@@ -19,7 +19,7 @@ classifiers = [
19
19
  "Topic :: Database",
20
20
  "Typing :: Typed",
21
21
  ]
22
- dependencies = ["redis>=4.2"]
22
+ dependencies = []
23
23
 
24
24
  [project.urls]
25
25
  Homepage = "https://ignacemaes.com/redis-lua-py/"
@@ -33,6 +33,9 @@ redis-lua-py = "redis_lua_py.__main__:main"
33
33
 
34
34
  [dependency-groups]
35
35
  dev = [
36
+ # The client every test runs through. Not a dependency of the package, which
37
+ # only calls the client it is handed: that client's library is already installed.
38
+ "redis>=4.2",
36
39
  "pytest>=8.3",
37
40
  "pytest-asyncio>=1.0",
38
41
  "fakeredis[lua]>=2.30",
@@ -67,7 +67,7 @@ __all__ = [
67
67
  "script",
68
68
  ]
69
69
 
70
- __version__ = "0.7.0" # x-release-please-version
70
+ __version__ = "0.9.0" # x-release-please-version
71
71
 
72
72
 
73
73
  @overload
@@ -217,7 +217,7 @@ def without_finally(node: ast.Try) -> ast.Try:
217
217
  def value_kind(value: object) -> str | None:
218
218
  """The kind of a module-level constant, as folded into the script."""
219
219
  if isinstance(value, bool):
220
- return None
220
+ return "bool"
221
221
  if isinstance(value, str | bytes):
222
222
  return "str"
223
223
  if isinstance(value, int | float):
@@ -242,16 +242,21 @@ class CompilerBase(ABC):
242
242
  def kind(self, node: ast.expr) -> str | None:
243
243
  """What an expression is statically known to be, or None.
244
244
 
245
- One of "str", "num", "list", "dict" or "func". Lua does not care, but
246
- several translations do: a subscript is shifted to 1-based for a list
247
- and not for a dict, `+` joins strings and adds numbers, `in` searches a
248
- list and looks up a dict's keys, and a format spec aligns a string and
249
- a number differently. None means "only known at runtime", and the
250
- translation then decides there, or refuses.
245
+ One of "str", "num", "bool", "list", "dict" or "func". Lua does not
246
+ care, but several translations do: a subscript is shifted to 1-based
247
+ for a list and not for a dict, `+` joins strings and adds numbers, `in`
248
+ searches a list and looks up a dict's keys, a format spec aligns a
249
+ string and a number differently, and a boolean needs no truthiness
250
+ helper. None means "only known at runtime", and the translation then
251
+ decides there, or refuses.
251
252
  """
252
253
  match node:
253
254
  case ast.Constant(value=bool()):
254
- return None
255
+ return "bool"
256
+ case ast.Compare() | ast.UnaryOp(op=ast.Not()):
257
+ return "bool"
258
+ case ast.BoolOp(values=values):
259
+ return "bool" if all(self.kind(value) == "bool" for value in values) else None
255
260
  case ast.Constant(value=str() | bytes()):
256
261
  return "str"
257
262
  case ast.Constant(value=int() | float()):
@@ -292,6 +297,10 @@ class CompilerBase(ABC):
292
297
  return None
293
298
  if attr in {"upper", "lower", "strip", "lstrip", "rstrip", "replace", "join"}:
294
299
  return "str"
300
+ if attr in {"encode", "decode"}:
301
+ return "str"
302
+ if attr in {"startswith", "endswith"}:
303
+ return "bool"
295
304
  if attr == "split":
296
305
  return "list"
297
306
  return "num" if attr == "find" else None
@@ -42,6 +42,8 @@ class CallCompiler(ExpressionCompiler):
42
42
  self.helpers.add("__dictlen")
43
43
  return lua.Call(lua.Name("__dictlen"), args)
44
44
  return lua.UnOp("#", args[0])
45
+ case ast.Name(id="int"):
46
+ return self.int_call(node, args)
45
47
  case ast.Name(id=name) if name in BUILTIN_FUNCS:
46
48
  return lua.Call(lua.Name(BUILTIN_FUNCS[name]), args)
47
49
  case ast.Attribute(value=receiver, attr="join") if not self.is_namespace(receiver):
@@ -87,6 +89,17 @@ class CallCompiler(ExpressionCompiler):
87
89
  args.append(self.expr(arg))
88
90
  return tuple(args)
89
91
 
92
+ def int_call(self, node: ast.Call, args: tuple[lua.Expr, ...]) -> lua.Expr:
93
+ """``int(x)``: truncated toward zero, which tonumber alone does not do."""
94
+ if len(args) != 1:
95
+ self.fail(
96
+ node, "int() takes exactly one argument", hint="int() with a base is not supported."
97
+ )
98
+ if self.kind(node.args[0]) == "bool":
99
+ return lua.BinOp("or", lua.BinOp("and", args[0], lua.Num(1)), lua.Num(0))
100
+ self.helpers.add("__int")
101
+ return lua.Call(lua.Name("__int"), args)
102
+
90
103
  def math_call(self, node: ast.Call, attr: str, args: tuple[lua.Expr, ...]) -> lua.Expr:
91
104
  if attr == "log":
92
105
  if len(args) != 1:
@@ -135,6 +148,16 @@ class CallCompiler(ExpressionCompiler):
135
148
  ) -> lua.Expr:
136
149
  """The str methods, and dict.get, over Lua's string library and helpers."""
137
150
  target = self.expr(receiver)
151
+ if attr in {"encode", "decode"}:
152
+ # Every Lua string is already bytes, so there is nothing to convert.
153
+ if len(node.args) > 1 or (node.args and not is_utf8(node.args[0])):
154
+ self.fail(
155
+ node,
156
+ f"{attr}() only supports UTF-8 inside a script",
157
+ hint="Lua strings are bytes, so encode() and decode() leave them as they are; "
158
+ "another encoding has no counterpart.",
159
+ )
160
+ return target
138
161
  allowed = {"get": (1, 2), "split": (0, 1), "replace": (2,)}.get(attr)
139
162
  if allowed is None:
140
163
  allowed = (0,) if attr in {"upper", "lower", "strip", "lstrip", "rstrip"} else (1,)
@@ -254,6 +277,13 @@ class CallCompiler(ExpressionCompiler):
254
277
  )
255
278
 
256
279
 
280
+ def is_utf8(node: ast.expr) -> bool:
281
+ """Whether an encode()/decode() argument names UTF-8."""
282
+ if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
283
+ return False
284
+ return node.value.lower().replace("_", "-") in {"utf-8", "utf8"}
285
+
286
+
257
287
  def listing(names: list[str], limit: int = 6) -> str:
258
288
  """A comma-separated sample, only trailing off when there is more."""
259
289
  shown = ", ".join(names[:limit])
@@ -393,6 +393,9 @@ class ExpressionCompiler(ScopeCompiler):
393
393
  that is a name or a literal goes through a helper; anything else is
394
394
  wrapped in a function, so it only runs when Python would run it.
395
395
  """
396
+ if self.kind(node) == "bool":
397
+ # Between booleans, Lua's and/or mean exactly what Python's do.
398
+ return self.condition(node)
396
399
  is_or = isinstance(node.op, ast.Or)
397
400
  self.helpers.add("__truthy")
398
401
  result = self.expr(node.values[0])
@@ -432,9 +435,11 @@ class ExpressionCompiler(ScopeCompiler):
432
435
  def condition(self, node: ast.expr) -> lua.Expr:
433
436
  """Compile an expression used for its truth value.
434
437
 
435
- Lua treats 0 and '' as true, so anything that is not already a boolean
436
- gets routed through the __truthy helper.
438
+ Lua treats 0 and '' as true, so anything that is not known to be a
439
+ boolean gets routed through the __truthy helper.
437
440
  """
441
+ if self.kind(node) == "bool" and not isinstance(node, ast.BoolOp | ast.UnaryOp):
442
+ return self.expr(node)
438
443
  match node:
439
444
  case ast.Compare():
440
445
  return self.compare(node)
@@ -125,6 +125,15 @@ local function __add(a, b)
125
125
  return out
126
126
  end
127
127
  return a + b
128
+ end""",
129
+ "__int": """\
130
+ -- Python's int(): a number truncated toward zero, and an error rather than
131
+ -- nil for anything that is not a number.
132
+ local function __int(v)
133
+ local n = tonumber(v)
134
+ if n == nil then error('invalid literal for int(): ' .. tostring(v), 0) end
135
+ if n < 0 then return math.ceil(n) end
136
+ return math.floor(n)
128
137
  end""",
129
138
  "__startswith": """\
130
139
  local function __startswith(s, prefix)
@@ -135,6 +135,10 @@ class ScopeCompiler(CompilerBase):
135
135
  # is the author asking for the conversion.
136
136
  self.numeric_args.add(name)
137
137
  source = lua.Call(lua.Name("tonumber"), (source,))
138
+ elif self._annotation_name(arg.annotation) == "bool":
139
+ # A bool arrives as "1" or "0", and "0" is a string Lua and
140
+ # Python both count as true. Comparing makes it a boolean.
141
+ source = lua.BinOp("==", source, lua.Str("1"))
138
142
  kind = self._annotation_kind(arg.annotation)
139
143
  if kind is not None:
140
144
  self.kinds[name] = kind
@@ -182,8 +186,9 @@ class ScopeCompiler(CompilerBase):
182
186
  name = self._annotation_name(node)
183
187
  if name in {"int", "float"}:
184
188
  return "num"
185
- # A bool arrives as the string "1" or "0", like every other ARGV.
186
- if name in {"Key", "str", "bytes", "memoryview", "bool"}:
189
+ if name == "bool":
190
+ return "bool"
191
+ if name in {"Key", "str", "bytes", "memoryview"}:
187
192
  return "str"
188
193
  return None
189
194
 
@@ -119,8 +119,8 @@ MATH_BY_OBJECT: dict[object, str] = {getattr(math, name): name for name in [*MAT
119
119
 
120
120
  METHOD_HINT = (
121
121
  "Available: the redis and cjson namespaces; list append, insert and pop; str join, "
122
- "upper, lower, strip, lstrip, rstrip, startswith, endswith, find, split and replace; "
123
- "and dict get."
122
+ "upper, lower, strip, lstrip, rstrip, startswith, endswith, find, split, replace, "
123
+ "encode and decode; and dict get."
124
124
  )
125
125
 
126
126
 
@@ -139,6 +139,8 @@ STRING_METHODS = frozenset(
139
139
  "find",
140
140
  "split",
141
141
  "replace",
142
+ "encode",
143
+ "decode",
142
144
  "get",
143
145
  }
144
146
  )
@@ -0,0 +1,162 @@
1
+ """Booleans, int() and str/bytes conversions, where Lua's meaning differs from Python's.
2
+
3
+ Each test runs the script as well as inspecting it: a translation that reads
4
+ well but answers differently is the failure being guarded against.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ import pytest
13
+
14
+ from redis_lua_py import CompiledScript, Key, UnsupportedSyntax, redis, script
15
+
16
+ Body = Callable[[CompiledScript[object]], str]
17
+
18
+
19
+ class TestBoolArguments:
20
+ """A bool travels as "1" or "0", and "0" is a string Lua counts as true."""
21
+
22
+ def test_false_is_false_in_a_condition(self, client: Any) -> None:
23
+ @script
24
+ def s(flag: bool) -> str:
25
+ if flag:
26
+ return "yes"
27
+ return "no"
28
+
29
+ assert s(client, flag=True) == b"yes"
30
+ assert s(client, flag=False) == b"no"
31
+
32
+ def test_arrives_as_a_lua_boolean(self, body: Body) -> None:
33
+ @script
34
+ def s(flag: bool) -> int:
35
+ if not flag:
36
+ return 0
37
+ return 1
38
+
39
+ emitted = body(s)
40
+ assert "local flag = ARGV[1] == '1'" in emitted
41
+ assert "__truthy" not in emitted
42
+
43
+ def test_int_of_a_bool(self, client: Any, body: Body) -> None:
44
+ @script
45
+ def s(flag: bool) -> int:
46
+ return int(flag) + 1
47
+
48
+ assert "__int" not in body(s)
49
+ assert s(client, flag=True) == 2
50
+ assert s(client, flag=False) == 1
51
+
52
+
53
+ class TestKnownBooleans:
54
+ """A value known to be a boolean needs no truthiness helper."""
55
+
56
+ def test_a_comparison_kept_in_a_name(self, client: Any, body: Body) -> None:
57
+ @script
58
+ def s(k: Key) -> int:
59
+ empty = redis.llen(k) == 0
60
+ if empty:
61
+ return 0
62
+ return 1
63
+
64
+ assert "__truthy" not in body(s)
65
+ assert s(client, k="list") == 0
66
+ client.rpush("list", "x")
67
+ assert s(client, k="list") == 1
68
+
69
+ def test_startswith_and_endswith(self, client: Any, body: Body) -> None:
70
+ @script
71
+ def s(k: Key, prefix: str, suffix: str) -> int:
72
+ value = redis.get(k)
73
+ if value is not None and value.startswith(prefix) and not value.endswith(suffix):
74
+ return 1
75
+ return 0
76
+
77
+ assert "__truthy" not in body(s)
78
+ client.set("k", "pointer:abc")
79
+ assert s(client, k="k", prefix="pointer:", suffix="xyz") == 1
80
+ assert s(client, k="k", prefix="inline:", suffix="xyz") == 0
81
+ assert s(client, k="k", prefix="pointer:", suffix="abc") == 0
82
+ assert s(client, k="missing", prefix="pointer:", suffix="xyz") == 0
83
+
84
+ def test_and_or_between_booleans_use_luas_operators(self, client: Any, body: Body) -> None:
85
+ @script
86
+ def s(a: int, b: int) -> list[int]:
87
+ both = a > 0 and b > 0
88
+ either = a > 0 or b > 0
89
+ return [int(both), int(either)]
90
+
91
+ emitted = body(s)
92
+ for helper in ("__truthy", "__and", "__or", "__int"):
93
+ assert helper not in emitted
94
+ assert s(client, a=1, b=0) == [0, 1]
95
+ assert s(client, a=1, b=1) == [1, 1]
96
+ assert s(client, a=0, b=0) == [0, 0]
97
+
98
+ def test_a_bool_constant(self, client: Any, body: Body) -> None:
99
+ @script
100
+ def s(n: int) -> int:
101
+ found = False
102
+ if n > 3:
103
+ found = True
104
+ if found:
105
+ return 1
106
+ return 0
107
+
108
+ assert "__truthy" not in body(s)
109
+ assert s(client, n=5) == 1
110
+ assert s(client, n=1) == 0
111
+
112
+
113
+ class TestInt:
114
+ """int() truncates toward zero, where tonumber keeps the fraction."""
115
+
116
+ @pytest.mark.parametrize(
117
+ ("value", "expected"), [("7", 7), ("3.7", 3), ("-3.7", -3), ("-0.5", 0), ("12", 12)]
118
+ )
119
+ def test_truncates_toward_zero(self, client: Any, value: str, expected: int) -> None:
120
+ @script
121
+ def s(v: str) -> list[int]:
122
+ # Scaled, so Redis' own truncation of a numeric reply cannot hide ours.
123
+ return [int(v) * 10]
124
+
125
+ assert s(client, v=value) == [expected * 10]
126
+
127
+ def test_a_missing_value_raises(self, client: Any) -> None:
128
+ @script
129
+ def s(k: Key) -> int:
130
+ return int(redis.get(k))
131
+
132
+ with pytest.raises(Exception, match="invalid literal for int"):
133
+ s(client, k="missing")
134
+
135
+ def test_a_base_is_refused(self) -> None:
136
+ with pytest.raises(UnsupportedSyntax, match="int\\(\\) takes exactly one argument"):
137
+
138
+ @script
139
+ def s(v: str) -> int:
140
+ return int(v, 16) # type: ignore[call-overload]
141
+
142
+
143
+ class TestEncodeDecode:
144
+ """Lua strings are bytes, so encode() and decode() have nothing to convert."""
145
+
146
+ def test_pass_through(self, client: Any, body: Body) -> None:
147
+ @script
148
+ def s(k: Key, text: str) -> bytes:
149
+ redis.set(k, text.encode())
150
+ return str(redis.get(k).decode("utf-8")).encode("utf-8")
151
+
152
+ emitted = body(s)
153
+ assert "encode" not in emitted
154
+ assert "decode" not in emitted
155
+ assert s(client, k="k", text="héllo") == "héllo".encode()
156
+
157
+ def test_another_encoding_is_refused(self) -> None:
158
+ with pytest.raises(UnsupportedSyntax, match="only supports UTF-8"):
159
+
160
+ @script
161
+ def s(text: str) -> bytes:
162
+ return text.encode("latin-1")
@@ -774,11 +774,8 @@ wheels = [
774
774
 
775
775
  [[package]]
776
776
  name = "redis-lua-py"
777
- version = "0.6.0"
777
+ version = "0.8.0"
778
778
  source = { editable = "." }
779
- dependencies = [
780
- { name = "redis" },
781
- ]
782
779
 
783
780
  [package.dev-dependencies]
784
781
  dev = [
@@ -787,13 +784,13 @@ dev = [
787
784
  { name = "mypy" },
788
785
  { name = "pytest" },
789
786
  { name = "pytest-asyncio" },
787
+ { name = "redis" },
790
788
  { name = "ruff" },
791
789
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
792
790
  { name = "zensical" },
793
791
  ]
794
792
 
795
793
  [package.metadata]
796
- requires-dist = [{ name = "redis", specifier = ">=4.2" }]
797
794
 
798
795
  [package.metadata.requires-dev]
799
796
  dev = [
@@ -802,6 +799,7 @@ dev = [
802
799
  { name = "mypy", specifier = ">=1.15" },
803
800
  { name = "pytest", specifier = ">=8.3" },
804
801
  { name = "pytest-asyncio", specifier = ">=1.0" },
802
+ { name = "redis", specifier = ">=4.2" },
805
803
  { name = "ruff", specifier = ">=0.16" },
806
804
  { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.2" },
807
805
  { name = "zensical", specifier = ">=0.0.61" },
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.7.0"
3
- }
File without changes
File without changes
File without changes
File without changes