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/_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"
@@ -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")
@@ -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