redis-lua-py 0.1.0__py3-none-any.whl
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/__init__.py +87 -0
- redis_lua_py/_compile.py +787 -0
- redis_lua_py/_lua.py +308 -0
- redis_lua_py/_runtime.py +72 -0
- redis_lua_py/_script.py +151 -0
- redis_lua_py/errors.py +53 -0
- redis_lua_py/py.typed +0 -0
- redis_lua_py-0.1.0.dist-info/METADATA +320 -0
- redis_lua_py-0.1.0.dist-info/RECORD +11 -0
- redis_lua_py-0.1.0.dist-info/WHEEL +4 -0
- redis_lua_py-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: redis-lua-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Write Redis Lua scripts as real Python functions, not strings.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ignacemaes/redis-lua-py
|
|
6
|
+
Project-URL: Issues, https://github.com/ignacemaes/redis-lua-py/issues
|
|
7
|
+
Author: Ignace Maes
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: eval,evalsha,lua,redis,scripting,transpiler
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Lua
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: redis>=5.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<picture>
|
|
25
|
+
<source media="(prefers-color-scheme: dark)" srcset="./.github/assets/banner-dark.svg">
|
|
26
|
+
<img alt="redis-lua-py: Redis Lua scripts as real Python functions." src="./.github/assets/banner-light.svg" width="860">
|
|
27
|
+
</picture>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<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>
|
|
32
|
+
<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>
|
|
33
|
+
<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>
|
|
34
|
+
<a href="./LICENSE"><img alt="license" src="https://img.shields.io/pypi/l/redis-lua-py?color=%230070F3"></a>
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<p align="center">
|
|
38
|
+
Write Redis Lua scripts as real Python functions, not as strings.<br>
|
|
39
|
+
Compiled at import, checked by <code>mypy</code>, sent with <code>EVALSHA</code>. Sync and async redis-py.
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from redis_lua_py import Key, redis, script
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@script
|
|
47
|
+
def rate_limit(key: Key, limit: int, ttl: int) -> int:
|
|
48
|
+
current = redis.incr(key)
|
|
49
|
+
if current == 1:
|
|
50
|
+
redis.expire(key, ttl)
|
|
51
|
+
if current > limit:
|
|
52
|
+
return -1
|
|
53
|
+
return limit - current
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The body is never executed by Python. It is read as source when the module is
|
|
57
|
+
imported, compiled to Lua, and sent to Redis with `EVALSHA`. Your editor
|
|
58
|
+
highlights it, your linter sees it, and `mypy` checks the signature — none of
|
|
59
|
+
which is true of a string.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from redis import Redis
|
|
63
|
+
|
|
64
|
+
client = Redis()
|
|
65
|
+
remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Importing the client as `from redis import Redis` leaves the name `redis` free
|
|
69
|
+
for the script namespace, so the two never collide.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv add redis-lua-py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## What it compiles to
|
|
78
|
+
|
|
79
|
+
Nothing is hidden. Every script exposes the Lua it produced:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
>>> print(rate_limit.lua)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```lua
|
|
86
|
+
-- rate_limit
|
|
87
|
+
-- Generated by redis-lua-py from /srv/app/limits.py:6. Do not edit.
|
|
88
|
+
local key = KEYS[1]
|
|
89
|
+
local limit = tonumber(ARGV[1])
|
|
90
|
+
local ttl = tonumber(ARGV[2])
|
|
91
|
+
local current = redis.call('INCR', key)
|
|
92
|
+
if current == 1 then
|
|
93
|
+
redis.call('EXPIRE', key, ttl)
|
|
94
|
+
end
|
|
95
|
+
if current > limit then
|
|
96
|
+
return -1
|
|
97
|
+
end
|
|
98
|
+
return limit - current
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Read it in review, paste it into `redis-cli`, check it into a golden test. The
|
|
102
|
+
point of this library is to generate Lua you would have been willing to write.
|
|
103
|
+
|
|
104
|
+
## Keys and arguments
|
|
105
|
+
|
|
106
|
+
A parameter annotated `Key` becomes `KEYS`, in declaration order. Everything
|
|
107
|
+
else becomes `ARGV`.
|
|
108
|
+
|
|
109
|
+
This distinction is not cosmetic. Redis Cluster routes a script by its declared
|
|
110
|
+
keys, and a key smuggled in as an argument is invisible to the router — the
|
|
111
|
+
script will execute on the wrong node. Annotate every key.
|
|
112
|
+
|
|
113
|
+
`ARGV` always arrives in Lua as a string. Annotating a parameter `int` or
|
|
114
|
+
`float` wraps it in `tonumber` for you, so `limit` above is a number by the
|
|
115
|
+
time your comparison runs.
|
|
116
|
+
|
|
117
|
+
Scripts accept positional or keyword arguments; keyword is clearer at the call
|
|
118
|
+
site and is what the errors suggest.
|
|
119
|
+
|
|
120
|
+
## Async
|
|
121
|
+
|
|
122
|
+
The same script object works with either client. Pass a sync client and you get
|
|
123
|
+
a value; pass an async one and you get an awaitable.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from redis.asyncio import Redis
|
|
127
|
+
|
|
128
|
+
client = Redis()
|
|
129
|
+
remaining = await rate_limit(client, key="user:42", limit=10, ttl=60)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Script caching, `EVALSHA`, and the `NOSCRIPT` reload are handled by redis-py's
|
|
133
|
+
own script machinery, which this defers to rather than reimplementing.
|
|
134
|
+
|
|
135
|
+
## Binding a client
|
|
136
|
+
|
|
137
|
+
Passing the client to every call gets repetitive. `bind` attaches one:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
limiter = rate_limit.bind(client)
|
|
141
|
+
|
|
142
|
+
limiter(key="user:42", limit=10, ttl=60)
|
|
143
|
+
limiter(key="user:43", limit=10, ttl=60)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A bound script exposes the same `.lua`, `.keys` and `.args` as the original,
|
|
147
|
+
binds async clients just as well, and leaves the unbound form working — the
|
|
148
|
+
script itself is unchanged and still usable against any other client.
|
|
149
|
+
|
|
150
|
+
## Calling Redis commands
|
|
151
|
+
|
|
152
|
+
`redis.<command>(...)` becomes `redis.call('<COMMAND>', ...)`. Underscores
|
|
153
|
+
split into subcommand tokens, so `redis.script_load(x)` compiles to
|
|
154
|
+
`redis.call('SCRIPT', 'LOAD', x)`.
|
|
155
|
+
|
|
156
|
+
`redis.pcall`, `redis.error_reply`, `redis.status_reply`, `redis.sha1hex`,
|
|
157
|
+
`redis.log` and `cjson.encode` / `cjson.decode` pass through under their own
|
|
158
|
+
names.
|
|
159
|
+
|
|
160
|
+
### When the client is imported too
|
|
161
|
+
|
|
162
|
+
Import the client *class* and nothing collides, because the name `redis` is
|
|
163
|
+
never taken:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from redis import Redis
|
|
167
|
+
from redis_lua_py import Key, redis, script
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
If you want the client module itself, the namespace is resolved by value rather
|
|
171
|
+
than by spelling, so import it under any name you like:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
import redis # the client
|
|
175
|
+
from redis_lua_py import Key, script
|
|
176
|
+
from redis_lua_py import redis as r # the script namespace
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@script
|
|
180
|
+
def claim(queue: Key, now: int) -> list[str]:
|
|
181
|
+
return r.zrangebyscore(queue, 0, now)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
client = redis.Redis()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`call` is also exported as an alias of `redis`, if you would rather rename
|
|
188
|
+
nothing at all.
|
|
189
|
+
|
|
190
|
+
Getting this wrong is caught rather than compiled. If the name in scope turns
|
|
191
|
+
out to be redis-py, the script is refused instead of being quietly aimed at the
|
|
192
|
+
client library:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
'redis' is bound to redis-py here, not to the script namespace
|
|
196
|
+
File "/srv/app/jobs.py", line 9
|
|
197
|
+
return redis.zrangebyscore(queue, 0, now)
|
|
198
|
+
^
|
|
199
|
+
hint: Import the namespace under another name (from redis_lua_py import
|
|
200
|
+
redis as r), or the client under another name (import redis as redis_client).
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## The supported subset
|
|
204
|
+
|
|
205
|
+
Supported: assignment, augmented assignment, `if`/`elif`/`else`, `for ... in`
|
|
206
|
+
over a table or `range()`, `while`, `break`, `return`, comparisons, arithmetic,
|
|
207
|
+
f-strings, list and dict literals, `len()`, `.append()`, `int()`, `float()`,
|
|
208
|
+
`str()`, `min()`, `max()`, `abs()`, and calls into `redis` and `cjson`.
|
|
209
|
+
|
|
210
|
+
Everything else raises `UnsupportedSyntax` when the module is imported, with a
|
|
211
|
+
caret under the line at fault:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
'and'/'or' are only supported in an if or while condition
|
|
215
|
+
File "/srv/app/limits.py", line 12
|
|
216
|
+
flag = a and b
|
|
217
|
+
^
|
|
218
|
+
hint: In Python these return an operand, which does not survive the
|
|
219
|
+
difference in truthiness. Use an if statement instead.
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Failing at import, loudly, is deliberate. A body that looks like Python but is
|
|
223
|
+
never run by Python is exactly where a quiet mistranslation would cost the
|
|
224
|
+
most.
|
|
225
|
+
|
|
226
|
+
## Where Lua differs from Python
|
|
227
|
+
|
|
228
|
+
These are the gaps that matter. Most are closed for you; the rest are refused.
|
|
229
|
+
|
|
230
|
+
**Truthiness is closed.** Lua counts `0` and `''` as true. Any condition that
|
|
231
|
+
is not already a boolean is routed through a generated `__truthy` helper, so
|
|
232
|
+
`if count:` means what it means in Python.
|
|
233
|
+
|
|
234
|
+
**Missing values are closed.** A Redis command with nothing to return hands Lua
|
|
235
|
+
`false`, not `nil`. This is the classic trap: a hand-written `== nil` never
|
|
236
|
+
matches, so the branch silently never runs. `x is None` compiles to a helper
|
|
237
|
+
accepting both, which also takes `x` as an argument — so
|
|
238
|
+
`if redis.hget(k, f) is None:` does not run the command twice.
|
|
239
|
+
|
|
240
|
+
**Indexing is closed.** Lua tables are 1-based. `items[0]` compiles to
|
|
241
|
+
`items[1]`. Write Python indices and let the compiler shift them. Negative
|
|
242
|
+
indices are refused, because Lua has no equivalent.
|
|
243
|
+
|
|
244
|
+
**Assignment scope is closed.** Python scopes a name to the whole function;
|
|
245
|
+
Lua's `local` scopes it to the enclosing block. A name assigned inside an `if`
|
|
246
|
+
and read after it is hoisted to the top of the script, so it does not silently
|
|
247
|
+
read back `nil`.
|
|
248
|
+
|
|
249
|
+
**`+` is arithmetic, not concatenation.** Use an f-string, which compiles to
|
|
250
|
+
Lua's `..`.
|
|
251
|
+
|
|
252
|
+
**`and` / `or` work only in conditions.** In Python they return an operand, not
|
|
253
|
+
a boolean, and that does not survive the truthiness difference. Use an `if`.
|
|
254
|
+
|
|
255
|
+
**There is no `continue`.** Lua 5.1 does not have one. Invert the condition and
|
|
256
|
+
nest the rest of the body.
|
|
257
|
+
|
|
258
|
+
**A loop variable does not outlive its loop,** unlike in Python.
|
|
259
|
+
|
|
260
|
+
**Return values follow Redis' own conversion rules:** `True` becomes `1`,
|
|
261
|
+
`False` and `None` become nil, floats are truncated to integers. Return a
|
|
262
|
+
string, or `cjson.encode(...)`, when you need one preserved exactly.
|
|
263
|
+
|
|
264
|
+
## A larger example
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
@script
|
|
268
|
+
def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[str]:
|
|
269
|
+
"""Atomically move due jobs from a sorted set into a processing hash."""
|
|
270
|
+
ids = redis.zrangebyscore(queue, 0, now, "LIMIT", 0, limit)
|
|
271
|
+
claimed = []
|
|
272
|
+
for job_id in ids:
|
|
273
|
+
if redis.zrem(queue, job_id) == 1:
|
|
274
|
+
redis.hset(processing, job_id, now)
|
|
275
|
+
claimed.append(job_id)
|
|
276
|
+
return claimed
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```lua
|
|
280
|
+
local queue = KEYS[1]
|
|
281
|
+
local processing = KEYS[2]
|
|
282
|
+
local now = tonumber(ARGV[1])
|
|
283
|
+
local limit = tonumber(ARGV[2])
|
|
284
|
+
local ids = redis.call('ZRANGEBYSCORE', queue, 0, now, 'LIMIT', 0, limit)
|
|
285
|
+
local claimed = {}
|
|
286
|
+
for __i1 = 1, #ids do
|
|
287
|
+
local job_id = ids[__i1]
|
|
288
|
+
if redis.call('ZREM', queue, job_id) == 1 then
|
|
289
|
+
redis.call('HSET', processing, job_id, now)
|
|
290
|
+
claimed[#claimed + 1] = job_id
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
return claimed
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Development
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
uv sync
|
|
300
|
+
uv run pytest
|
|
301
|
+
uv run ruff check
|
|
302
|
+
uv run mypy
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Tests run against [fakeredis](https://github.com/cunla/fakeredis-py), which
|
|
306
|
+
executes real Lua, so `uv run pytest` needs no server. Set `REDIS_URL` to also
|
|
307
|
+
run them against a live Redis:
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
REDIS_URL=redis://localhost:6379/0 uv run pytest
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Pull requests are squash-merged and their titles must follow
|
|
314
|
+
[Conventional Commits](https://www.conventionalcommits.org/): the title becomes
|
|
315
|
+
the changelog entry and decides the version bump. See
|
|
316
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
317
|
+
|
|
318
|
+
## License
|
|
319
|
+
|
|
320
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
redis_lua_py/__init__.py,sha256=k7tmd6Al1jQbXSiiJjMRRjuZHA4qIVC5Wq3t6_4xsp8,2490
|
|
2
|
+
redis_lua_py/_compile.py,sha256=Clr-zZY5W12nGGJmbDk6sYuSUGsOCo0e3WcPslrn5zQ,32409
|
|
3
|
+
redis_lua_py/_lua.py,sha256=WzT3UAOZHqMpF6icq9bMhasLT1U40PkT1Mp8rkVLNBQ,8590
|
|
4
|
+
redis_lua_py/_runtime.py,sha256=t9rrWxGWWrUpErCPHxfzCuLOz_NWPISC8sN0O_gzcYU,2559
|
|
5
|
+
redis_lua_py/_script.py,sha256=WFqZSFyGVTdPOlRkq-0mVjyKyRrfwQ0eZpyNvZXTARo,5368
|
|
6
|
+
redis_lua_py/errors.py,sha256=lN-NUOB1vEmNQKUCPiZj7kBbu4-zF_a9gIQZmaHeock,1582
|
|
7
|
+
redis_lua_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
redis_lua_py-0.1.0.dist-info/METADATA,sha256=_ADf1G21MQbwJBdVYVCBMteFupaBvJNkLDnYzvVuW5Y,10559
|
|
9
|
+
redis_lua_py-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
redis_lua_py-0.1.0.dist-info/licenses/LICENSE,sha256=E1Kbm45KA8UemKRyzOkZy8NTjRlA6Mi_xcJgHKs1m1k,1068
|
|
11
|
+
redis_lua_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ignace Maes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|