262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""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())
|