labtasker-server 2.0.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,528 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import math
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import Any, Literal, TypeAlias, cast
9
+
10
+ from sqlalchemy import and_, false, func, not_, or_, select, true
11
+ from sqlalchemy.sql.elements import ColumnElement
12
+
13
+ from labtasker_server.errors import DomainError, invalid
14
+ from labtasker_server.models import TaskRouteRow, TaskRow
15
+ from labtasker_server.validation import INT64_MAX, INT64_MIN, MAX_FILTER_BYTES
16
+
17
+ Scalar: TypeAlias = bool | int | float | str | None
18
+ CompareOperator: TypeAlias = Literal["==", "!=", "<", "<=", ">", ">="]
19
+ MembershipOperator: TypeAlias = Literal["in", "not in"]
20
+ PATH_SEGMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
21
+ RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$")
22
+
23
+ BUILTIN_TYPES: dict[str, tuple[str, bool]] = {
24
+ "id": ("string", False),
25
+ "status": ("status", False),
26
+ "name": ("string", True),
27
+ "priority": ("number", False),
28
+ "attempt": ("number", False),
29
+ "max_attempts": ("number", False),
30
+ "last_route": ("string", True),
31
+ "created_at": ("timestamp", False),
32
+ "updated_at": ("timestamp", False),
33
+ "started_at": ("timestamp", True),
34
+ "finished_at": ("timestamp", True),
35
+ }
36
+ LAST_ERROR_TYPES: dict[str, tuple[str, bool, str]] = {
37
+ "type": ("string", False, "type"),
38
+ "message": ("string", False, "message"),
39
+ "traceback": ("string", True, "traceback"),
40
+ "occurred_at": ("timestamp", False, "occurred_at_us"),
41
+ "attempt": ("number", False, "attempt"),
42
+ "run_id": ("string", False, "run_id"),
43
+ }
44
+ TASK_STATUSES = {"pending", "running", "succeeded", "failed", "cancelled"}
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class FilterPath:
49
+ root: str
50
+ segments: tuple[str, ...] = ()
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class Comparison:
55
+ path: FilterPath
56
+ operator: CompareOperator
57
+ value: Scalar
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Membership:
62
+ path: FilterPath
63
+ operator: MembershipOperator
64
+ values: tuple[Scalar, ...]
65
+ mode: Literal["candidate_set", "array_contains"]
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Presence:
70
+ path: FilterPath
71
+ exists: bool
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class BooleanExpression:
76
+ operator: Literal["and", "or"]
77
+ children: tuple[FilterNode, ...]
78
+
79
+
80
+ FilterNode: TypeAlias = Comparison | Membership | Presence | BooleanExpression
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class RuntimePath:
85
+ kind: Literal["fixed", "dynamic", "routes"]
86
+ value: Any
87
+ json_type: Any
88
+ declared_type: str | None
89
+ nullable: bool
90
+
91
+
92
+ def parse_filter(expression: str) -> FilterNode:
93
+ if any(0xD800 <= ord(character) <= 0xDFFF for character in expression):
94
+ raise invalid("invalid_filter", "Filter contains a lone Unicode surrogate.")
95
+ if len(expression.encode("utf-8")) > MAX_FILTER_BYTES:
96
+ raise invalid(
97
+ "filter_too_large",
98
+ "Filter exceeds the 8192-byte limit.",
99
+ max_bytes=MAX_FILTER_BYTES,
100
+ )
101
+ if not expression.strip():
102
+ raise invalid("invalid_filter", "Filter must not be empty.")
103
+ try:
104
+ parsed = ast.parse(expression, mode="eval")
105
+ return _parse_expression(parsed.body)
106
+ except (SyntaxError, ValueError, RecursionError) as error:
107
+ details: dict[str, object] = {}
108
+ if isinstance(error, SyntaxError) and error.offset is not None:
109
+ details["column"] = error.offset
110
+ raise invalid("invalid_filter", "Filter syntax is invalid.", **details) from error
111
+
112
+
113
+ def compile_filter(expression: str) -> ColumnElement[bool]:
114
+ try:
115
+ return compile_filter_node(parse_filter(expression))
116
+ except RecursionError as error:
117
+ raise invalid("invalid_filter", "Filter expression is too deeply nested.") from error
118
+
119
+
120
+ def compile_filter_node(node: FilterNode) -> ColumnElement[bool]:
121
+ if isinstance(node, BooleanExpression):
122
+ compiled = [compile_filter_node(child) for child in node.children]
123
+ return and_(*compiled) if node.operator == "and" else or_(*compiled)
124
+ if isinstance(node, Presence):
125
+ runtime = _runtime_path(node.path)
126
+ if runtime.kind in {"fixed", "routes"}:
127
+ return true() if node.exists else false()
128
+ present = runtime.json_type.is_not(None)
129
+ return cast(ColumnElement[bool], present if node.exists else not_(present))
130
+ if isinstance(node, Comparison):
131
+ return _compile_comparison(node)
132
+ if isinstance(node, Membership):
133
+ return _compile_membership(node)
134
+ raise AssertionError(f"Unknown filter node: {node!r}")
135
+
136
+
137
+ def _parse_expression(node: ast.expr) -> FilterNode:
138
+ if isinstance(node, ast.BoolOp):
139
+ if isinstance(node.op, ast.And):
140
+ bool_operator: Literal["and", "or"] = "and"
141
+ elif isinstance(node.op, ast.Or):
142
+ bool_operator = "or"
143
+ else:
144
+ raise _filter_error(node, "Only 'and' and 'or' Boolean operators are supported.")
145
+ return BooleanExpression(
146
+ bool_operator,
147
+ tuple(_parse_expression(value) for value in node.values),
148
+ )
149
+
150
+ if isinstance(node, ast.Call):
151
+ if (
152
+ not isinstance(node.func, ast.Name)
153
+ or node.func.id not in {"exists", "missing"}
154
+ or len(node.args) != 1
155
+ or node.keywords
156
+ ):
157
+ raise _filter_error(node, "Only exists(path) and missing(path) are supported.")
158
+ return Presence(_parse_path(node.args[0]), exists=node.func.id == "exists")
159
+
160
+ if not isinstance(node, ast.Compare) or len(node.ops) != 1 or len(node.comparators) != 1:
161
+ raise _filter_error(node, "A filter predicate must be one comparison or membership test.")
162
+
163
+ left = node.left
164
+ right = node.comparators[0]
165
+ operator_node = node.ops[0]
166
+ if isinstance(operator_node, (ast.In, ast.NotIn)):
167
+ membership_operator: MembershipOperator = (
168
+ "not in" if isinstance(operator_node, ast.NotIn) else "in"
169
+ )
170
+ left_path = _try_parse_path(left)
171
+ right_path = _try_parse_path(right)
172
+ if left_path is not None and isinstance(right, ast.List):
173
+ return Membership(
174
+ left_path,
175
+ membership_operator,
176
+ tuple(_parse_scalar(element) for element in right.elts),
177
+ "candidate_set",
178
+ )
179
+ if right_path is not None:
180
+ return Membership(
181
+ right_path,
182
+ membership_operator,
183
+ (_parse_scalar(left),),
184
+ "array_contains",
185
+ )
186
+ raise _filter_error(
187
+ node,
188
+ "Membership must be 'path in [values]' or 'value in path'.",
189
+ )
190
+
191
+ comparison_operator = _comparison_operator(operator_node, node)
192
+ left_path = _try_parse_path(left)
193
+ right_path = _try_parse_path(right)
194
+ if left_path is not None and right_path is None:
195
+ return Comparison(left_path, comparison_operator, _parse_scalar(right))
196
+ if right_path is not None and left_path is None:
197
+ return Comparison(
198
+ right_path,
199
+ _reverse_operator(comparison_operator),
200
+ _parse_scalar(left),
201
+ )
202
+ raise _filter_error(node, "A comparison must contain exactly one path and one scalar literal.")
203
+
204
+
205
+ def _try_parse_path(node: ast.expr) -> FilterPath | None:
206
+ if not isinstance(node, (ast.Name, ast.Attribute)):
207
+ return None
208
+ return _parse_path(node)
209
+
210
+
211
+ def _parse_path(node: ast.expr) -> FilterPath:
212
+ segments: list[str] = []
213
+ current = node
214
+ while isinstance(current, ast.Attribute):
215
+ segments.append(current.attr)
216
+ current = current.value
217
+ if not isinstance(current, ast.Name):
218
+ raise _filter_error(node, "Filter paths use dot-separated names only.")
219
+ root = current.id
220
+ segments.reverse()
221
+
222
+ if root in BUILTIN_TYPES or root == "routes":
223
+ if segments:
224
+ raise _filter_error(node, f"'{root}' does not have nested fields.")
225
+ return FilterPath(root)
226
+ if root not in {"args", "metadata", "result", "last_error"} or not segments:
227
+ raise _filter_error(node, f"Unsupported filter path '{_display_path(root, segments)}'.")
228
+ for segment in segments:
229
+ if not PATH_SEGMENT_RE.fullmatch(segment):
230
+ raise _filter_error(
231
+ node,
232
+ "Path segments must match [A-Za-z_][A-Za-z0-9_]*.",
233
+ )
234
+ if root == "last_error" and (len(segments) != 1 or segments[0] not in LAST_ERROR_TYPES):
235
+ raise _filter_error(node, f"Unsupported filter path '{_display_path(root, segments)}'.")
236
+ return FilterPath(root, tuple(segments))
237
+
238
+
239
+ def _parse_scalar(node: ast.expr) -> Scalar:
240
+ sign = 1
241
+ literal = node
242
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
243
+ sign = -1
244
+ literal = node.operand
245
+ if not isinstance(literal, ast.Constant):
246
+ raise _filter_error(node, "Filter values must be scalar JSON literals.")
247
+ value = literal.value
248
+ if value is None or isinstance(value, (bool, str)):
249
+ if sign == -1:
250
+ raise _filter_error(node, "Only numeric literals may have a minus sign.")
251
+ if isinstance(value, str) and any(
252
+ 0xD800 <= ord(character) <= 0xDFFF for character in value
253
+ ):
254
+ raise _filter_error(node, "String literals must contain Unicode scalar values.")
255
+ return value
256
+ if isinstance(value, int):
257
+ value *= sign
258
+ if not INT64_MIN <= value <= INT64_MAX:
259
+ raise _filter_error(node, "Integer literal is outside the signed 64-bit range.")
260
+ return value
261
+ if isinstance(value, float):
262
+ value *= sign
263
+ if not math.isfinite(value):
264
+ raise _filter_error(node, "Number literal must be finite.")
265
+ return value
266
+ raise _filter_error(node, "Filter values must be scalar JSON literals.")
267
+
268
+
269
+ def _comparison_operator(node: ast.cmpop, parent: ast.AST) -> CompareOperator:
270
+ operators: list[tuple[type[ast.cmpop], CompareOperator]] = [
271
+ (ast.Eq, "=="),
272
+ (ast.NotEq, "!="),
273
+ (ast.Lt, "<"),
274
+ (ast.LtE, "<="),
275
+ (ast.Gt, ">"),
276
+ (ast.GtE, ">="),
277
+ ]
278
+ for node_type, name in operators:
279
+ if isinstance(node, node_type):
280
+ return name
281
+ raise _filter_error(parent, "Unsupported comparison operator.")
282
+
283
+
284
+ def _reverse_operator(operator: CompareOperator) -> CompareOperator:
285
+ reversed_operators: dict[CompareOperator, CompareOperator] = {
286
+ "==": "==",
287
+ "!=": "!=",
288
+ "<": ">",
289
+ "<=": ">=",
290
+ ">": "<",
291
+ ">=": "<=",
292
+ }
293
+ return reversed_operators[operator]
294
+
295
+
296
+ def _runtime_path(path: FilterPath) -> RuntimePath:
297
+ if path.root == "routes":
298
+ return RuntimePath("routes", None, None, "array", False)
299
+ if path.root in BUILTIN_TYPES:
300
+ declared_type, nullable = BUILTIN_TYPES[path.root]
301
+ columns = {
302
+ "id": TaskRow.task_id,
303
+ "status": TaskRow.status,
304
+ "name": TaskRow.name,
305
+ "priority": TaskRow.priority,
306
+ "attempt": TaskRow.attempt,
307
+ "max_attempts": TaskRow.max_attempts,
308
+ "last_route": TaskRow.last_route,
309
+ "created_at": TaskRow.created_at_us,
310
+ "updated_at": TaskRow.updated_at_us,
311
+ "started_at": TaskRow.started_at_us,
312
+ "finished_at": TaskRow.finished_at_us,
313
+ }
314
+ return RuntimePath("fixed", columns[path.root], None, declared_type, nullable)
315
+
316
+ json_column = {
317
+ "args": TaskRow.args_json,
318
+ "metadata": TaskRow.metadata_json,
319
+ "result": TaskRow.result_json,
320
+ "last_error": TaskRow.last_error_json,
321
+ }[path.root]
322
+ segments = list(path.segments)
323
+ dynamic_declared_type: str | None = None
324
+ nullable = True
325
+ if path.root == "last_error":
326
+ dynamic_declared_type, nullable, stored_name = LAST_ERROR_TYPES[segments[0]]
327
+ segments[0] = stored_name
328
+ json_path = "$" + "".join(f'."{segment}"' for segment in segments)
329
+ return RuntimePath(
330
+ "dynamic",
331
+ func.json_extract(json_column, json_path),
332
+ func.json_type(json_column, json_path),
333
+ dynamic_declared_type,
334
+ nullable,
335
+ )
336
+
337
+
338
+ def _compile_comparison(node: Comparison) -> ColumnElement[bool]:
339
+ runtime = _runtime_path(node.path)
340
+ if runtime.kind == "routes":
341
+ raise _invalid_filter("Routes support membership tests only.")
342
+ if runtime.kind == "fixed" or runtime.declared_type is not None:
343
+ value = _normalize_declared_literal(runtime, node.value)
344
+ return _declared_comparison(runtime, node.operator, value)
345
+ return _dynamic_comparison(runtime, node.operator, node.value)
346
+
347
+
348
+ def _normalize_declared_literal(runtime: RuntimePath, value: Scalar) -> Scalar | int:
349
+ declared_type = runtime.declared_type
350
+ if value is None:
351
+ if runtime.nullable:
352
+ return None
353
+ raise _invalid_filter("A non-nullable field cannot be compared with None.")
354
+ if declared_type == "number":
355
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
356
+ raise _invalid_filter("This field requires a numeric literal.")
357
+ return value
358
+ if declared_type in {"string", "status"}:
359
+ if not isinstance(value, str):
360
+ raise _invalid_filter("This field requires a string literal.")
361
+ if declared_type == "status" and value not in TASK_STATUSES:
362
+ raise _invalid_filter("Status literal is not a valid Task status.")
363
+ return value
364
+ if declared_type == "timestamp":
365
+ if not isinstance(value, str):
366
+ raise _invalid_filter("Timestamp fields require an RFC 3339 string literal.")
367
+ return _timestamp_us(value)
368
+ raise AssertionError(f"Unknown declared filter type: {declared_type}")
369
+
370
+
371
+ def _declared_comparison(
372
+ runtime: RuntimePath,
373
+ operator: CompareOperator,
374
+ value: Scalar | int,
375
+ ) -> ColumnElement[bool]:
376
+ column = runtime.value
377
+ if runtime.kind == "dynamic":
378
+ if operator in {"==", "!="}:
379
+ equal = _json_equal(column, runtime.json_type, value)
380
+ if operator == "==":
381
+ return equal
382
+ return and_(runtime.json_type.is_not(None), not_(equal))
383
+ if value is None:
384
+ raise _invalid_filter("None supports only equality and inequality comparisons.")
385
+ expected_types = (
386
+ ["integer", "real"] if runtime.declared_type in {"number", "timestamp"} else ["text"]
387
+ )
388
+ if operator == "<":
389
+ comparison = column < value
390
+ elif operator == "<=":
391
+ comparison = column <= value
392
+ elif operator == ">":
393
+ comparison = column > value
394
+ else:
395
+ comparison = column >= value
396
+ return and_(runtime.json_type.in_(expected_types), comparison)
397
+
398
+ if value is None:
399
+ if operator == "==":
400
+ return cast(ColumnElement[bool], column.is_(None))
401
+ if operator == "!=":
402
+ return cast(ColumnElement[bool], column.is_not(None))
403
+ raise _invalid_filter("None supports only equality and inequality comparisons.")
404
+ if operator == "==":
405
+ comparison = column == value
406
+ elif operator == "!=":
407
+ comparison = or_(column.is_(None), column != value) if runtime.nullable else column != value
408
+ elif operator == "<":
409
+ comparison = column < value
410
+ elif operator == "<=":
411
+ comparison = column <= value
412
+ elif operator == ">":
413
+ comparison = column > value
414
+ else:
415
+ comparison = column >= value
416
+ return cast(ColumnElement[bool], comparison)
417
+
418
+
419
+ def _dynamic_comparison(
420
+ runtime: RuntimePath,
421
+ operator: CompareOperator,
422
+ value: Scalar,
423
+ ) -> ColumnElement[bool]:
424
+ if operator in {"==", "!="}:
425
+ equal = _json_equal(runtime.value, runtime.json_type, value)
426
+ if operator == "==":
427
+ return equal
428
+ return and_(runtime.json_type.is_not(None), not_(equal))
429
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
430
+ raise _invalid_filter("Ordering a dynamic JSON path requires a numeric literal.")
431
+ numeric = runtime.json_type.in_(["integer", "real"])
432
+ comparisons = {
433
+ "<": runtime.value < value,
434
+ "<=": runtime.value <= value,
435
+ ">": runtime.value > value,
436
+ ">=": runtime.value >= value,
437
+ }
438
+ return and_(numeric, comparisons[operator])
439
+
440
+
441
+ def _compile_membership(node: Membership) -> ColumnElement[bool]:
442
+ runtime = _runtime_path(node.path)
443
+ if node.mode == "candidate_set":
444
+ if runtime.kind == "routes":
445
+ raise _invalid_filter("Use a scalar literal on the left to test route membership.")
446
+ if runtime.kind == "fixed" or runtime.declared_type is not None:
447
+ normalized = [_normalize_declared_literal(runtime, value) for value in node.values]
448
+ matches = (
449
+ or_(*[_declared_comparison(runtime, "==", value) for value in normalized])
450
+ if normalized
451
+ else false()
452
+ )
453
+ present = true() if runtime.kind == "fixed" else runtime.json_type.is_not(None)
454
+ else:
455
+ comparisons = [
456
+ _json_equal(runtime.value, runtime.json_type, value) for value in node.values
457
+ ]
458
+ matches = or_(*comparisons) if comparisons else false()
459
+ present = runtime.json_type.in_(["null", "true", "false", "integer", "real", "text"])
460
+ return and_(present, not_(matches) if node.operator == "not in" else matches)
461
+
462
+ value = node.values[0]
463
+ if runtime.kind == "fixed":
464
+ raise _invalid_filter("The right side of array containment must be an array-valued path.")
465
+ if runtime.kind == "routes":
466
+ if not isinstance(value, str):
467
+ raise _invalid_filter("Route membership requires a string literal.")
468
+ match = _route_exists(value)
469
+ return not_(match) if node.operator == "not in" else match
470
+
471
+ each = func.json_each(runtime.value).table_valued("key", "value", "type").alias()
472
+ match = (
473
+ select(1).select_from(each).where(_json_equal(each.c.value, each.c.type, value)).exists()
474
+ )
475
+ is_array = runtime.json_type == "array"
476
+ return and_(is_array, not_(match) if node.operator == "not in" else match)
477
+
478
+
479
+ def _route_exists(route: str) -> ColumnElement[bool]:
480
+ return (
481
+ select(1)
482
+ .select_from(TaskRouteRow)
483
+ .where(
484
+ TaskRouteRow.queue_name == TaskRow.queue_name,
485
+ TaskRouteRow.task_id == TaskRow.task_id,
486
+ TaskRouteRow.route == route,
487
+ )
488
+ .exists()
489
+ )
490
+
491
+
492
+ def _json_equal(value_expression: Any, type_expression: Any, value: Scalar) -> ColumnElement[bool]:
493
+ if value is None:
494
+ return cast(ColumnElement[bool], type_expression == "null")
495
+ if isinstance(value, bool):
496
+ return cast(ColumnElement[bool], type_expression == ("true" if value else "false"))
497
+ if isinstance(value, (int, float)):
498
+ return and_(type_expression.in_(["integer", "real"]), value_expression == value)
499
+ return and_(type_expression == "text", value_expression == value)
500
+
501
+
502
+ def _timestamp_us(value: str) -> int:
503
+ if not RFC3339_RE.fullmatch(value):
504
+ raise _invalid_filter("Timestamp literal must be a strict RFC 3339 string.")
505
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
506
+ try:
507
+ parsed = datetime.fromisoformat(normalized)
508
+ except ValueError as error:
509
+ raise _invalid_filter("Timestamp literal must be a valid RFC 3339 time.") from error
510
+ if parsed.utcoffset() is None:
511
+ raise _invalid_filter("Timestamp literal must include a UTC offset.")
512
+ delta = parsed.astimezone(UTC) - datetime(1970, 1, 1, tzinfo=UTC)
513
+ return (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
514
+
515
+
516
+ def _filter_error(node: ast.AST, message: str) -> DomainError:
517
+ details: dict[str, object] = {}
518
+ if hasattr(node, "col_offset"):
519
+ details["column"] = node.col_offset + 1
520
+ return invalid("invalid_filter", message, **details)
521
+
522
+
523
+ def _invalid_filter(message: str) -> DomainError:
524
+ return invalid("invalid_filter", message)
525
+
526
+
527
+ def _display_path(root: str, segments: list[str]) -> str:
528
+ return ".".join([root, *segments])