Initial production analytics foundation

This commit is contained in:
2026-09-04 05:34:52 +02:00
commit f017d187eb
30 changed files with 1219 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
"""Command-line entry point for safe, read-only API exploration."""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections.abc import Sequence
from pathlib import Path
from production_analytics.enlyze.exploration import (
ExplorationClient,
ExplorationError,
ExplorationSettings,
load_secret_file,
)
from production_analytics.enlyze.sanitize import sanitize
def _query_item(value: str) -> tuple[str, str]:
if "=" not in value:
raise argparse.ArgumentTypeError("query parameters must use KEY=VALUE format")
key, item_value = value.split("=", 1)
if not key:
raise argparse.ArgumentTypeError("query parameter key must not be empty")
return key, item_value
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="production-analytics")
namespaces = parser.add_subparsers(dest="namespace", required=True)
enlyze = namespaces.add_parser("enlyze", help="Read-only ENLYZE API exploration")
commands = enlyze.add_subparsers(dest="command", required=True)
raw = commands.add_parser("raw", help="GET an operator-verified relative API path")
raw.add_argument("path", help="Relative API path beginning with '/'")
raw.add_argument("--query", action="append", type=_query_item, default=[], metavar="KEY=VALUE")
raw.add_argument("--pretty", action="store_true", help="Pretty-print sanitized JSON")
raw.add_argument("--verbose", action="store_true", help="Show safe request progress on stderr")
raw.add_argument("--save-fixture", metavar="NAME", help="Save sanitized JSON under fixtures/enlyze/")
raw.add_argument("--save-raw", metavar="NAME", help="Save unmodified response under ignored data/raw/enlyze/")
raw.add_argument("--fixture-dir", type=Path, default=Path("fixtures/enlyze"), help=argparse.SUPPRESS)
raw.add_argument("--raw-dir", type=Path, default=Path("data/raw/enlyze"), help=argparse.SUPPRESS)
raw.add_argument(
"--secrets-file",
type=Path,
default=Path("secrets/enlyze.env"),
help="Local dotenv-style secret file (default: secrets/enlyze.env)",
)
timeseries = commands.add_parser("timeseries", help="Read-only POST /v2/timeseries exploration")
timeseries.add_argument("--machine", required=True, help="Machine UUID")
timeseries.add_argument("--start", required=True, help="ISO 8601 datetime with timezone")
timeseries.add_argument("--end", required=True, help="ISO 8601 datetime with timezone")
timeseries.add_argument("--variable", required=True, help="Variable UUID")
timeseries.add_argument("--resampling-interval", type=int, help="Seconds; schema range is 10..604800")
timeseries.add_argument(
"--resampling-method",
choices=["first", "last", "max", "min", "count", "sum", "avg", "median", "std", "q5", "q25", "q75", "q95"],
help="Optional schema-defined method for this variable",
)
timeseries.add_argument("--pretty", action="store_true", help="Pretty-print sanitized JSON")
timeseries.add_argument("--verbose", action="store_true", help="Show safe request progress on stderr")
timeseries.add_argument("--save-fixture", metavar="NAME", help="Save sanitized JSON under fixtures/enlyze/")
timeseries.add_argument("--save-raw", metavar="NAME", help="Save unmodified response under ignored data/raw/enlyze/")
timeseries.add_argument("--fixture-dir", type=Path, default=Path("fixtures/enlyze"), help=argparse.SUPPRESS)
timeseries.add_argument("--raw-dir", type=Path, default=Path("data/raw/enlyze"), help=argparse.SUPPRESS)
timeseries.add_argument(
"--secrets-file",
type=Path,
default=Path("secrets/enlyze.env"),
help="Local dotenv-style secret file (default: secrets/enlyze.env)",
)
return parser
def _write_fixture(directory: Path, name: str, payload: object) -> Path:
candidate = Path(name)
if candidate.name != name or candidate.suffix.lower() != ".json":
raise ExplorationError("Fixture name must be a plain filename ending in .json.")
directory.mkdir(parents=True, exist_ok=True)
destination = directory / candidate
destination.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return destination
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
if args.namespace != "enlyze" or args.command not in {"raw", "timeseries"}:
return 2
try:
environment = dict(os.environ)
environment.update(load_secret_file(args.secrets_file))
settings = ExplorationSettings.from_environment(environment)
if args.verbose:
authentication = "configured" if settings.api_key else "not configured"
operation = "GET" if args.command == "raw" else "POST"
target = args.path if args.command == "raw" else "/v2/timeseries"
print(
f"Requesting {operation} {target} (timeout={settings.timeout_seconds:g}s; authentication {authentication}).",
file=sys.stderr,
)
client = ExplorationClient(settings)
if args.command == "raw":
response = client.get(args.path, dict(args.query))
request = {"method": "GET", "path": response.path}
else:
if args.resampling_interval is not None and not 10 <= args.resampling_interval <= 604800:
raise ExplorationError("--resampling-interval must be between 10 and 604800 seconds.")
variable: dict[str, str] = {"uuid": args.variable}
if args.resampling_method:
variable["resampling_method"] = args.resampling_method
request_body: dict[str, object] = {
"machine": args.machine,
"start": args.start,
"end": args.end,
"variables": [variable],
}
if args.resampling_interval is not None:
request_body["resampling_interval"] = args.resampling_interval
response = client.post_json("/v2/timeseries", request_body)
request = {"method": "POST", "path": response.path, "body": request_body}
if args.verbose:
print(f"Received HTTP {response.status_code} for {response.path}.", file=sys.stderr)
raw_payload = {
"request": request,
"response": {
"status_code": response.status_code,
"headers": response.headers,
"body": response.body,
},
}
payload = sanitize(raw_payload)
indent = 2 if args.pretty or args.save_fixture else None
print(json.dumps(payload, indent=indent, sort_keys=True))
if args.save_fixture:
print(f"Sanitized fixture written to {_write_fixture(args.fixture_dir, args.save_fixture, payload)}")
if args.save_raw:
print(f"Raw response written to {_write_fixture(args.raw_dir, args.save_raw, raw_payload)}")
except ExplorationError as error:
print(f"ENLYZE exploration error: {error}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())