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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.