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
redis_lua_py/_lua.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""A small Lua IR and the emitter that turns it into source text.
|
|
2
|
+
|
|
3
|
+
The IR is deliberately Lua-shaped rather than Python-shaped: every semantic
|
|
4
|
+
gap between the two languages is closed in ``_compile``, so that by the time a
|
|
5
|
+
node reaches this module it means exactly what the emitted text means. That
|
|
6
|
+
keeps the emitter dumb, which is what makes the output reviewable.
|
|
7
|
+
|
|
8
|
+
This module is private. Its shape is not part of the public API.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
# Lua 5.1 operator precedence, lowest binding first. Used to decide where
|
|
16
|
+
# parentheses are actually required, so the emitted source stays readable.
|
|
17
|
+
_PRECEDENCE: dict[str, int] = {
|
|
18
|
+
"or": 1,
|
|
19
|
+
"and": 2,
|
|
20
|
+
"<": 3,
|
|
21
|
+
">": 3,
|
|
22
|
+
"<=": 3,
|
|
23
|
+
">=": 3,
|
|
24
|
+
"~=": 3,
|
|
25
|
+
"==": 3,
|
|
26
|
+
"..": 4,
|
|
27
|
+
"+": 5,
|
|
28
|
+
"-": 5,
|
|
29
|
+
"*": 6,
|
|
30
|
+
"/": 6,
|
|
31
|
+
"%": 6,
|
|
32
|
+
"^": 8,
|
|
33
|
+
}
|
|
34
|
+
_UNARY_PRECEDENCE = 7
|
|
35
|
+
_RIGHT_ASSOCIATIVE = {"..", "^"}
|
|
36
|
+
|
|
37
|
+
_LUA_KEYWORDS = frozenset(
|
|
38
|
+
[
|
|
39
|
+
"and",
|
|
40
|
+
"break",
|
|
41
|
+
"do",
|
|
42
|
+
"else",
|
|
43
|
+
"elseif",
|
|
44
|
+
"end",
|
|
45
|
+
"false",
|
|
46
|
+
"for",
|
|
47
|
+
"function",
|
|
48
|
+
"if",
|
|
49
|
+
"in",
|
|
50
|
+
"local",
|
|
51
|
+
"nil",
|
|
52
|
+
"not",
|
|
53
|
+
"or",
|
|
54
|
+
"repeat",
|
|
55
|
+
"return",
|
|
56
|
+
"then",
|
|
57
|
+
"true",
|
|
58
|
+
"until",
|
|
59
|
+
"while",
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_ESCAPES = {"\\": "\\\\", "'": "\\'", "\n": "\\n", "\r": "\\r", "\t": "\\t", "\0": "\\0"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def quote(value: str) -> str:
|
|
67
|
+
"""Render a Python string as a single-quoted Lua literal."""
|
|
68
|
+
out = []
|
|
69
|
+
for ch in value:
|
|
70
|
+
if ch in _ESCAPES:
|
|
71
|
+
out.append(_ESCAPES[ch])
|
|
72
|
+
elif ord(ch) < 0x20 or ord(ch) == 0x7F:
|
|
73
|
+
out.append(f"\\{ord(ch):03d}")
|
|
74
|
+
else:
|
|
75
|
+
out.append(ch)
|
|
76
|
+
return "'" + "".join(out) + "'"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_identifier(name: str) -> bool:
|
|
80
|
+
return name.isidentifier() and name not in _LUA_KEYWORDS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Node:
|
|
84
|
+
__slots__ = ()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Expr(Node):
|
|
88
|
+
__slots__ = ()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Stat(Node):
|
|
92
|
+
__slots__ = ()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Nil(Expr):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class Bool(Expr):
|
|
102
|
+
value: bool
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class Num(Expr):
|
|
107
|
+
value: int | float
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class Str(Expr):
|
|
112
|
+
value: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class Name(Expr):
|
|
117
|
+
id: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class BinOp(Expr):
|
|
122
|
+
op: str
|
|
123
|
+
left: Expr
|
|
124
|
+
right: Expr
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class UnOp(Expr):
|
|
129
|
+
op: str # "not", "-", "#"
|
|
130
|
+
operand: Expr
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class Index(Expr):
|
|
135
|
+
obj: Expr
|
|
136
|
+
key: Expr
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class Call(Expr):
|
|
141
|
+
func: Expr
|
|
142
|
+
args: tuple[Expr, ...]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class Table(Expr):
|
|
147
|
+
array: tuple[Expr, ...] = ()
|
|
148
|
+
hash: tuple[tuple[Expr, Expr], ...] = ()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(slots=True)
|
|
152
|
+
class Local(Stat):
|
|
153
|
+
names: list[str]
|
|
154
|
+
values: list[Expr]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(slots=True)
|
|
158
|
+
class Assign(Stat):
|
|
159
|
+
targets: list[Expr]
|
|
160
|
+
values: list[Expr]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(slots=True)
|
|
164
|
+
class If(Stat):
|
|
165
|
+
branches: list[tuple[Expr, list[Stat]]]
|
|
166
|
+
orelse: list[Stat] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(slots=True)
|
|
170
|
+
class NumericFor(Stat):
|
|
171
|
+
var: str
|
|
172
|
+
start: Expr
|
|
173
|
+
stop: Expr
|
|
174
|
+
step: Expr | None
|
|
175
|
+
body: list[Stat]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(slots=True)
|
|
179
|
+
class While(Stat):
|
|
180
|
+
test: Expr
|
|
181
|
+
body: list[Stat]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(slots=True)
|
|
185
|
+
class Return(Stat):
|
|
186
|
+
value: Expr | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(slots=True)
|
|
190
|
+
class Break(Stat):
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(slots=True)
|
|
195
|
+
class ExprStat(Stat):
|
|
196
|
+
expr: Expr
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(slots=True)
|
|
200
|
+
class Comment(Stat):
|
|
201
|
+
text: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def emit_expr(node: Expr, parent_prec: int = 0) -> str:
|
|
205
|
+
"""Render an expression, parenthesising only where precedence demands it."""
|
|
206
|
+
match node:
|
|
207
|
+
case Nil():
|
|
208
|
+
return "nil"
|
|
209
|
+
case Bool(value=v):
|
|
210
|
+
return "true" if v else "false"
|
|
211
|
+
case Num(value=v):
|
|
212
|
+
return repr(v) if isinstance(v, float) else str(v)
|
|
213
|
+
case Str(value=v):
|
|
214
|
+
return quote(v)
|
|
215
|
+
case Name(id=v):
|
|
216
|
+
return v
|
|
217
|
+
case Table(array=arr, hash=pairs):
|
|
218
|
+
items = [emit_expr(a) for a in arr]
|
|
219
|
+
items += [
|
|
220
|
+
(
|
|
221
|
+
f"[{emit_expr(k)}] = {emit_expr(val)}"
|
|
222
|
+
if not (isinstance(k, Str) and is_identifier(k.value))
|
|
223
|
+
else f"{k.value} = {emit_expr(val)}"
|
|
224
|
+
)
|
|
225
|
+
for k, val in pairs
|
|
226
|
+
]
|
|
227
|
+
return "{" + ", ".join(items) + "}"
|
|
228
|
+
case Index(obj=obj, key=key):
|
|
229
|
+
base = emit_expr(obj, 9)
|
|
230
|
+
if isinstance(key, Str) and is_identifier(key.value):
|
|
231
|
+
return f"{base}.{key.value}"
|
|
232
|
+
return f"{base}[{emit_expr(key)}]"
|
|
233
|
+
case Call(func=func, args=args):
|
|
234
|
+
rendered = ", ".join(emit_expr(a) for a in args)
|
|
235
|
+
return f"{emit_expr(func, 9)}({rendered})"
|
|
236
|
+
case UnOp(op=op, operand=operand):
|
|
237
|
+
spacer = " " if op == "not" else ""
|
|
238
|
+
text = f"{op}{spacer}{emit_expr(operand, _UNARY_PRECEDENCE)}"
|
|
239
|
+
return f"({text})" if parent_prec > _UNARY_PRECEDENCE else text
|
|
240
|
+
case BinOp(op=op, left=left, right=right):
|
|
241
|
+
prec = _PRECEDENCE[op]
|
|
242
|
+
# For a right-associative operator the left operand needs the
|
|
243
|
+
# tighter bound, and vice versa.
|
|
244
|
+
if op in _RIGHT_ASSOCIATIVE:
|
|
245
|
+
left_prec, right_prec = prec + 1, prec
|
|
246
|
+
else:
|
|
247
|
+
left_prec, right_prec = prec, prec + 1
|
|
248
|
+
text = f"{emit_expr(left, left_prec)} {op} {emit_expr(right, right_prec)}"
|
|
249
|
+
return f"({text})" if prec < parent_prec else text
|
|
250
|
+
case _: # pragma: no cover - guards against an IR node with no emitter
|
|
251
|
+
raise TypeError(f"cannot emit expression node {type(node).__name__}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def emit_block(body: list[Stat], indent: int = 0) -> list[str]:
|
|
255
|
+
lines: list[str] = []
|
|
256
|
+
pad = " " * indent
|
|
257
|
+
for stat in body:
|
|
258
|
+
match stat:
|
|
259
|
+
case Comment(text=text):
|
|
260
|
+
lines += [f"{pad}-- {line}" for line in text.splitlines()]
|
|
261
|
+
case Local(names=names, values=values):
|
|
262
|
+
target = ", ".join(names)
|
|
263
|
+
if values:
|
|
264
|
+
rhs = ", ".join(emit_expr(v) for v in values)
|
|
265
|
+
lines.append(f"{pad}local {target} = {rhs}")
|
|
266
|
+
else:
|
|
267
|
+
lines.append(f"{pad}local {target}")
|
|
268
|
+
case Assign(targets=targets, values=values):
|
|
269
|
+
lhs = ", ".join(emit_expr(t) for t in targets)
|
|
270
|
+
rhs = ", ".join(emit_expr(v) for v in values)
|
|
271
|
+
lines.append(f"{pad}{lhs} = {rhs}")
|
|
272
|
+
case ExprStat(expr=expr):
|
|
273
|
+
lines.append(f"{pad}{emit_expr(expr)}")
|
|
274
|
+
case Return(value=value):
|
|
275
|
+
rendered = "" if value is None else f" {emit_expr(value)}"
|
|
276
|
+
lines.append(f"{pad}return{rendered}")
|
|
277
|
+
case Break():
|
|
278
|
+
# Lua 5.1 requires `break` to be the final statement of a
|
|
279
|
+
# block; wrapping it in `do ... end` makes that true wherever
|
|
280
|
+
# Python allowed it.
|
|
281
|
+
lines.append(f"{pad}do break end")
|
|
282
|
+
case While(test=test, body=inner):
|
|
283
|
+
lines.append(f"{pad}while {emit_expr(test)} do")
|
|
284
|
+
lines += emit_block(inner, indent + 1)
|
|
285
|
+
lines.append(f"{pad}end")
|
|
286
|
+
case NumericFor(var=var, start=start, stop=stop, step=step, body=inner):
|
|
287
|
+
header = f"{pad}for {var} = {emit_expr(start)}, {emit_expr(stop)}"
|
|
288
|
+
if step is not None:
|
|
289
|
+
header += f", {emit_expr(step)}"
|
|
290
|
+
lines.append(header + " do")
|
|
291
|
+
lines += emit_block(inner, indent + 1)
|
|
292
|
+
lines.append(f"{pad}end")
|
|
293
|
+
case If(branches=branches, orelse=orelse):
|
|
294
|
+
for i, (test, inner) in enumerate(branches):
|
|
295
|
+
keyword = "if" if i == 0 else "elseif"
|
|
296
|
+
lines.append(f"{pad}{keyword} {emit_expr(test)} then")
|
|
297
|
+
lines += emit_block(inner, indent + 1)
|
|
298
|
+
if orelse:
|
|
299
|
+
lines.append(f"{pad}else")
|
|
300
|
+
lines += emit_block(orelse, indent + 1)
|
|
301
|
+
lines.append(f"{pad}end")
|
|
302
|
+
case _: # pragma: no cover - guards against an IR node with no emitter
|
|
303
|
+
raise TypeError(f"cannot emit statement node {type(stat).__name__}")
|
|
304
|
+
return lines
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def emit(body: list[Stat]) -> str:
|
|
308
|
+
return "\n".join(emit_block(body)) + "\n"
|
redis_lua_py/_runtime.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Names that script bodies refer to.
|
|
2
|
+
|
|
3
|
+
Nothing here ever runs. A decorated function's body is read as source and
|
|
4
|
+
compiled to Lua; Python never executes it. These objects exist so that the
|
|
5
|
+
body is a valid Python expression to your editor, your linter and your type
|
|
6
|
+
checker, and so that calling one by mistake fails loudly instead of silently
|
|
7
|
+
doing nothing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, NoReturn
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Key(str):
|
|
16
|
+
"""Marks a parameter as a Redis key.
|
|
17
|
+
|
|
18
|
+
Parameters annotated ``Key`` become ``KEYS[n]`` inside the script, in
|
|
19
|
+
declaration order; everything else becomes ``ARGV[n]``. Getting this right
|
|
20
|
+
matters: Redis Cluster routes a script by its declared keys, so a key
|
|
21
|
+
passed as an argument will be invisible to the router.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__slots__ = ()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Namespace:
|
|
28
|
+
"""Attribute access that type checkers accept and runtime refuses.
|
|
29
|
+
|
|
30
|
+
The compiler identifies these by value rather than by the name they are
|
|
31
|
+
imported under, so every alias works and nothing is reserved.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_name",)
|
|
35
|
+
|
|
36
|
+
def __init__(self, name: str) -> None:
|
|
37
|
+
object.__setattr__(self, "_name", name)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def kind(self) -> str:
|
|
41
|
+
"""Which Lua table this stands for: ``redis`` or ``cjson``."""
|
|
42
|
+
return str(object.__getattribute__(self, "_name"))
|
|
43
|
+
|
|
44
|
+
def __getattr__(self, item: str) -> Any:
|
|
45
|
+
namespace = object.__getattribute__(self, "_name")
|
|
46
|
+
|
|
47
|
+
def _stub(*_args: object, **_kwargs: object) -> NoReturn:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
f"{namespace}.{item}() is a Lua construct and cannot run in Python. "
|
|
50
|
+
"It is only meaningful inside the body of an @script function, "
|
|
51
|
+
"which is compiled rather than executed."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return _stub
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
57
|
+
return f"<redis_lua_py {object.__getattribute__(self, '_name')} namespace>"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: Call Redis commands: ``redis.incr(key)`` becomes ``redis.call('INCR', key)``.
|
|
61
|
+
#: Underscores split into subcommand tokens, so ``redis.script_load(x)``
|
|
62
|
+
#: becomes ``redis.call('SCRIPT', 'LOAD', x)``.
|
|
63
|
+
#:
|
|
64
|
+
#: Import it under any name you like. If the module also imports the redis-py
|
|
65
|
+
#: client, ``from redis_lua_py import redis as r`` keeps the two apart.
|
|
66
|
+
redis = _Namespace("redis")
|
|
67
|
+
|
|
68
|
+
#: Alias for :data:`redis`, for modules that would rather not rename anything.
|
|
69
|
+
call = _Namespace("redis")
|
|
70
|
+
|
|
71
|
+
#: The JSON library Redis exposes to scripts: ``cjson.encode`` / ``cjson.decode``.
|
|
72
|
+
cjson = _Namespace("cjson")
|
redis_lua_py/_script.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The callable object a decorated function becomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
from weakref import WeakKeyDictionary
|
|
9
|
+
|
|
10
|
+
from .errors import ScriptArgumentError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def encode(name: str, value: object) -> str | bytes | memoryview:
|
|
14
|
+
"""Render a Python value as a Redis argument.
|
|
15
|
+
|
|
16
|
+
Redis has no argument types: everything on the wire is a byte string. This
|
|
17
|
+
only accepts values whose string form is unambiguous, so that a stray None
|
|
18
|
+
or object fails here rather than arriving in Lua as something surprising.
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
return "1" if value else "0"
|
|
22
|
+
if isinstance(value, str | bytes | memoryview):
|
|
23
|
+
return value
|
|
24
|
+
if isinstance(value, int | float):
|
|
25
|
+
return repr(value) if isinstance(value, float) else str(value)
|
|
26
|
+
raise ScriptArgumentError(
|
|
27
|
+
f"argument {name!r} is a {type(value).__name__}, which has no Redis representation. "
|
|
28
|
+
"Pass a str, bytes, int, float or bool."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CompiledScript:
|
|
34
|
+
"""A Python function compiled to Lua, callable against a Redis client.
|
|
35
|
+
|
|
36
|
+
Calling it runs EVALSHA and falls back to EVAL the first time, or whenever
|
|
37
|
+
the server has dropped the script from its cache. Pass a sync client and
|
|
38
|
+
you get a value; pass an async client and you get an awaitable.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
lua: str
|
|
43
|
+
params: tuple[str, ...]
|
|
44
|
+
keys: tuple[str, ...]
|
|
45
|
+
args: tuple[str, ...]
|
|
46
|
+
doc: str | None = None
|
|
47
|
+
source: str = ""
|
|
48
|
+
_registry: WeakKeyDictionary[Any, Any] = field(
|
|
49
|
+
default_factory=WeakKeyDictionary, compare=False, repr=False
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __call__(self, client: Any, /, *positional: object, **keyword: object) -> Any:
|
|
53
|
+
keys, argv = self.resolve(*positional, **keyword)
|
|
54
|
+
return self._for(client)(keys=keys, args=argv, client=client)
|
|
55
|
+
|
|
56
|
+
def bind(self, client: Any) -> BoundScript:
|
|
57
|
+
"""Attach a client, so that callers stop repeating it.
|
|
58
|
+
|
|
59
|
+
The unbound form keeps working; this only removes the first argument.
|
|
60
|
+
Sync and async clients bind the same way, and the bound call returns
|
|
61
|
+
whatever the underlying client would have.
|
|
62
|
+
"""
|
|
63
|
+
return BoundScript(self, client)
|
|
64
|
+
|
|
65
|
+
def resolve(self, *positional: object, **keyword: object) -> tuple[list[Any], list[Any]]:
|
|
66
|
+
"""Resolve call arguments into the KEYS and ARGV lists."""
|
|
67
|
+
if len(positional) > len(self.params):
|
|
68
|
+
raise ScriptArgumentError(
|
|
69
|
+
f"{self.name}() takes {len(self.params)} argument(s), got {len(positional)}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
values: dict[str, object] = dict(zip(self.params, positional, strict=False))
|
|
73
|
+
for key, value in keyword.items():
|
|
74
|
+
if key not in self.params:
|
|
75
|
+
suggestion = difflib.get_close_matches(key, self.params, n=1)
|
|
76
|
+
hint = f"; did you mean {suggestion[0]!r}?" if suggestion else ""
|
|
77
|
+
raise ScriptArgumentError(
|
|
78
|
+
f"{self.name}() has no parameter {key!r}{hint} "
|
|
79
|
+
f"(parameters: {', '.join(self.params)})"
|
|
80
|
+
)
|
|
81
|
+
if key in values:
|
|
82
|
+
raise ScriptArgumentError(f"{self.name}() got two values for {key!r}")
|
|
83
|
+
values[key] = value
|
|
84
|
+
|
|
85
|
+
missing = [p for p in self.params if p not in values]
|
|
86
|
+
if missing:
|
|
87
|
+
raise ScriptArgumentError(f"{self.name}() is missing argument(s): {', '.join(missing)}")
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
[values[k] for k in self.keys],
|
|
91
|
+
[encode(a, values[a]) for a in self.args],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _for(self, client: Any) -> Any:
|
|
95
|
+
"""Get the redis-py Script bound to this client, registering it once.
|
|
96
|
+
|
|
97
|
+
redis-py's own Script object already implements the EVALSHA-then-EVAL
|
|
98
|
+
dance and the NOSCRIPT retry, so this defers to it rather than
|
|
99
|
+
reimplementing script caching.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
registered = self._registry.get(client)
|
|
103
|
+
except TypeError: # a client that does not support weak references
|
|
104
|
+
return client.register_script(self.lua)
|
|
105
|
+
if registered is None:
|
|
106
|
+
registered = client.register_script(self.lua)
|
|
107
|
+
self._registry[client] = registered
|
|
108
|
+
return registered
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
signature = ", ".join(self.params)
|
|
112
|
+
return f"<script {self.name}({signature}) from {self.source}>"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class BoundScript:
|
|
117
|
+
"""A script with its client already attached, produced by :meth:`bind`."""
|
|
118
|
+
|
|
119
|
+
script: CompiledScript
|
|
120
|
+
client: Any
|
|
121
|
+
|
|
122
|
+
def __call__(self, *positional: object, **keyword: object) -> Any:
|
|
123
|
+
return self.script(self.client, *positional, **keyword)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def name(self) -> str:
|
|
127
|
+
return self.script.name
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def lua(self) -> str:
|
|
131
|
+
return self.script.lua
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def params(self) -> tuple[str, ...]:
|
|
135
|
+
return self.script.params
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def keys(self) -> tuple[str, ...]:
|
|
139
|
+
return self.script.keys
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def args(self) -> tuple[str, ...]:
|
|
143
|
+
return self.script.args
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def doc(self) -> str | None:
|
|
147
|
+
return self.script.doc
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
signature = ", ".join(self.script.params)
|
|
151
|
+
return f"<bound script {self.script.name}({signature})>"
|
redis_lua_py/errors.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Errors raised while compiling or running a script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RedisLuaError(Exception):
|
|
7
|
+
"""Base class for every error this package raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompileError(RedisLuaError):
|
|
11
|
+
"""A Python function could not be turned into Lua."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsupportedSyntax(CompileError):
|
|
15
|
+
"""The function used Python that has no meaning inside a Redis script.
|
|
16
|
+
|
|
17
|
+
Carries the location so the message points at the offending line rather
|
|
18
|
+
than at the decorator.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
filename: str,
|
|
26
|
+
lineno: int,
|
|
27
|
+
col: int,
|
|
28
|
+
source_line: str | None = None,
|
|
29
|
+
hint: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.message = message
|
|
32
|
+
self.filename = filename
|
|
33
|
+
self.lineno = lineno
|
|
34
|
+
self.col = col
|
|
35
|
+
self.source_line = source_line
|
|
36
|
+
self.hint = hint
|
|
37
|
+
super().__init__(self._render())
|
|
38
|
+
|
|
39
|
+
def _render(self) -> str:
|
|
40
|
+
parts = [f"{self.message}", f' File "{self.filename}", line {self.lineno}']
|
|
41
|
+
if self.source_line is not None:
|
|
42
|
+
stripped = self.source_line.rstrip()
|
|
43
|
+
indent = len(stripped) - len(stripped.lstrip())
|
|
44
|
+
parts.append(f" {stripped.lstrip()}")
|
|
45
|
+
caret_col = max(self.col - indent, 0)
|
|
46
|
+
parts.append(" " + " " * caret_col + "^")
|
|
47
|
+
if self.hint:
|
|
48
|
+
parts.append(f" hint: {self.hint}")
|
|
49
|
+
return "\n".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScriptArgumentError(RedisLuaError):
|
|
53
|
+
"""A script was called with the wrong keys or arguments."""
|
redis_lua_py/py.typed
ADDED
|
File without changes
|