redis-lua-py 0.2.0__tar.gz → 0.3.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.
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/workflows/ci.yml +16 -1
- redis_lua_py-0.3.0/.github/workflows/docs.yml +46 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.gitignore +3 -0
- redis_lua_py-0.3.0/.release-please-manifest.json +3 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/CHANGELOG.md +14 -0
- redis_lua_py-0.3.0/PKG-INFO +211 -0
- redis_lua_py-0.3.0/README.md +185 -0
- redis_lua_py-0.3.0/docs/assets/logo.svg +4 -0
- redis_lua_py-0.3.0/docs/contributing.md +67 -0
- redis_lua_py-0.3.0/docs/development.md +53 -0
- redis_lua_py-0.3.0/docs/examples.md +110 -0
- redis_lua_py-0.3.0/docs/guide/async.md +29 -0
- redis_lua_py-0.3.0/docs/guide/binary-values.md +30 -0
- redis_lua_py-0.3.0/docs/guide/binding-a-client.md +23 -0
- redis_lua_py-0.3.0/docs/guide/calling-redis-commands.md +95 -0
- redis_lua_py-0.3.0/docs/guide/constants.md +36 -0
- redis_lua_py-0.3.0/docs/guide/generated-lua.md +52 -0
- redis_lua_py-0.3.0/docs/guide/keys-and-arguments.md +60 -0
- redis_lua_py-0.3.0/docs/guide/return-values.md +53 -0
- redis_lua_py-0.3.0/docs/guide/testing.md +50 -0
- redis_lua_py-0.3.0/docs/index.md +85 -0
- redis_lua_py-0.3.0/docs/installation.md +34 -0
- redis_lua_py-0.3.0/docs/quickstart.md +98 -0
- redis_lua_py-0.3.0/docs/reference/api.md +144 -0
- redis_lua_py-0.3.0/docs/reference/errors.md +77 -0
- redis_lua_py-0.3.0/docs/reference/lua-vs-python.md +88 -0
- redis_lua_py-0.3.0/docs/reference/supported-subset.md +31 -0
- redis_lua_py-0.3.0/docs/stylesheets/extra.css +26 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/pyproject.toml +13 -6
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/__init__.py +1 -1
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/_script.py +4 -3
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/conftest.py +2 -2
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_commands.py +6 -2
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_typing.py +6 -1
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/uv.lock +339 -3
- redis_lua_py-0.3.0/zensical.toml +78 -0
- redis_lua_py-0.2.0/.release-please-manifest.json +0 -3
- redis_lua_py-0.2.0/PKG-INFO +0 -565
- redis_lua_py-0.2.0/README.md +0 -543
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/README.md +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/banner-dark.svg +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/banner-light.svg +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/generate.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/logo.svg +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/logomark.svg +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/social-preview.png +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/assets/social-preview.svg +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/workflows/pr-title.yml +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.github/workflows/release.yml +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/.python-version +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/CONTRIBUTING.md +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/LICENSE +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/release-please-config.json +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/scripts/generate_commands.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/_commands.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/_compile.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/_lua.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/_runtime.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/errors.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/src/redis_lua_py/py.typed +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/module_with_client.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/module_without_import.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_binary.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_binding.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_compile.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_constants.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_errors.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_execute.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_namespace.py +0 -0
- {redis_lua_py-0.2.0 → redis_lua_py-0.3.0}/tests/test_semantics.py +0 -0
|
@@ -25,7 +25,7 @@ jobs:
|
|
|
25
25
|
strategy:
|
|
26
26
|
fail-fast: false
|
|
27
27
|
matrix:
|
|
28
|
-
python-version: ["3.11", "3.12", "3.13"]
|
|
28
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
29
29
|
steps:
|
|
30
30
|
- uses: actions/checkout@v4
|
|
31
31
|
- uses: astral-sh/setup-uv@v5
|
|
@@ -34,6 +34,21 @@ jobs:
|
|
|
34
34
|
- run: uv sync --dev --python ${{ matrix.python-version }}
|
|
35
35
|
- run: uv run pytest
|
|
36
36
|
|
|
37
|
+
oldest:
|
|
38
|
+
name: Tests (oldest supported Python and redis-py)
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v4
|
|
42
|
+
- uses: astral-sh/setup-uv@v5
|
|
43
|
+
with:
|
|
44
|
+
enable-cache: true
|
|
45
|
+
- run: uv sync --dev --python 3.10
|
|
46
|
+
# The floor in pyproject.toml is only a claim until something runs on it.
|
|
47
|
+
# fakeredis declares redis>=4.3 but works on 4.2, the first release with
|
|
48
|
+
# redis.asyncio, so the pin goes in after the resolver is done.
|
|
49
|
+
- run: uv pip install --no-deps "redis==4.2.0" async-timeout deprecated
|
|
50
|
+
- run: uv run --no-sync pytest
|
|
51
|
+
|
|
37
52
|
live:
|
|
38
53
|
name: Tests against a real Redis
|
|
39
54
|
runs-on: ubuntu-latest
|
|
@@ -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
|
|
@@ -7,6 +7,20 @@ 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.3.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.2.1...v0.3.0) (2026-09-12)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
* support python 3.10 and redis-py 4.2 ([#6](https://github.com/IgnaceMaes/redis-lua-py/issues/6)) ([6ce6249](https://github.com/IgnaceMaes/redis-lua-py/commit/6ce6249d219756a598e83f18f3bf44e8a58722eb))
|
|
16
|
+
|
|
17
|
+
## [0.2.1](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.2.0...v0.2.1) (2026-09-12)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* 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))
|
|
23
|
+
|
|
10
24
|
## [0.2.0](https://github.com/IgnaceMaes/redis-lua-py/compare/v0.1.0...v0.2.0) (2026-09-12)
|
|
11
25
|
|
|
12
26
|
Everything raised by the first adoption report of 0.1.0
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: redis-lua-py
|
|
3
|
+
Version: 0.3.0
|
|
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.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: redis>=4.2
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<picture>
|
|
29
|
+
<source media="(prefers-color-scheme: dark)" srcset="./.github/assets/banner-dark.svg">
|
|
30
|
+
<img alt="redis-lua-py: Redis Lua scripts as real Python functions." src="./.github/assets/banner-light.svg" width="860">
|
|
31
|
+
</picture>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<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>
|
|
36
|
+
<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>
|
|
37
|
+
<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>
|
|
38
|
+
<a href="./LICENSE"><img alt="license" src="https://img.shields.io/pypi/l/redis-lua-py?color=%230070F3"></a>
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
Write Redis Lua scripts as real Python functions, not as strings.<br>
|
|
43
|
+
Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
<b><a href="https://ignacemaes.com/redis-lua-py/">Documentation</a></b> ·
|
|
48
|
+
<a href="https://ignacemaes.com/redis-lua-py/quickstart/">Quickstart</a> ·
|
|
49
|
+
<a href="https://ignacemaes.com/redis-lua-py/reference/api/">API reference</a> ·
|
|
50
|
+
<a href="./CHANGELOG.md">Changelog</a>
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from redis_lua_py import Key, redis, script
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@script
|
|
58
|
+
def rate_limit(key: Key, limit: int, ttl: int) -> int:
|
|
59
|
+
current = redis.incr(key)
|
|
60
|
+
if current == 1:
|
|
61
|
+
redis.expire(key, ttl)
|
|
62
|
+
if current > limit:
|
|
63
|
+
return -1
|
|
64
|
+
return limit - current
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The body is never executed by Python. It is read as source when the module is
|
|
68
|
+
imported, compiled to Lua, and sent to Redis with `EVALSHA`. Your editor
|
|
69
|
+
highlights it, your linter sees it, and `mypy` checks the signature — none of
|
|
70
|
+
which is true of a string.
|
|
71
|
+
|
|
72
|
+
Define scripts at module level, where they compile once at import. A script
|
|
73
|
+
defined inside a function recompiles on every call, and one defined through
|
|
74
|
+
`exec` has no source to read and is refused.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from redis import Redis
|
|
78
|
+
|
|
79
|
+
client = Redis()
|
|
80
|
+
remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Importing the client as `from redis import Redis` leaves the name `redis` free
|
|
84
|
+
for the script namespace, so the two never collide.
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv add redis-lua-py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Python 3.10+, and redis-py 4.2+ as the only dependency.
|
|
93
|
+
|
|
94
|
+
## What it compiles to
|
|
95
|
+
|
|
96
|
+
Nothing is hidden. Every script exposes the Lua it produced:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
>>> print(rate_limit.lua)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```lua
|
|
103
|
+
-- rate_limit
|
|
104
|
+
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
|
|
105
|
+
local key = KEYS[1]
|
|
106
|
+
local limit = tonumber(ARGV[1])
|
|
107
|
+
local ttl = tonumber(ARGV[2])
|
|
108
|
+
local current = redis.call('INCR', key)
|
|
109
|
+
if current == 1 then
|
|
110
|
+
redis.call('EXPIRE', key, ttl)
|
|
111
|
+
end
|
|
112
|
+
if current > limit then
|
|
113
|
+
return -1
|
|
114
|
+
end
|
|
115
|
+
return limit - current
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Read it in review, paste it into `redis-cli`, check it into a golden test. The
|
|
119
|
+
point of this library is to generate Lua you would have been willing to write.
|
|
120
|
+
|
|
121
|
+
The header is part of the body, and the body is what `EVALSHA` hashes, so the
|
|
122
|
+
path in it is relative to your project root rather than absolute — the same
|
|
123
|
+
script has the same SHA on a laptop, in CI and in a container, and the server's
|
|
124
|
+
script cache is cold once per script rather than once per environment.
|
|
125
|
+
|
|
126
|
+
## What else it does
|
|
127
|
+
|
|
128
|
+
- **[Keys and arguments](https://ignacemaes.com/redis-lua-py/guide/keys-and-arguments/)** —
|
|
129
|
+
a parameter annotated `Key` becomes `KEYS`, which is what Redis Cluster
|
|
130
|
+
routes on; an `int` or `float` is wrapped in `tonumber` for you.
|
|
131
|
+
- **[Command names are checked](https://ignacemaes.com/redis-lua-py/guide/calling-redis-commands/)**
|
|
132
|
+
at compile time against Redis' own command table, so `redis.expires(...)` is
|
|
133
|
+
refused where you can see it rather than raised inside a script whose whole
|
|
134
|
+
purpose was to be atomic.
|
|
135
|
+
- **[Constants are folded](https://ignacemaes.com/redis-lua-py/guide/constants/)** —
|
|
136
|
+
a module-level `int`, `float`, `str`, `bytes` or `bool` is read once, at
|
|
137
|
+
import, and written into the script as a literal.
|
|
138
|
+
- **[Binary values survive](https://ignacemaes.com/redis-lua-py/guide/binary-values/)** —
|
|
139
|
+
nothing here decodes, and `bytes` is a passthrough in both directions.
|
|
140
|
+
- **[The caller's side is typed](https://ignacemaes.com/redis-lua-py/guide/return-values/)** —
|
|
141
|
+
a script is a `CompiledScript[R]`, and an async client gives you
|
|
142
|
+
`Awaitable[R]`.
|
|
143
|
+
- **[Sync and async](https://ignacemaes.com/redis-lua-py/guide/async/)** from
|
|
144
|
+
the same script object, and
|
|
145
|
+
**[`bind`](https://ignacemaes.com/redis-lua-py/guide/binding-a-client/)** when
|
|
146
|
+
passing the client every time gets repetitive.
|
|
147
|
+
- **[The gaps between Lua and Python](https://ignacemaes.com/redis-lua-py/reference/lua-vs-python/)**
|
|
148
|
+
are closed or refused — truthiness, 1-based indexing, `false` versus `nil`,
|
|
149
|
+
block scope, and the nil that truncates a returned table.
|
|
150
|
+
- **[Anything outside the supported subset](https://ignacemaes.com/redis-lua-py/reference/supported-subset/)**
|
|
151
|
+
raises at import, with a caret under the line at fault.
|
|
152
|
+
|
|
153
|
+
Full documentation: **[ignacemaes.com/redis-lua-py](https://ignacemaes.com/redis-lua-py/)**.
|
|
154
|
+
|
|
155
|
+
## Testing your scripts
|
|
156
|
+
|
|
157
|
+
[fakeredis](https://github.com/cunla/fakeredis-py) embeds a real Lua
|
|
158
|
+
interpreter, so your script executes for real against an in-process server:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
import fakeredis
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_rate_limit_refuses_past_the_limit():
|
|
165
|
+
client = fakeredis.FakeRedis()
|
|
166
|
+
|
|
167
|
+
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
|
|
168
|
+
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
|
|
169
|
+
assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Install it with `uv add --dev "fakeredis[lua]"`; the `lua` extra is what brings
|
|
173
|
+
the interpreter. `.lua` is the whole script, so a golden snapshot is a string
|
|
174
|
+
comparison — see
|
|
175
|
+
[Testing your scripts](https://ignacemaes.com/redis-lua-py/guide/testing/).
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uv sync
|
|
181
|
+
uv run pytest
|
|
182
|
+
uv run ruff check
|
|
183
|
+
uv run mypy
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Tests run against [fakeredis](https://github.com/cunla/fakeredis-py), which
|
|
187
|
+
executes real Lua, so `uv run pytest` needs no server. Set `REDIS_URL` to also
|
|
188
|
+
run them against a live Redis:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
REDIS_URL=redis://localhost:6379/0 uv run pytest
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`src/redis_lua_py/_commands.py` is generated from the Redis source. Refresh it
|
|
195
|
+
when a Redis release adds commands:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv run python scripts/generate_commands.py 8.10.1
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The docs site is built with [Zensical](https://zensical.org); `uv run zensical
|
|
202
|
+
serve` previews it with live reload.
|
|
203
|
+
|
|
204
|
+
Pull requests are squash-merged and their titles must follow
|
|
205
|
+
[Conventional Commits](https://www.conventionalcommits.org/): the title becomes
|
|
206
|
+
the changelog entry and decides the version bump. See
|
|
207
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
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.10+, and redis-py 4.2+ 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).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Development
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
uv sync
|
|
5
|
+
uv run pytest
|
|
6
|
+
uv run ruff check
|
|
7
|
+
uv run mypy
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## The test suite
|
|
11
|
+
|
|
12
|
+
Tests run against [fakeredis](https://github.com/cunla/fakeredis-py), which
|
|
13
|
+
executes real Lua, so `uv run pytest` needs no server. Set `REDIS_URL` to also
|
|
14
|
+
run them against a live Redis:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
REDIS_URL=redis://localhost:6379/0 uv run pytest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
CI runs both, and the two agree — including on reply conversion, which is the
|
|
21
|
+
part you would most want a real server for.
|
|
22
|
+
|
|
23
|
+
## The command table
|
|
24
|
+
|
|
25
|
+
`src/redis_lua_py/_commands.py` is generated, not written. It is the table
|
|
26
|
+
command names are checked against, and it comes from the command definitions
|
|
27
|
+
in the Redis source. Refresh it when a Redis release adds commands, and commit
|
|
28
|
+
the result:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv run python scripts/generate_commands.py 8.10.1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A stale table can never block a caller: `redis.call('NEW.CMD', ...)` is
|
|
35
|
+
deliberately never checked. See
|
|
36
|
+
[Calling Redis commands](guide/calling-redis-commands.md#the-escape-hatch).
|
|
37
|
+
|
|
38
|
+
## The docs site
|
|
39
|
+
|
|
40
|
+
This site is built with [Zensical](https://zensical.org). Serve it locally
|
|
41
|
+
with live reload:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv run zensical serve
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Build it the way CI does:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv run zensical build --clean
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`.github/workflows/docs.yml` publishes `main` to GitHub Pages on every push.
|