Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Local property-based query helpers for cached Redmine ticket JSON.
Small expression language (intentionally not a full DSL):
path == value
path != value
path IS NULL / path IS NOT NULL
path.includes("substring")
path.since("20 minutes") # relative window for date-ish fields
path == other.path
expr AND expr
expr OR expr
( expr )
Paths use dot notation into issue JSON, e.g.:
subject
status.name
helpdesk_ticket.contact.email
assigned_to.name
When a dict is compared to a string, its ``name`` field is used if present.
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
class QueryError(ValueError):
"""Invalid property query expression."""
_TOKEN_RE = re.compile(
r"""
\s*
(
(?:AND|OR|IS|NOT|NULL)\b
| &&
| \|\|
| \.
| \(
| \)
| ==
| !=
| "[^"\\]*(?:\\.[^"\\]*)*"
| '[^'\\]*(?:\\.[^'\\]*)*'
| [A-Za-z_][A-Za-z0-9_]*
| \d+(?:\.\d+)?
)
""",
re.VERBOSE | re.IGNORECASE,
)
_RELATIVE_RE = re.compile(
r"^(?P<n>\d+)\s*(?P<unit>d|day|days|h|hour|hours|m|min|mins|minute|minutes|w|week|weeks)\s*(?:ago)?$",
re.IGNORECASE,
)
def tokenize(expression: str) -> list[str]:
text = expression.strip()
if not text:
raise QueryError("Empty query expression.")
tokens: list[str] = []
pos = 0
while pos < len(text):
match = _TOKEN_RE.match(text, pos)
if not match:
raise QueryError(f"Unexpected input near: {text[pos:pos + 40]!r}")
token = match.group(1)
tokens.append(token)
pos = match.end()
if pos != len(text):
raise QueryError(f"Unparsed trailing input: {text[pos:]!r}")
return tokens
def _unquote(token: str) -> str:
if len(token) >= 2 and token[0] == token[-1] and token[0] in "\"'":
body = token[1:-1]
return bytes(body, "utf-8").decode("unicode_escape") if "\\" in body else body
return token
def parse_relative_delta(value: str) -> timedelta:
match = _RELATIVE_RE.fullmatch(value.strip())
if not match:
raise QueryError(
f"Invalid relative time {value!r}; use forms like '20 minutes', '2h', '5 days'."
)
amount = int(match.group("n"))
unit = match.group("unit").lower()
if unit.startswith("w"):
return timedelta(weeks=amount)
if unit.startswith("d"):
return timedelta(days=amount)
if unit.startswith("h"):
return timedelta(hours=amount)
return timedelta(minutes=amount)
def parse_dt_loose(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
dt = value
else:
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
if "T" not in text and len(text) == 10:
text = text + "T00:00:00+00:00"
try:
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def get_path(issue: dict[str, Any], path: str) -> Any:
current: Any = issue
for part in path.split("."):
if current is None:
return None
if isinstance(current, dict):
current = current.get(part)
continue
return None
return current
def display_value(value: Any) -> Any:
"""Coerce nested Redmine objects to a comparable scalar when useful."""
if isinstance(value, dict):
if value.get("name") is not None:
return value.get("name")
if value.get("email") is not None:
return value.get("email")
if value.get("id") is not None:
return value.get("id")
return value
def values_equal(left: Any, right: Any) -> bool:
left_d = display_value(left)
right_d = display_value(right)
if left_d is None and right_d is None:
return True
if left_d is None or right_d is None:
return False
if isinstance(left_d, (int, float)) and isinstance(right_d, (int, float)):
return left_d == right_d
return str(left_d).lower() == str(right_d).lower()
def value_includes(haystack: Any, needle: str) -> bool:
if haystack is None:
return False
text = str(display_value(haystack))
return needle.lower() in text.lower()
class _Parser:
def __init__(self, tokens: list[str]):
self.tokens = tokens
self.i = 0
def _peek(self) -> str | None:
if self.i >= len(self.tokens):
return None
return self.tokens[self.i]
def _advance(self) -> str:
token = self._peek()
if token is None:
raise QueryError("Unexpected end of expression.")
self.i += 1
return token
def _accept_keyword(self, *keywords: str) -> str | None:
token = self._peek()
if token is None:
return None
upper = token.upper()
for keyword in keywords:
if upper == keyword.upper():
self.i += 1
return token
return None
def parse(self) -> Callable[[dict[str, Any]], bool]:
node = self._parse_or()
if self._peek() is not None:
raise QueryError(f"Unexpected token {self._peek()!r}")
return node
def _parse_or(self) -> Callable[[dict[str, Any]], bool]:
left = self._parse_and()
while True:
if self._accept_keyword("OR") or self._peek() == "||":
if self._peek() == "||":
self._advance()
right = self._parse_and()
prev_left, prev_right = left, right
left = lambda issue, a=prev_left, b=prev_right: a(issue) or b(issue)
continue
break
return left
def _parse_and(self) -> Callable[[dict[str, Any]], bool]:
left = self._parse_primary()
while True:
if self._accept_keyword("AND") or self._peek() == "&&":
if self._peek() == "&&":
self._advance()
right = self._parse_primary()
prev_left, prev_right = left, right
left = lambda issue, a=prev_left, b=prev_right: a(issue) and b(issue)
continue
break
return left
def _parse_primary(self) -> Callable[[dict[str, Any]], bool]:
if self._peek() == "(":
self._advance()
node = self._parse_or()
if self._peek() != ")":
raise QueryError("Expected ')'")
self._advance()
return node
return self._parse_comparison()
def _parse_path(self) -> str:
parts: list[str] = []
token = self._advance()
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token):
raise QueryError(f"Expected path starting identifier, got {token!r}")
parts.append(token)
while self._peek() == ".":
# Lookahead: path.includes( / path.since( → leave method name for comparison parser
# tokens: ['.', 'includes', '(']
if self.i + 2 < len(self.tokens):
method_name = self.tokens[self.i + 1]
after = self.tokens[self.i + 2]
if method_name in {"includes", "since"} and after == "(":
break
self._advance() # consume '.'
nxt = self._peek()
if nxt is None:
raise QueryError("Dangling '.' in path")
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", nxt):
raise QueryError(f"Invalid path segment {nxt!r}")
parts.append(self._advance())
return ".".join(parts)
def _parse_value_or_path(self) -> tuple[str, Any]:
"""Return ('path', path_str) or ('lit', literal)."""
token = self._peek()
if token is None:
raise QueryError("Expected value or path")
if token[0] in "\"'" or re.fullmatch(r"\d+(?:\.\d+)?", token):
self._advance()
if token[0] in "\"'":
return "lit", _unquote(token)
if "." in token:
return "lit", float(token)
return "lit", int(token)
path = self._parse_path()
return "path", path
def _parse_comparison(self) -> Callable[[dict[str, Any]], bool]:
left_path = self._parse_path()
# method form: path.includes("x") / path.since("20 minutes")
method = None
base_path = left_path
if self._peek() == ".":
# path was stopped before method name
self._advance()
method = self._advance()
if method not in {"includes", "since"}:
raise QueryError(f"Unknown method .{method}; supported: includes, since")
base_path = left_path
if method is not None:
if self._advance() != "(":
raise QueryError(f"Expected '(' after .{method}")
arg_tok = self._advance()
if not arg_tok or arg_tok[0] not in "\"'":
raise QueryError(f".{method}() argument must be a string literal")
arg = _unquote(arg_tok)
if self._advance() != ")":
raise QueryError(f"Expected ')' after .{method} argument")
if method == "includes":
return lambda issue, p=base_path, a=arg: value_includes(get_path(issue, p), a)
delta = parse_relative_delta(arg)
def since_pred(issue: dict[str, Any], p=base_path, d=delta) -> bool:
raw = get_path(issue, p)
dt = parse_dt_loose(raw)
if dt is None:
return False
now = datetime.now(timezone.utc)
return dt >= (now - d)
return since_pred
# IS NULL / IS NOT NULL
if self._accept_keyword("IS"):
not_null = bool(self._accept_keyword("NOT"))
if not self._accept_keyword("NULL"):
raise QueryError("Expected NULL after IS [NOT]")
if not_null:
return lambda issue, p=left_path: get_path(issue, p) is not None and display_value(get_path(issue, p)) not in (None, "")
return lambda issue, p=left_path: get_path(issue, p) is None or display_value(get_path(issue, p)) in (None, "")
op = self._peek()
if op not in {"==", "!="}:
raise QueryError(f"Expected comparison operator after path {left_path!r}, got {op!r}")
self._advance()
kind, right = self._parse_value_or_path()
if kind == "lit":
if op == "==":
return lambda issue, p=left_path, r=right: values_equal(get_path(issue, p), r)
return lambda issue, p=left_path, r=right: not values_equal(get_path(issue, p), r)
# path == path
if op == "==":
return lambda issue, a=left_path, b=right: values_equal(get_path(issue, a), get_path(issue, b))
return lambda issue, a=left_path, b=right: not values_equal(get_path(issue, a), get_path(issue, b))
def compile_query(expression: str) -> Callable[[dict[str, Any]], bool]:
tokens = tokenize(expression)
# Normalize parentheses tokens already fine
return _Parser(tokens).parse()
def issue_matches(issue: dict[str, Any], expression: str) -> bool:
return compile_query(expression)(issue)
def filter_by_query(
issues: list[dict[str, Any]],
expression: str | None = None,
expressions: list[str] | None = None,
) -> list[dict[str, Any]]:
preds: list[Callable[[dict[str, Any]], bool]] = []
if expression:
preds.append(compile_query(expression))
for expr in expressions or []:
preds.append(compile_query(expr))
if not preds:
return list(issues)
out: list[dict[str, Any]] = []
for issue in issues:
if all(pred(issue) for pred in preds):
out.append(issue)
return out