#!/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\d+)\s*(?Pd|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