redis-lua-py 0.1.0__tar.gz → 0.2.1__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 (72) hide show
  1. redis_lua_py-0.2.1/.github/workflows/docs.yml +46 -0
  2. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.gitignore +3 -0
  3. redis_lua_py-0.2.1/.release-please-manifest.json +3 -0
  4. redis_lua_py-0.2.1/CHANGELOG.md +116 -0
  5. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/CONTRIBUTING.md +12 -0
  6. redis_lua_py-0.2.1/PKG-INFO +210 -0
  7. redis_lua_py-0.2.1/README.md +185 -0
  8. redis_lua_py-0.2.1/docs/assets/logo.svg +4 -0
  9. redis_lua_py-0.2.1/docs/contributing.md +67 -0
  10. redis_lua_py-0.2.1/docs/development.md +53 -0
  11. redis_lua_py-0.2.1/docs/examples.md +110 -0
  12. redis_lua_py-0.2.1/docs/guide/async.md +29 -0
  13. redis_lua_py-0.2.1/docs/guide/binary-values.md +30 -0
  14. redis_lua_py-0.2.1/docs/guide/binding-a-client.md +23 -0
  15. redis_lua_py-0.2.1/docs/guide/calling-redis-commands.md +95 -0
  16. redis_lua_py-0.2.1/docs/guide/constants.md +36 -0
  17. redis_lua_py-0.2.1/docs/guide/generated-lua.md +52 -0
  18. redis_lua_py-0.2.1/docs/guide/keys-and-arguments.md +60 -0
  19. redis_lua_py-0.2.1/docs/guide/return-values.md +53 -0
  20. redis_lua_py-0.2.1/docs/guide/testing.md +50 -0
  21. redis_lua_py-0.2.1/docs/index.md +85 -0
  22. redis_lua_py-0.2.1/docs/installation.md +34 -0
  23. redis_lua_py-0.2.1/docs/quickstart.md +98 -0
  24. redis_lua_py-0.2.1/docs/reference/api.md +144 -0
  25. redis_lua_py-0.2.1/docs/reference/errors.md +77 -0
  26. redis_lua_py-0.2.1/docs/reference/lua-vs-python.md +88 -0
  27. redis_lua_py-0.2.1/docs/reference/supported-subset.md +31 -0
  28. redis_lua_py-0.2.1/docs/stylesheets/extra.css +26 -0
  29. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/pyproject.toml +18 -3
  30. redis_lua_py-0.2.1/scripts/generate_commands.py +105 -0
  31. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/__init__.py +32 -9
  32. redis_lua_py-0.2.1/src/redis_lua_py/_commands.py +556 -0
  33. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_compile.py +359 -21
  34. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_lua.py +36 -1
  35. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_script.py +52 -8
  36. redis_lua_py-0.2.1/src/redis_lua_py/errors.py +95 -0
  37. redis_lua_py-0.2.1/tests/test_binary.py +94 -0
  38. redis_lua_py-0.2.1/tests/test_commands.py +193 -0
  39. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_compile.py +50 -2
  40. redis_lua_py-0.2.1/tests/test_constants.py +139 -0
  41. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_execute.py +1 -1
  42. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_semantics.py +117 -2
  43. redis_lua_py-0.2.1/tests/test_typing.py +60 -0
  44. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/uv.lock +265 -1
  45. redis_lua_py-0.2.1/zensical.toml +78 -0
  46. redis_lua_py-0.1.0/.release-please-manifest.json +0 -3
  47. redis_lua_py-0.1.0/CHANGELOG.md +0 -42
  48. redis_lua_py-0.1.0/PKG-INFO +0 -320
  49. redis_lua_py-0.1.0/README.md +0 -298
  50. redis_lua_py-0.1.0/src/redis_lua_py/errors.py +0 -53
  51. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/README.md +0 -0
  52. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/banner-dark.svg +0 -0
  53. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/banner-light.svg +0 -0
  54. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/generate.py +0 -0
  55. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/logo.svg +0 -0
  56. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/logomark.svg +0 -0
  57. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/social-preview.png +0 -0
  58. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/social-preview.svg +0 -0
  59. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/workflows/ci.yml +0 -0
  60. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/workflows/pr-title.yml +0 -0
  61. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/workflows/release.yml +0 -0
  62. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.python-version +0 -0
  63. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/LICENSE +0 -0
  64. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/release-please-config.json +0 -0
  65. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_runtime.py +0 -0
  66. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/py.typed +0 -0
  67. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/conftest.py +0 -0
  68. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/module_with_client.py +0 -0
  69. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/module_without_import.py +0 -0
  70. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_binding.py +0 -0
  71. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_errors.py +0 -0
  72. {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_namespace.py +0 -0
@@ -0,0 +1,46 @@
1
+ name: Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ # A pull request only builds, so a broken link fails review rather than main.
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+ pages: write
13
+ id-token: write
14
+
15
+ # One deploy at a time, and never cancel one in flight.
16
+ concurrency:
17
+ group: pages
18
+ cancel-in-progress: false
19
+
20
+ jobs:
21
+ build:
22
+ name: Build
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: astral-sh/setup-uv@v5
27
+ with:
28
+ enable-cache: true
29
+ - run: uv sync --dev
30
+ # Link validation is on in zensical.toml, so this fails on a dead link.
31
+ - run: uv run zensical build --clean
32
+ - uses: actions/upload-pages-artifact@v4
33
+ with:
34
+ path: site
35
+
36
+ deploy:
37
+ name: Deploy
38
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
39
+ needs: build
40
+ runs-on: ubuntu-latest
41
+ environment:
42
+ name: github-pages
43
+ url: ${{ steps.deployment.outputs.page_url }}
44
+ steps:
45
+ - uses: actions/deploy-pages@v4
46
+ id: deployment
@@ -22,3 +22,6 @@ htmlcov/
22
22
  .idea/
23
23
  .vscode/
24
24
  .DS_Store
25
+
26
+ # Docs site
27
+ site/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.1"
3
+ }
@@ -0,0 +1,116 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries
6
+ are written by [release-please](https://github.com/googleapis/release-please)
7
+ from the conventional commit subjects on `main`; see
8
+ [CONTRIBUTING.md](CONTRIBUTING.md) for how a release is cut.
9
+
10
+ ## [0.2.1](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.2.0...v0.2.1) (2026-09-12)
11
+
12
+
13
+ ### Documentation
14
+
15
+ * add a documentation site built with zensical ([#4](https://github.com/IgnaceMaes/redis-lua-py/issues/4)) ([f877f97](https://github.com/IgnaceMaes/redis-lua-py/commit/f877f97c0b7c8126ab62ef2324aff99106434ee4))
16
+
17
+ ## [0.2.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.1.0...v0.2.0) (2026-09-12)
18
+
19
+ Everything raised by the first adoption report of 0.1.0
20
+ ([#2](https://github.com/IgnaceMaes/redis-lua-py/pull/2),
21
+ [0ef4ed2](https://github.com/IgnaceMaes/redis-lua-py/commit/0ef4ed23dd3cef8616ab5132d7a9089e38bb518a)).
22
+
23
+ ### ⚠ BREAKING CHANGES
24
+
25
+ - A command name Redis does not have is now refused at import rather than
26
+ compiled, and a literal `None` inside a returned table is refused. Both
27
+ previously compiled to Lua that failed, or silently truncated, at runtime.
28
+ - The generated header's source path is now relative to the project root,
29
+ which changes the SHA of every script — once.
30
+
31
+ ### Added
32
+
33
+ - Command names are checked at compile time against a table generated from the
34
+ Redis source (`scripts/generate_commands.py`, tracking Redis 8.10), so a name
35
+ Redis does not have is refused with a caret and a suggestion rather than
36
+ raising the first time its branch runs.
37
+ - The redis-py spellings that name exactly one command are translated instead
38
+ of refused: `redis.delete(k)` compiles to `redis.call('DEL', k)`.
39
+ - Container commands are checked down to the subcommand, and a hyphenated one
40
+ is reached through its underscores: `redis.client_no_evict("on")` compiles to
41
+ `redis.call('CLIENT', 'NO-EVICT', 'on')`.
42
+ - Module-level `int`, `float`, `str`, `bytes` and `bool` constants are folded
43
+ into a script body, including through a dotted name such as an `IntEnum`
44
+ member or a settings attribute. A body no longer has to repeat a number its
45
+ own module already names.
46
+ - The return annotation is carried to the caller: a script is a
47
+ `CompiledScript[R]`, `bind()` gives a `BoundScript`, and an async client
48
+ yields `Awaitable[R]` rather than `Any`.
49
+ - `@script(header=False)` drops the provenance comment, for anyone who wants
50
+ the script body and nothing else.
51
+ - `NilTruncationWarning` (and `RedisLuaWarning`) are raised when a name that is
52
+ not assigned on every path is returned inside a table, where it would
53
+ truncate the reply.
54
+
55
+ ### Fixed
56
+
57
+ - The generated header recorded an absolute source path. Since the header is
58
+ part of the body, and the body is what `EVALSHA` hashes, the same script had
59
+ a different SHA on a laptop, in CI and in a container, and put build-machine
60
+ paths on the Redis server. The path is now relative to the project root.
61
+ - A `bytes` literal in a body was decoded with `surrogateescape`, which could
62
+ not survive the script being sent to Redis as text. Bytes literals are now
63
+ emitted as numeric escapes, so they arrive exactly.
64
+ - A NUL in a Lua string literal was written `\0`, which Lua reads together
65
+ with a following digit as a different byte. It is now `\000`.
66
+ - `redis.sort_ro(...)` and the other `_RO` variants split into two tokens,
67
+ making the `RO` a stray argument. Their underscore is part of the wire name
68
+ and is now kept.
69
+
70
+ ### Documentation
71
+
72
+ - Binary values through `ARGV` are now a stated guarantee with a test that
73
+ round-trips non-UTF-8 bytes through `ARGV`, a stored value and a returned
74
+ `GETRANGE`.
75
+ - A returned table is truncated at the first genuine `nil` -- but a command
76
+ with nothing to return hands Lua `false`, which becomes a null *element* and
77
+ does not truncate. Both are written down, in "Where Lua differs from Python".
78
+ - "Testing your scripts": snapshot `.lua` in a golden test, and run behaviour
79
+ against `fakeredis[lua]` without a server.
80
+ - Scripts belong at module level, where they compile once at import.
81
+ - `float` round-trips through Lua's `%.14g` number formatting; `bool` encodes
82
+ to `"1"` / `"0"`; `bytes` is a passthrough.
83
+
84
+ ## 0.1.0 (2026-09-12)
85
+
86
+ First release.
87
+
88
+ ### Added
89
+
90
+ - `@script`, which compiles a Python function to Redis Lua at import time.
91
+ - `Key` annotation marking a parameter as `KEYS` rather than `ARGV`, so that
92
+ Redis Cluster can route the script correctly.
93
+ - `int` and `float` annotations wrap their argument in `tonumber`, since `ARGV`
94
+ always arrives as a string.
95
+ - One script object drives both sync and async redis-py clients, deferring to
96
+ redis-py for `EVALSHA`, script caching and the `NOSCRIPT` reload.
97
+ - `redis.*` / `call.*` command calls, with underscores splitting into
98
+ subcommand tokens, plus `cjson.encode` / `cjson.decode`.
99
+ - The generated Lua is exposed on `.lua` for review and golden testing.
100
+ - `UnsupportedSyntax` reports the file, line and column of anything outside the
101
+ supported subset, with a hint for what to write instead.
102
+ - The command namespace is resolved by value rather than by name, so it works
103
+ under any alias (`from redis_lua_py import redis as r`). A receiver that
104
+ turns out to be redis-py itself is refused with an explanation, instead of
105
+ being compiled against the client library.
106
+ - `script.bind(client)` returns a callable with the client attached, for code
107
+ that would otherwise repeat it at every call site.
108
+
109
+ ### Semantics closed between Python and Lua
110
+
111
+ - Truthiness: `0`, `''` and empty tables are falsy, as in Python.
112
+ - Missing values: a Redis command returning nothing gives Lua `false`, not
113
+ `nil`, so `is None` accepts both and evaluates its operand once.
114
+ - Indexing: Python's 0-based indices are translated to Lua's 1-based tables.
115
+ - Scope: a name first assigned inside a block is hoisted, because Lua's `local`
116
+ is block-scoped where Python's assignment is function-scoped.
@@ -10,6 +10,18 @@ uv run pytest && uv run ruff check && uv run ruff format --check && uv run mypy
10
10
  See the [README](README.md#development) for running the suite against a live
11
11
  Redis.
12
12
 
13
+ `src/redis_lua_py/_commands.py` is generated, not written. It is the table
14
+ command names are checked against, and it comes from the command definitions
15
+ in the Redis source. Refresh it when a Redis release adds commands, and commit
16
+ the result:
17
+
18
+ ```sh
19
+ uv run python scripts/generate_commands.py 8.10.1
20
+ ```
21
+
22
+ A stale table can never block a caller: `redis.call('NEW.CMD', ...)` is
23
+ deliberately never checked.
24
+
13
25
  ## Commit messages
14
26
 
15
27
  Pull requests are squash-merged, so **the PR title becomes the commit subject
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.5
2
+ Name: redis-lua-py
3
+ Version: 0.2.1
4
+ Summary: Write Redis Lua scripts as real Python functions, not strings.
5
+ Project-URL: Homepage, https://ignacemaes.com/redis-lua-py/
6
+ Project-URL: Documentation, https://ignacemaes.com/redis-lua-py/
7
+ Project-URL: Repository, https://github.com/ignacemaes/redis-lua-py
8
+ Project-URL: Issues, https://github.com/ignacemaes/redis-lua-py/issues
9
+ Project-URL: Changelog, https://github.com/ignacemaes/redis-lua-py/blob/main/CHANGELOG.md
10
+ Author: Ignace Maes
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: eval,evalsha,lua,redis,scripting,transpiler
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Lua
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Database
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: redis>=5.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ <p align="center">
27
+ <picture>
28
+ <source media="(prefers-color-scheme: dark)" srcset="./.github/assets/banner-dark.svg">
29
+ <img alt="redis-lua-py: Redis Lua scripts as real Python functions." src="./.github/assets/banner-light.svg" width="860">
30
+ </picture>
31
+ </p>
32
+
33
+ <p align="center">
34
+ <a href="https://pypi.org/project/redis-lua-py/"><img alt="PyPI" src="https://img.shields.io/pypi/v/redis-lua-py?color=%230070F3&label=pypi"></a>
35
+ <a href="https://pypi.org/project/redis-lua-py/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/redis-lua-py?color=%230070F3"></a>
36
+ <a href="https://github.com/ignacemaes/redis-lua-py/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/ignacemaes/redis-lua-py/ci.yml?branch=main&color=%230070F3&label=ci"></a>
37
+ <a href="./LICENSE"><img alt="license" src="https://img.shields.io/pypi/l/redis-lua-py?color=%230070F3"></a>
38
+ </p>
39
+
40
+ <p align="center">
41
+ Write Redis Lua scripts as real Python functions, not as strings.<br>
42
+ Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
43
+ </p>
44
+
45
+ <p align="center">
46
+ <b><a href="https://ignacemaes.com/redis-lua-py/">Documentation</a></b> ·
47
+ <a href="https://ignacemaes.com/redis-lua-py/quickstart/">Quickstart</a> ·
48
+ <a href="https://ignacemaes.com/redis-lua-py/reference/api/">API reference</a> ·
49
+ <a href="./CHANGELOG.md">Changelog</a>
50
+ </p>
51
+
52
+ ```python
53
+ from redis_lua_py import Key, redis, script
54
+
55
+
56
+ @script
57
+ def rate_limit(key: Key, limit: int, ttl: int) -> int:
58
+ current = redis.incr(key)
59
+ if current == 1:
60
+ redis.expire(key, ttl)
61
+ if current > limit:
62
+ return -1
63
+ return limit - current
64
+ ```
65
+
66
+ The body is never executed by Python. It is read as source when the module is
67
+ imported, compiled to Lua, and sent to Redis with `EVALSHA`. Your editor
68
+ highlights it, your linter sees it, and `mypy` checks the signature — none of
69
+ which is true of a string.
70
+
71
+ Define scripts at module level, where they compile once at import. A script
72
+ defined inside a function recompiles on every call, and one defined through
73
+ `exec` has no source to read and is refused.
74
+
75
+ ```python
76
+ from redis import Redis
77
+
78
+ client = Redis()
79
+ remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
80
+ ```
81
+
82
+ Importing the client as `from redis import Redis` leaves the name `redis` free
83
+ for the script namespace, so the two never collide.
84
+
85
+ ## Install
86
+
87
+ ```bash
88
+ uv add redis-lua-py
89
+ ```
90
+
91
+ Python 3.11+, and redis-py 5.0+ as the only dependency.
92
+
93
+ ## What it compiles to
94
+
95
+ Nothing is hidden. Every script exposes the Lua it produced:
96
+
97
+ ```python
98
+ >>> print(rate_limit.lua)
99
+ ```
100
+
101
+ ```lua
102
+ -- rate_limit
103
+ -- Generated by redis-lua-py from src/limits.py:6. Do not edit.
104
+ local key = KEYS[1]
105
+ local limit = tonumber(ARGV[1])
106
+ local ttl = tonumber(ARGV[2])
107
+ local current = redis.call('INCR', key)
108
+ if current == 1 then
109
+ redis.call('EXPIRE', key, ttl)
110
+ end
111
+ if current > limit then
112
+ return -1
113
+ end
114
+ return limit - current
115
+ ```
116
+
117
+ Read it in review, paste it into `redis-cli`, check it into a golden test. The
118
+ point of this library is to generate Lua you would have been willing to write.
119
+
120
+ The header is part of the body, and the body is what `EVALSHA` hashes, so the
121
+ path in it is relative to your project root rather than absolute — the same
122
+ script has the same SHA on a laptop, in CI and in a container, and the server's
123
+ script cache is cold once per script rather than once per environment.
124
+
125
+ ## What else it does
126
+
127
+ - **[Keys and arguments](https://ignacemaes.com/redis-lua-py/guide/keys-and-arguments/)** —
128
+ a parameter annotated `Key` becomes `KEYS`, which is what Redis Cluster
129
+ routes on; an `int` or `float` is wrapped in `tonumber` for you.
130
+ - **[Command names are checked](https://ignacemaes.com/redis-lua-py/guide/calling-redis-commands/)**
131
+ at compile time against Redis' own command table, so `redis.expires(...)` is
132
+ refused where you can see it rather than raised inside a script whose whole
133
+ purpose was to be atomic.
134
+ - **[Constants are folded](https://ignacemaes.com/redis-lua-py/guide/constants/)** —
135
+ a module-level `int`, `float`, `str`, `bytes` or `bool` is read once, at
136
+ import, and written into the script as a literal.
137
+ - **[Binary values survive](https://ignacemaes.com/redis-lua-py/guide/binary-values/)** —
138
+ nothing here decodes, and `bytes` is a passthrough in both directions.
139
+ - **[The caller's side is typed](https://ignacemaes.com/redis-lua-py/guide/return-values/)** —
140
+ a script is a `CompiledScript[R]`, and an async client gives you
141
+ `Awaitable[R]`.
142
+ - **[Sync and async](https://ignacemaes.com/redis-lua-py/guide/async/)** from
143
+ the same script object, and
144
+ **[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
145
+ passing the client every time gets repetitive.
146
+ - **[The gaps between Lua and Python](https://ignacemaes.com/redis-lua-py/reference/lua-vs-python/)**
147
+ are closed or refused — truthiness, 1-based indexing, `false` versus `nil`,
148
+ block scope, and the nil that truncates a returned table.
149
+ - **[Anything outside the supported subset](https://ignacemaes.com/redis-lua-py/reference/supported-subset/)**
150
+ raises at import, with a caret under the line at fault.
151
+
152
+ Full documentation: **[ignacemaes.com/redis-lua-py](https://ignacemaes.com/redis-lua-py/)**.
153
+
154
+ ## Testing your scripts
155
+
156
+ [fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
157
+ interpreter, so your script executes for real against an in-process server:
158
+
159
+ ```python
160
+ import fakeredis
161
+
162
+
163
+ def test_rate_limit_refuses_past_the_limit():
164
+ client = fakeredis.FakeRedis()
165
+
166
+ assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
167
+ assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
168
+ assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1
169
+ ```
170
+
171
+ Install it with `uv add --dev "fakeredis[lua]"`; the `lua` extra is what brings
172
+ the interpreter. `.lua` is the whole script, so a golden snapshot is a string
173
+ comparison — see
174
+ [Testing your scripts](https://ignacemaes.com/redis-lua-py/guide/testing/).
175
+
176
+ ## Development
177
+
178
+ ```bash
179
+ uv sync
180
+ uv run pytest
181
+ uv run ruff check
182
+ uv run mypy
183
+ ```
184
+
185
+ Tests run against [fakeredis](https://github.com/cunla/fakeredis-py), which
186
+ executes real Lua, so `uv run pytest` needs no server. Set `REDIS_URL` to also
187
+ run them against a live Redis:
188
+
189
+ ```bash
190
+ REDIS_URL=redis://localhost:6379/0 uv run pytest
191
+ ```
192
+
193
+ `src/redis_lua_py/_commands.py` is generated from the Redis source. Refresh it
194
+ when a Redis release adds commands:
195
+
196
+ ```bash
197
+ uv run python scripts/generate_commands.py 8.10.1
198
+ ```
199
+
200
+ The docs site is built with [Zensical](https://zensical.org); `uv run zensical
201
+ serve` previews it with live reload.
202
+
203
+ Pull requests are squash-merged and their titles must follow
204
+ [Conventional Commits](https://www.conventionalcommits.org/): the title becomes
205
+ the changelog entry and decides the version bump. See
206
+ [CONTRIBUTING.md](CONTRIBUTING.md).
207
+
208
+ ## License
209
+
210
+ MIT
@@ -0,0 +1,185 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="./.github/assets/banner-dark.svg">
4
+ <img alt="redis-lua-py: Redis Lua scripts as real Python functions." src="./.github/assets/banner-light.svg" width="860">
5
+ </picture>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://pypi.org/project/redis-lua-py/"><img alt="PyPI" src="https://img.shields.io/pypi/v/redis-lua-py?color=%230070F3&label=pypi"></a>
10
+ <a href="https://pypi.org/project/redis-lua-py/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/redis-lua-py?color=%230070F3"></a>
11
+ <a href="https://github.com/ignacemaes/redis-lua-py/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/ignacemaes/redis-lua-py/ci.yml?branch=main&color=%230070F3&label=ci"></a>
12
+ <a href="./LICENSE"><img alt="license" src="https://img.shields.io/pypi/l/redis-lua-py?color=%230070F3"></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ Write Redis Lua scripts as real Python functions, not as strings.<br>
17
+ Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
18
+ </p>
19
+
20
+ <p align="center">
21
+ <b><a href="https://ignacemaes.com/redis-lua-py/">Documentation</a></b> ·
22
+ <a href="https://ignacemaes.com/redis-lua-py/quickstart/">Quickstart</a> ·
23
+ <a href="https://ignacemaes.com/redis-lua-py/reference/api/">API reference</a> ·
24
+ <a href="./CHANGELOG.md">Changelog</a>
25
+ </p>
26
+
27
+ ```python
28
+ from redis_lua_py import Key, redis, script
29
+
30
+
31
+ @script
32
+ def rate_limit(key: Key, limit: int, ttl: int) -> int:
33
+ current = redis.incr(key)
34
+ if current == 1:
35
+ redis.expire(key, ttl)
36
+ if current > limit:
37
+ return -1
38
+ return limit - current
39
+ ```
40
+
41
+ The body is never executed by Python. It is read as source when the module is
42
+ imported, compiled to Lua, and sent to Redis with `EVALSHA`. Your editor
43
+ highlights it, your linter sees it, and `mypy` checks the signature — none of
44
+ which is true of a string.
45
+
46
+ Define scripts at module level, where they compile once at import. A script
47
+ defined inside a function recompiles on every call, and one defined through
48
+ `exec` has no source to read and is refused.
49
+
50
+ ```python
51
+ from redis import Redis
52
+
53
+ client = Redis()
54
+ remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
55
+ ```
56
+
57
+ Importing the client as `from redis import Redis` leaves the name `redis` free
58
+ for the script namespace, so the two never collide.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ uv add redis-lua-py
64
+ ```
65
+
66
+ Python 3.11+, and redis-py 5.0+ as the only dependency.
67
+
68
+ ## What it compiles to
69
+
70
+ Nothing is hidden. Every script exposes the Lua it produced:
71
+
72
+ ```python
73
+ >>> print(rate_limit.lua)
74
+ ```
75
+
76
+ ```lua
77
+ -- rate_limit
78
+ -- Generated by redis-lua-py from src/limits.py:6. Do not edit.
79
+ local key = KEYS[1]
80
+ local limit = tonumber(ARGV[1])
81
+ local ttl = tonumber(ARGV[2])
82
+ local current = redis.call('INCR', key)
83
+ if current == 1 then
84
+ redis.call('EXPIRE', key, ttl)
85
+ end
86
+ if current > limit then
87
+ return -1
88
+ end
89
+ return limit - current
90
+ ```
91
+
92
+ Read it in review, paste it into `redis-cli`, check it into a golden test. The
93
+ point of this library is to generate Lua you would have been willing to write.
94
+
95
+ The header is part of the body, and the body is what `EVALSHA` hashes, so the
96
+ path in it is relative to your project root rather than absolute — the same
97
+ script has the same SHA on a laptop, in CI and in a container, and the server's
98
+ script cache is cold once per script rather than once per environment.
99
+
100
+ ## What else it does
101
+
102
+ - **[Keys and arguments](https://ignacemaes.com/redis-lua-py/guide/keys-and-arguments/)** —
103
+ a parameter annotated `Key` becomes `KEYS`, which is what Redis Cluster
104
+ routes on; an `int` or `float` is wrapped in `tonumber` for you.
105
+ - **[Command names are checked](https://ignacemaes.com/redis-lua-py/guide/calling-redis-commands/)**
106
+ at compile time against Redis' own command table, so `redis.expires(...)` is
107
+ refused where you can see it rather than raised inside a script whose whole
108
+ purpose was to be atomic.
109
+ - **[Constants are folded](https://ignacemaes.com/redis-lua-py/guide/constants/)** —
110
+ a module-level `int`, `float`, `str`, `bytes` or `bool` is read once, at
111
+ import, and written into the script as a literal.
112
+ - **[Binary values survive](https://ignacemaes.com/redis-lua-py/guide/binary-values/)** —
113
+ nothing here decodes, and `bytes` is a passthrough in both directions.
114
+ - **[The caller's side is typed](https://ignacemaes.com/redis-lua-py/guide/return-values/)** —
115
+ a script is a `CompiledScript[R]`, and an async client gives you
116
+ `Awaitable[R]`.
117
+ - **[Sync and async](https://ignacemaes.com/redis-lua-py/guide/async/)** from
118
+ the same script object, and
119
+ **[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
120
+ passing the client every time gets repetitive.
121
+ - **[The gaps between Lua and Python](https://ignacemaes.com/redis-lua-py/reference/lua-vs-python/)**
122
+ are closed or refused — truthiness, 1-based indexing, `false` versus `nil`,
123
+ block scope, and the nil that truncates a returned table.
124
+ - **[Anything outside the supported subset](https://ignacemaes.com/redis-lua-py/reference/supported-subset/)**
125
+ raises at import, with a caret under the line at fault.
126
+
127
+ Full documentation: **[ignacemaes.com/redis-lua-py](https://ignacemaes.com/redis-lua-py/)**.
128
+
129
+ ## Testing your scripts
130
+
131
+ [fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
132
+ interpreter, so your script executes for real against an in-process server:
133
+
134
+ ```python
135
+ import fakeredis
136
+
137
+
138
+ def test_rate_limit_refuses_past_the_limit():
139
+ client = fakeredis.FakeRedis()
140
+
141
+ assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
142
+ assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
143
+ assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1
144
+ ```
145
+
146
+ Install it with `uv add --dev "fakeredis[lua]"`; the `lua` extra is what brings
147
+ the interpreter. `.lua` is the whole script, so a golden snapshot is a string
148
+ comparison — see
149
+ [Testing your scripts](https://ignacemaes.com/redis-lua-py/guide/testing/).
150
+
151
+ ## Development
152
+
153
+ ```bash
154
+ uv sync
155
+ uv run pytest
156
+ uv run ruff check
157
+ uv run mypy
158
+ ```
159
+
160
+ Tests run against [fakeredis](https://github.com/cunla/fakeredis-py), which
161
+ executes real Lua, so `uv run pytest` needs no server. Set `REDIS_URL` to also
162
+ run them against a live Redis:
163
+
164
+ ```bash
165
+ REDIS_URL=redis://localhost:6379/0 uv run pytest
166
+ ```
167
+
168
+ `src/redis_lua_py/_commands.py` is generated from the Redis source. Refresh it
169
+ when a Redis release adds commands:
170
+
171
+ ```bash
172
+ uv run python scripts/generate_commands.py 8.10.1
173
+ ```
174
+
175
+ The docs site is built with [Zensical](https://zensical.org); `uv run zensical
176
+ serve` previews it with live reload.
177
+
178
+ Pull requests are squash-merged and their titles must follow
179
+ [Conventional Commits](https://www.conventionalcommits.org/): the title becomes
180
+ the changelog entry and decides the version bump. See
181
+ [CONTRIBUTING.md](CONTRIBUTING.md).
182
+
183
+ ## License
184
+
185
+ MIT
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="redis-lua-py">
2
+ <rect width="64" height="64" rx="14" fill="#000000"/>
3
+ <g transform="translate(7.6 7.6) scale(0.76)"><defs><mask id="l"><rect width="64" height="64" fill="#fff"/><path d="M40 15.8 L64.2 40 L40 64.2 L15.8 40 Z" fill="#000"/></mask></defs><g fill="none" stroke="#FFFFFF" stroke-width="6.0" stroke-linejoin="miter"><path mask="url(#l)" d="M8 8 H36 V36 H8 Z"/><path d="M40 20 L60 40 L40 60 L20 40 Z"/></g></g>
4
+ </svg>
@@ -0,0 +1,67 @@
1
+ # Contributing
2
+
3
+ ## Getting set up
4
+
5
+ ```bash
6
+ uv sync --dev
7
+ uv run pytest && uv run ruff check && uv run ruff format --check && uv run mypy
8
+ ```
9
+
10
+ See [Development](development.md) for running the suite against a live Redis
11
+ and for regenerating the command table.
12
+
13
+ ## Commit messages
14
+
15
+ Pull requests are squash-merged, so **the PR title becomes the commit subject
16
+ on `main`**, and that subject is the only thing the release tooling reads. It
17
+ must follow [Conventional Commits](https://www.conventionalcommits.org/):
18
+
19
+ ```
20
+ feat: compile `pcall` into a protected call
21
+ fix: hoist names assigned in both arms of an if
22
+ docs: document the Key annotation
23
+ ```
24
+
25
+ | Prefix | Changelog section | Version bump |
26
+ | --- | --- | --- |
27
+ | `feat:` | Added | minor |
28
+ | `fix:` | Fixed | patch |
29
+ | `perf:` | Performance | patch |
30
+ | `refactor:` | Changed | patch |
31
+ | `deps:` | Dependencies | patch |
32
+ | `docs:` | Documentation | patch |
33
+ | `chore:` `test:` `ci:` `build:` `style:` | omitted | none |
34
+
35
+ A `!` after the prefix (`feat!:`) or a `BREAKING CHANGE:` paragraph in the PR
36
+ body marks a breaking change. While the version is below 1.0 that bumps the
37
+ minor, not the major — `0.1.0` becomes `0.2.0`, never `1.0.0`. Promoting the
38
+ project to 1.0 is a deliberate act: set `"release-as": "1.0.0"` once in
39
+ `release-please-config.json`, or land a commit with a `Release-As: 1.0.0`
40
+ footer.
41
+
42
+ The subject is copied verbatim into the changelog, so write it as a lowercase
43
+ phrase with no trailing period. `.github/workflows/pr-title.yml` enforces this
44
+ on every pull request.
45
+
46
+ ## Releasing
47
+
48
+ Releases are cut by merging a pull request; nobody edits a version by hand.
49
+
50
+ 1. Land your PR on `main`.
51
+ 2. `.github/workflows/release.yml` runs
52
+ [release-please](https://github.com/googleapis/release-please), which keeps
53
+ **one open PR** titled something like `chore(main): release 0.2.0`. Each
54
+ merge to `main` refreshes it. The PR bumps `version` in `pyproject.toml`,
55
+ `__version__` in `src/redis_lua_py/__init__.py`, and prepends the new
56
+ section to `CHANGELOG.md`. Review it like any other PR — the changelog body
57
+ is editable before you merge.
58
+ 3. Merge the release PR. That tags `vX.Y.Z`, publishes a GitHub release, and
59
+ the same workflow then builds the sdist and wheel and uploads them to PyPI
60
+ via [trusted publishing](https://docs.pypi.org/trusted-publishers/) — no API
61
+ token is stored anywhere.
62
+
63
+ `.release-please-manifest.json` records the last released version and is
64
+ updated by the release PR. Don't edit it by hand.
65
+
66
+ The full text lives in
67
+ [CONTRIBUTING.md](https://github.com/ignacemaes/redis-lua-py/blob/main/CONTRIBUTING.md).