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.
- redis_lua_py-0.2.1/.github/workflows/docs.yml +46 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.gitignore +3 -0
- redis_lua_py-0.2.1/.release-please-manifest.json +3 -0
- redis_lua_py-0.2.1/CHANGELOG.md +116 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/CONTRIBUTING.md +12 -0
- redis_lua_py-0.2.1/PKG-INFO +210 -0
- redis_lua_py-0.2.1/README.md +185 -0
- redis_lua_py-0.2.1/docs/assets/logo.svg +4 -0
- redis_lua_py-0.2.1/docs/contributing.md +67 -0
- redis_lua_py-0.2.1/docs/development.md +53 -0
- redis_lua_py-0.2.1/docs/examples.md +110 -0
- redis_lua_py-0.2.1/docs/guide/async.md +29 -0
- redis_lua_py-0.2.1/docs/guide/binary-values.md +30 -0
- redis_lua_py-0.2.1/docs/guide/binding-a-client.md +23 -0
- redis_lua_py-0.2.1/docs/guide/calling-redis-commands.md +95 -0
- redis_lua_py-0.2.1/docs/guide/constants.md +36 -0
- redis_lua_py-0.2.1/docs/guide/generated-lua.md +52 -0
- redis_lua_py-0.2.1/docs/guide/keys-and-arguments.md +60 -0
- redis_lua_py-0.2.1/docs/guide/return-values.md +53 -0
- redis_lua_py-0.2.1/docs/guide/testing.md +50 -0
- redis_lua_py-0.2.1/docs/index.md +85 -0
- redis_lua_py-0.2.1/docs/installation.md +34 -0
- redis_lua_py-0.2.1/docs/quickstart.md +98 -0
- redis_lua_py-0.2.1/docs/reference/api.md +144 -0
- redis_lua_py-0.2.1/docs/reference/errors.md +77 -0
- redis_lua_py-0.2.1/docs/reference/lua-vs-python.md +88 -0
- redis_lua_py-0.2.1/docs/reference/supported-subset.md +31 -0
- redis_lua_py-0.2.1/docs/stylesheets/extra.css +26 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/pyproject.toml +18 -3
- redis_lua_py-0.2.1/scripts/generate_commands.py +105 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/__init__.py +32 -9
- redis_lua_py-0.2.1/src/redis_lua_py/_commands.py +556 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_compile.py +359 -21
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_lua.py +36 -1
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_script.py +52 -8
- redis_lua_py-0.2.1/src/redis_lua_py/errors.py +95 -0
- redis_lua_py-0.2.1/tests/test_binary.py +94 -0
- redis_lua_py-0.2.1/tests/test_commands.py +193 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_compile.py +50 -2
- redis_lua_py-0.2.1/tests/test_constants.py +139 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_execute.py +1 -1
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_semantics.py +117 -2
- redis_lua_py-0.2.1/tests/test_typing.py +60 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/uv.lock +265 -1
- redis_lua_py-0.2.1/zensical.toml +78 -0
- redis_lua_py-0.1.0/.release-please-manifest.json +0 -3
- redis_lua_py-0.1.0/CHANGELOG.md +0 -42
- redis_lua_py-0.1.0/PKG-INFO +0 -320
- redis_lua_py-0.1.0/README.md +0 -298
- redis_lua_py-0.1.0/src/redis_lua_py/errors.py +0 -53
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/README.md +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/banner-dark.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/banner-light.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/generate.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/logo.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/logomark.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/social-preview.png +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/assets/social-preview.svg +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/workflows/ci.yml +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/workflows/pr-title.yml +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.github/workflows/release.yml +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/.python-version +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/LICENSE +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/release-please-config.json +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/_runtime.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/src/redis_lua_py/py.typed +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/conftest.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/module_with_client.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/module_without_import.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_binding.py +0 -0
- {redis_lua_py-0.1.0 → redis_lua_py-0.2.1}/tests/test_errors.py +0 -0
- {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
|
|
@@ -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).
|