Organized
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user