197 lines
8.3 KiB
Python
197 lines
8.3 KiB
Python
"""Small, explicit running query language with source-located diagnostics."""
|
||
|
||
import difflib
|
||
import json
|
||
import math
|
||
import re
|
||
from datetime import date
|
||
|
||
NUMBER = r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
|
||
UNITS = {
|
||
"distance": {"km": 1, "m": 0.001},
|
||
"duration": {"h": 1, "min": 1 / 60, "s": 1 / 3600},
|
||
"pace": {"/km": 1},
|
||
"heart_rate": {"bpm": 1},
|
||
"cadence": {"spm": 1},
|
||
"power": {"W": 1},
|
||
"elevation": {"m": 1},
|
||
"ground_contact": {"ms": 1},
|
||
"stride_length": {"m": 1},
|
||
}
|
||
|
||
|
||
class QueryError(ValueError):
|
||
def __init__(self, message, line=1):
|
||
super().__init__(message)
|
||
self.line = line
|
||
|
||
|
||
def parse_dsl(text, metrics, defaults, units=None):
|
||
units = units or UNITS
|
||
if not isinstance(text, str) or len(text) > 4000:
|
||
raise QueryError("Keep the query under 4,000 characters.")
|
||
q = dict(defaults)
|
||
q["filters"] = []
|
||
q["extra_metrics"] = []
|
||
seen = set()
|
||
locations = {}
|
||
|
||
def claim(key, line):
|
||
if key in seen:
|
||
raise QueryError(f"Repeated {key} clause. Keep one per query.", line)
|
||
seen.add(key)
|
||
locations[key] = line
|
||
|
||
def metric(key, line):
|
||
if key not in metrics:
|
||
suggestion = difflib.get_close_matches(key, metrics, n=1)
|
||
hint = (
|
||
f" Did you mean '{suggestion[0]}'?"
|
||
if suggestion
|
||
else " Open the metric reference for supported names."
|
||
)
|
||
raise QueryError(f"Unknown metric '{key}'.{hint}", line)
|
||
|
||
def amount(key, raw, unit, line):
|
||
if unit not in units[key]:
|
||
raise QueryError(f"Use {' or '.join(units[key])} for {key}.", line)
|
||
if key == "pace" and ":" in raw:
|
||
minutes, seconds = raw.split(":")
|
||
if not minutes.isdecimal() or not seconds.isdecimal() or int(seconds) >= 60:
|
||
raise QueryError("Write pace like 5:30 /km (seconds from 00 to 59).", line)
|
||
value = int(minutes) * 60 + int(seconds)
|
||
else:
|
||
try:
|
||
value = float(raw)
|
||
except ValueError:
|
||
raise QueryError("Use a numeric value with its unit.", line) from None
|
||
if not math.isfinite(value) or not -1000000 <= value <= 1000000:
|
||
raise QueryError(
|
||
"Filter values must be finite and between -1,000,000 and 1,000,000.", line
|
||
)
|
||
if (
|
||
key in {"distance", "heart_rate", "analysis_raw_distance", "analysis_raw_heart_rate"}
|
||
and value < 0
|
||
):
|
||
raise QueryError("Distance and heart rate cannot be negative.", line)
|
||
return value * units[key][unit]
|
||
|
||
def condition(key, op, value, line):
|
||
legacy = {
|
||
("distance", ">="): "minimum",
|
||
("distance", "<="): "maximum",
|
||
("heart_rate", ">="): "hr_min",
|
||
("heart_rate", "<="): "hr_max",
|
||
}.get((key, op))
|
||
if legacy:
|
||
if value > 1000:
|
||
raise QueryError("Distance and heart rate bounds must be at most 1,000.", line)
|
||
old = q[legacy]
|
||
q[legacy] = value if old is None else max(old, value) if op == ">=" else min(old, value)
|
||
locations[legacy] = line
|
||
else:
|
||
q["filters"].append({"field": key, "op": op, "value": value})
|
||
|
||
for line_number, source in enumerate(text.splitlines(), 1):
|
||
line = source.strip()
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
if line == "runs":
|
||
claim("runs", line_number)
|
||
elif match := re.fullmatch(r"during last (\d+) (days?|weeks?|months?)", line):
|
||
claim("during", line_number)
|
||
count, unit = int(match[1]), match[2].rstrip("s")
|
||
limit = {"day": 732, "week": 104, "month": 24}[unit]
|
||
if not 1 <= count <= limit:
|
||
raise QueryError(f"Use 1–{limit} {unit}s.", line_number)
|
||
if unit == "week":
|
||
q["weeks"] = count
|
||
else:
|
||
q["days" if unit == "day" else "months"] = count
|
||
elif match := re.fullmatch(r"during (\d{4}-\d{2}-\d{2}) to (\d{4}-\d{2}-\d{2})", line):
|
||
claim("during", line_number)
|
||
try:
|
||
q["start"], q["end"] = date.fromisoformat(match[1]), date.fromisoformat(match[2])
|
||
if not 0 <= (q["end"] - q["start"]).days < 732:
|
||
raise ValueError
|
||
except ValueError:
|
||
raise QueryError(
|
||
"Use valid dates in order, covering at most 732 days.", line_number
|
||
) from None
|
||
elif match := re.fullmatch(r"ending (\d{4}-\d{2}-\d{2})", line):
|
||
claim("ending", line_number)
|
||
if q["start"]:
|
||
raise QueryError(
|
||
"An explicit date range already supplies its end date.", line_number
|
||
)
|
||
try:
|
||
q["end"] = date.fromisoformat(match[1])
|
||
except ValueError:
|
||
raise QueryError("Use a valid end date, YYYY-MM-DD.", line_number) from None
|
||
elif line.startswith("summarize "):
|
||
claim("summarize", line_number)
|
||
keys = [key.strip() for key in line[10:].split(",")]
|
||
if not 1 <= len(keys) <= 6 or len(set(keys)) != len(keys):
|
||
raise QueryError("Choose 1–6 different metrics, separated by commas.", line_number)
|
||
for key in keys:
|
||
metric(key, line_number)
|
||
q["metric"] = keys[0]
|
||
q["secondary"] = keys[1] if len(keys) > 1 else "none"
|
||
q["extra_metrics"] = keys[2:]
|
||
elif match := re.fullmatch(r"by (day|week|month)", line):
|
||
claim("by", line_number)
|
||
q["group"] = match[1]
|
||
elif match := re.fullmatch(r"compare (previous period|none)", line):
|
||
claim("compare", line_number)
|
||
q["compare"] = match[1] == "previous period"
|
||
elif match := re.fullmatch(r"sort by (\w+) (asc|desc)", line):
|
||
claim("sort", line_number)
|
||
if match[1] != "date":
|
||
metric(match[1], line_number)
|
||
q["sort"] = [match[1], match[2]]
|
||
elif match := re.fullmatch(r'(?:where|and) title contains (".*")', line):
|
||
try:
|
||
title = json.loads(match[1])
|
||
if not isinstance(title, str) or not 1 <= len(title) <= 160:
|
||
raise ValueError
|
||
except ValueError:
|
||
raise QueryError(
|
||
'Use a title fragment in double quotes, e.g. "River".', line_number
|
||
) from None
|
||
q["filters"].append({"field": "title", "op": "contains", "value": title})
|
||
elif match := re.fullmatch(r"(?:where|and) (has|missing) (\w+)", line):
|
||
metric(match[2], line_number)
|
||
q["filters"].append({"field": match[2], "op": match[1]})
|
||
elif match := re.fullmatch(r"(?:where|and) (\w+) between (\S+) and (\S+) (\S+)", line):
|
||
key, low, high, unit = match.groups()
|
||
metric(key, line_number)
|
||
lower, upper = amount(key, low, unit, line_number), amount(key, high, unit, line_number)
|
||
if lower > upper:
|
||
raise QueryError("The lower bound must not exceed the upper bound.", line_number)
|
||
condition(key, ">=", lower, line_number)
|
||
condition(key, "<=", upper, line_number)
|
||
elif match := re.fullmatch(r"(?:where|and) (\w+) (>=|<=|>|<|=|!=) (\S+) (\S+)", line):
|
||
key, op, raw, unit = match.groups()
|
||
metric(key, line_number)
|
||
value = amount(key, raw, unit, line_number)
|
||
condition(key, op, value, line_number)
|
||
else:
|
||
raise QueryError(
|
||
"Unrecognised clause. Use autocomplete (Ctrl+Space) or a starting query.",
|
||
line_number,
|
||
)
|
||
if len(q["filters"]) > 20:
|
||
raise QueryError("Use at most 20 filter conditions.", line_number)
|
||
if not {"runs", "summarize"} <= seen:
|
||
raise QueryError("Include 'runs' and a 'summarize' clause.")
|
||
if "ending" in seen and q["start"]:
|
||
raise QueryError(
|
||
"Use either an explicit date range or an ending clause.", locations["during"]
|
||
)
|
||
for low, high in (("minimum", "maximum"), ("hr_min", "hr_max")):
|
||
if q[low] is not None and q[high] is not None and q[low] > q[high]:
|
||
raise QueryError(
|
||
"A filter's minimum cannot exceed its maximum.", locations.get(high, 1)
|
||
)
|
||
return q
|