"""Command-line entry point for exploration and production polling.""" 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)", ) run = namespaces.add_parser("run", help="Foreground production runners") runners = run.add_subparsers(dest="command", required=True) material = runners.add_parser("material-poll", help="Continuously poll material consumption") material.add_argument("--config", type=Path, required=True) material.add_argument("--calculation-id") material.add_argument("--poll-interval-seconds", type=float, default=10.0) material.add_argument("--state-directory", type=Path, default=Path("data/state/material")) material.add_argument("--secrets-file", type=Path, default=Path("secrets/enlyze.env")) efficiency = runners.add_parser("material-efficiency", help="Persist ERP feedback KPI points") efficiency.add_argument("--config", type=Path, required=True) efficiency.add_argument("--secrets-file", type=Path, default=Path("secrets/enlyze.env")) efficiency.add_argument("--erp-secrets-file", type=Path, default=Path("secrets/erp.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 _run_material(args: argparse.Namespace) -> int: from production_analytics.calculations.config import ( CalculationConfigError, load_material_calculation, ) from production_analytics.enlyze.exploration import ConfigurationError from production_analytics.service.material_runtime import build_material_runner try: calculation = load_material_calculation(args.config, args.calculation_id) runner = build_material_runner( calculation, poll_interval_seconds=args.poll_interval_seconds, state_directory=args.state_directory, secrets_file=args.secrets_file, ) runner.run() except (CalculationConfigError, ConfigurationError) as exc: print(f"Config/startup error: {exc}", file=sys.stderr) return 2 except Exception as exc: print(f"Startup/runtime error: {type(exc).__name__}", file=sys.stderr) return 1 except KeyboardInterrupt: return 0 return 0 def _run_material_efficiency(args: argparse.Namespace) -> int: from production_analytics.calculations.config import CalculationConfigError from production_analytics.enlyze.exploration import ConfigurationError from production_analytics.service.material_efficiency_runtime import ( build_material_efficiency_runner, ) try: build_material_efficiency_runner( args.config, secrets_file=args.secrets_file, erp_secrets_file=args.erp_secrets_file, ).run() except (CalculationConfigError, ConfigurationError) as exc: print(f"Config/startup error: {exc}", file=sys.stderr) return 2 except Exception as exc: print(f"Startup/runtime error: {type(exc).__name__}", file=sys.stderr) return 1 except KeyboardInterrupt: return 0 return 0 def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) if args.namespace == "run": if args.command == "material-efficiency": return _run_material_efficiency(args) return _run_material(args) 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; " f"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( "Sanitized fixture written to " f"{_write_fixture(args.fixture_dir, args.save_fixture, payload)}" ) if args.save_raw: print( "Raw response written to " f"{_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())