366 lines
12 KiB
Python
366 lines
12 KiB
Python
#!/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
|