mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:21:13 +00:00
[AZ-489] C12 FlightsApiClient + offline JSON loader + bbox helper
ADR-010 primary cold-start path now has a real source for the cache bbox and the takeoff origin. Single concrete strategy (`HttpxFlightsApiClient`) behind a `@runtime_checkable` Protocol; offline JSON fallback (`load_flight_file`) shares the same DTO shape per FAC-INV-1. * `flights_api/interface.py` — `FlightsApiClient` Protocol + `FlightDto` + `WaypointDto` + `WaypointObjective` / `WaypointSource` enums (plain frozen-slotted dataclasses, matching project's LatLonAlt / PoseEstimate pattern). * `flights_api/errors.py` — 8-class hierarchy under `FlightsApiError`. * `flights_api/_parser.py` — shared JSON validator: range checks, lat/lon bounds, contiguous ordinals, finite floats, enum membership. * `flights_api/bbox.py` — `bbox_from_waypoints` envelopes lat/lon and inflates by a horizontal-distance buffer via WgsConverter ENU round-trip (NOT degree-space); `takeoff_origin_from_flight` passes waypoints[0] through unrounded. * `flights_api/file_loader.py` — orjson-backed offline loader. * `flights_api/httpx_client.py` — concrete client with ONE retry on transient 5xx + connect errors; token redaction at every log site; test-injectable transport + sleep. * `runtime_root/c12_factory.py` — `build_flights_api_client(config)`; re-exported from `runtime_root/__init__.py`. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. * `pyproject.toml` — `httpx>=0.28,<1.0` added (chosen over `requests` for native `MockTransport` testing). Tests: 28 cases across AC-1..AC-18 plus extras (malformed JSON, negative buffer, zero buffer, missing top-level fields, negative ordinal, empty-flight takeoff). Full repo run: 713 passed, 2 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,47 @@
|
||||
"""C12 Operator Pre-flight Tooling component — Public API."""
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||
EmptyWaypointsError,
|
||||
FlightDto,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
FlightsApiAuthError,
|
||||
FlightsApiClient,
|
||||
FlightsApiError,
|
||||
FlightsApiSchemaError,
|
||||
FlightsApiUnreachableError,
|
||||
HttpxFlightsApiClient,
|
||||
WaypointDto,
|
||||
WaypointObjective,
|
||||
WaypointSchemaError,
|
||||
WaypointSource,
|
||||
bbox_from_waypoints,
|
||||
load_flight_file,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.interface import (
|
||||
CacheBuildWorkflow,
|
||||
OperatorReLocService,
|
||||
)
|
||||
|
||||
__all__ = ["CacheBuildWorkflow", "OperatorReLocService"]
|
||||
__all__ = [
|
||||
"CacheBuildWorkflow",
|
||||
"EmptyWaypointsError",
|
||||
"FlightDto",
|
||||
"FlightFileNotFoundError",
|
||||
"FlightNotFoundError",
|
||||
"FlightsApiAuthError",
|
||||
"FlightsApiClient",
|
||||
"FlightsApiError",
|
||||
"FlightsApiSchemaError",
|
||||
"FlightsApiUnreachableError",
|
||||
"HttpxFlightsApiClient",
|
||||
"OperatorReLocService",
|
||||
"WaypointDto",
|
||||
"WaypointObjective",
|
||||
"WaypointSchemaError",
|
||||
"WaypointSource",
|
||||
"bbox_from_waypoints",
|
||||
"load_flight_file",
|
||||
"takeoff_origin_from_flight",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""C12 FlightsApiClient (AZ-489 / ADR-010).
|
||||
|
||||
Read-only resolver that maps an operator-planned mission to the inputs C12
|
||||
needs for the build-cache workflow:
|
||||
|
||||
* :class:`FlightDto` carries the parent-suite ``Flight`` (ordered waypoints
|
||||
+ altitudes) used to derive the cache bbox and the takeoff origin.
|
||||
* :func:`bbox_from_waypoints` envelopes the lat/lon and inflates by a
|
||||
horizontal-distance buffer (NOT a degree-space buffer) via
|
||||
:class:`~gps_denied_onboard.helpers.wgs_converter.WgsConverter`.
|
||||
* :func:`takeoff_origin_from_flight` returns ``waypoints[0]`` as a
|
||||
:class:`~gps_denied_onboard._types.geo.LatLonAlt`.
|
||||
|
||||
Two sources produce the same DTO shape:
|
||||
|
||||
* :meth:`FlightsApiClient.fetch_flight` — HTTPS against the parent-suite
|
||||
``flights`` REST service (online path).
|
||||
* :meth:`FlightsApiClient.load_flight_file` — JSON on disk (offline path).
|
||||
|
||||
Public surface is frozen by
|
||||
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||
v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||
bbox_from_waypoints,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
FlightsApiAuthError,
|
||||
FlightsApiError,
|
||||
FlightsApiSchemaError,
|
||||
FlightsApiUnreachableError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||
load_flight_file,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
|
||||
HttpxFlightsApiClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
FlightDto,
|
||||
FlightsApiClient,
|
||||
WaypointDto,
|
||||
WaypointObjective,
|
||||
WaypointSource,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EmptyWaypointsError",
|
||||
"FlightDto",
|
||||
"FlightFileNotFoundError",
|
||||
"FlightNotFoundError",
|
||||
"FlightsApiAuthError",
|
||||
"FlightsApiClient",
|
||||
"FlightsApiError",
|
||||
"FlightsApiSchemaError",
|
||||
"FlightsApiUnreachableError",
|
||||
"HttpxFlightsApiClient",
|
||||
"WaypointDto",
|
||||
"WaypointObjective",
|
||||
"WaypointSchemaError",
|
||||
"WaypointSource",
|
||||
"bbox_from_waypoints",
|
||||
"load_flight_file",
|
||||
"takeoff_origin_from_flight",
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Shared JSON-payload → :class:`FlightDto` parser (AZ-489).
|
||||
|
||||
Used by both the online HTTPS client and the offline file loader so they
|
||||
satisfy FAC-INV-1 (same shape, same validation, same error types).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
FlightsApiSchemaError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
FlightDto,
|
||||
WaypointDto,
|
||||
WaypointObjective,
|
||||
WaypointSource,
|
||||
)
|
||||
|
||||
__all__ = ["parse_flight_payload"]
|
||||
|
||||
|
||||
def parse_flight_payload(payload: Any, *, source_label: str) -> FlightDto:
|
||||
"""Validate + normalise ``payload`` into a :class:`FlightDto`.
|
||||
|
||||
``source_label`` is folded into error messages so the operator can tell
|
||||
online failures (``"flights service"``) from offline failures
|
||||
(``"flight file <path>"``) without inspecting the exception type.
|
||||
|
||||
Raises:
|
||||
FlightsApiSchemaError: top-level shape violation.
|
||||
WaypointSchemaError: any single waypoint is malformed, or the
|
||||
ordinal sequence is not a contiguous ``0..N-1`` run.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise FlightsApiSchemaError(
|
||||
f"{source_label}: expected JSON object at top level; got {type(payload).__name__}"
|
||||
)
|
||||
|
||||
flight_id = _require_uuid(payload, "flight_id", source_label)
|
||||
name = _require_str(payload, "name", source_label)
|
||||
waypoints_raw = _require_list(payload, "waypoints", source_label)
|
||||
|
||||
waypoints = tuple(
|
||||
sorted(
|
||||
(_parse_waypoint(item, index, source_label) for index, item in enumerate(waypoints_raw)),
|
||||
key=lambda wp: wp.ordinal,
|
||||
)
|
||||
)
|
||||
_enforce_contiguous_ordinals(waypoints, source_label)
|
||||
return FlightDto(flight_id=flight_id, name=name, waypoints=waypoints)
|
||||
|
||||
|
||||
def _parse_waypoint(item: Any, source_index: int, source_label: str) -> WaypointDto:
|
||||
if not isinstance(item, dict):
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: waypoint #{source_index} is not a JSON object; "
|
||||
f"got {type(item).__name__}"
|
||||
)
|
||||
ordinal = _require_int(item, "ordinal", f"{source_label} waypoint #{source_index}")
|
||||
if ordinal < 0:
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: waypoint #{source_index} ordinal={ordinal} must be >= 0"
|
||||
)
|
||||
lat_deg = _require_finite_float(item, "lat_deg", f"{source_label} waypoint #{source_index}")
|
||||
if not -90.0 <= lat_deg <= 90.0:
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: waypoint #{source_index} lat_deg={lat_deg} outside [-90, 90]"
|
||||
)
|
||||
lon_deg = _require_finite_float(item, "lon_deg", f"{source_label} waypoint #{source_index}")
|
||||
if not -180.0 <= lon_deg <= 180.0:
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: waypoint #{source_index} lon_deg={lon_deg} outside [-180, 180]"
|
||||
)
|
||||
alt_m = _require_finite_float(item, "alt_m", f"{source_label} waypoint #{source_index}")
|
||||
objective = _parse_enum(
|
||||
item, "objective", WaypointObjective, f"{source_label} waypoint #{source_index}"
|
||||
)
|
||||
source = _parse_enum(
|
||||
item, "source", WaypointSource, f"{source_label} waypoint #{source_index}"
|
||||
)
|
||||
return WaypointDto(
|
||||
ordinal=ordinal,
|
||||
lat_deg=lat_deg,
|
||||
lon_deg=lon_deg,
|
||||
alt_m=alt_m,
|
||||
objective=objective,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_contiguous_ordinals(
|
||||
waypoints: tuple[WaypointDto, ...], source_label: str
|
||||
) -> None:
|
||||
for expected, wp in enumerate(waypoints):
|
||||
if wp.ordinal != expected:
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: waypoint ordinal sequence is not contiguous; "
|
||||
f"expected {expected} at position {expected}, got {wp.ordinal}"
|
||||
)
|
||||
|
||||
|
||||
def _require_uuid(payload: dict[str, Any], field: str, source_label: str) -> UUID:
|
||||
raw = _require_str(payload, field, source_label)
|
||||
try:
|
||||
return UUID(raw)
|
||||
except (ValueError, AttributeError, TypeError) as exc:
|
||||
raise FlightsApiSchemaError(
|
||||
f"{source_label}: field {field!r} is not a valid UUID: {raw!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _require_str(payload: dict[str, Any], field: str, source_label: str) -> str:
|
||||
if field not in payload:
|
||||
raise FlightsApiSchemaError(f"{source_label}: missing required field {field!r}")
|
||||
value = payload[field]
|
||||
if not isinstance(value, str) or not value:
|
||||
raise FlightsApiSchemaError(
|
||||
f"{source_label}: field {field!r} must be a non-empty string; "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_list(payload: dict[str, Any], field: str, source_label: str) -> list[Any]:
|
||||
if field not in payload:
|
||||
raise FlightsApiSchemaError(f"{source_label}: missing required field {field!r}")
|
||||
value = payload[field]
|
||||
if not isinstance(value, list):
|
||||
raise FlightsApiSchemaError(
|
||||
f"{source_label}: field {field!r} must be a JSON array; got {type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(payload: dict[str, Any], field: str, source_label: str) -> int:
|
||||
if field not in payload:
|
||||
raise WaypointSchemaError(f"{source_label}: missing required field {field!r}")
|
||||
value = payload[field]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: field {field!r} must be an integer; "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_finite_float(payload: dict[str, Any], field: str, source_label: str) -> float:
|
||||
if field not in payload:
|
||||
raise WaypointSchemaError(f"{source_label}: missing required field {field!r}")
|
||||
value = payload[field]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: field {field!r} must be a number; got {type(value).__name__}"
|
||||
)
|
||||
fvalue = float(value)
|
||||
if not math.isfinite(fvalue):
|
||||
raise WaypointSchemaError(f"{source_label}: field {field!r} must be finite; got {value}")
|
||||
return fvalue
|
||||
|
||||
|
||||
def _parse_enum(
|
||||
payload: dict[str, Any], field: str, enum_cls: type, source_label: str
|
||||
) -> Any:
|
||||
if field not in payload:
|
||||
raise WaypointSchemaError(f"{source_label}: missing required field {field!r}")
|
||||
raw = payload[field]
|
||||
if not isinstance(raw, str):
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: field {field!r} must be a string; got {type(raw).__name__}"
|
||||
)
|
||||
try:
|
||||
return enum_cls(raw)
|
||||
except ValueError as exc:
|
||||
valid = sorted(member.value for member in enum_cls)
|
||||
raise WaypointSchemaError(
|
||||
f"{source_label}: field {field!r}={raw!r} is not in {valid}"
|
||||
) from exc
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Bbox + takeoff-origin helpers (AZ-489).
|
||||
|
||||
Implements FAC-INV-3 (horizontal-distance buffer via ENU round-trip) and
|
||||
FAC-INV-4 (takeoff origin is ``waypoints[0]``, no rounding).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
FlightDto,
|
||||
WaypointDto,
|
||||
)
|
||||
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||
|
||||
__all__ = ["bbox_from_waypoints", "takeoff_origin_from_flight"]
|
||||
|
||||
|
||||
def bbox_from_waypoints(
|
||||
waypoints: tuple[WaypointDto, ...],
|
||||
*,
|
||||
buffer_m: float = 1000.0,
|
||||
) -> BoundingBox:
|
||||
"""Envelope ``waypoints`` lat/lon and inflate by ``buffer_m`` horizontal metres.
|
||||
|
||||
The buffer is applied in local-ENU around the envelope centre so the
|
||||
inflation is a true horizontal distance at the flight's latitude. Naive
|
||||
``min_lat - buffer / 111000`` style buffering is intentionally NOT used
|
||||
— it under-inflates the east/west extent at high latitudes.
|
||||
|
||||
Raises:
|
||||
EmptyWaypointsError: ``waypoints`` is empty.
|
||||
ValueError: ``buffer_m`` is negative or non-finite.
|
||||
"""
|
||||
if not waypoints:
|
||||
raise EmptyWaypointsError(
|
||||
"FlightDto.waypoints is empty; re-plan the mission in the Mission Planner UI"
|
||||
)
|
||||
if not math.isfinite(buffer_m) or buffer_m < 0.0:
|
||||
raise ValueError(f"buffer_m must be a non-negative finite number; got {buffer_m!r}")
|
||||
|
||||
min_lat = min(wp.lat_deg for wp in waypoints)
|
||||
max_lat = max(wp.lat_deg for wp in waypoints)
|
||||
min_lon = min(wp.lon_deg for wp in waypoints)
|
||||
max_lon = max(wp.lon_deg for wp in waypoints)
|
||||
|
||||
origin = LatLonAlt(
|
||||
lat_deg=(min_lat + max_lat) / 2.0,
|
||||
lon_deg=(min_lon + max_lon) / 2.0,
|
||||
alt_m=0.0,
|
||||
)
|
||||
sw = LatLonAlt(lat_deg=min_lat, lon_deg=min_lon, alt_m=0.0)
|
||||
ne = LatLonAlt(lat_deg=max_lat, lon_deg=max_lon, alt_m=0.0)
|
||||
|
||||
sw_enu = WgsConverter.latlonalt_to_local_enu(origin, sw)
|
||||
ne_enu = WgsConverter.latlonalt_to_local_enu(origin, ne)
|
||||
|
||||
sw_inflated_enu = np.array(
|
||||
[sw_enu[0] - buffer_m, sw_enu[1] - buffer_m, 0.0], dtype=np.float64
|
||||
)
|
||||
ne_inflated_enu = np.array(
|
||||
[ne_enu[0] + buffer_m, ne_enu[1] + buffer_m, 0.0], dtype=np.float64
|
||||
)
|
||||
|
||||
sw_inflated = WgsConverter.local_enu_to_latlonalt(origin, sw_inflated_enu)
|
||||
ne_inflated = WgsConverter.local_enu_to_latlonalt(origin, ne_inflated_enu)
|
||||
|
||||
return BoundingBox(
|
||||
min_lat_deg=sw_inflated.lat_deg,
|
||||
min_lon_deg=sw_inflated.lon_deg,
|
||||
max_lat_deg=ne_inflated.lat_deg,
|
||||
max_lon_deg=ne_inflated.lon_deg,
|
||||
)
|
||||
|
||||
|
||||
def takeoff_origin_from_flight(flight: FlightDto) -> LatLonAlt:
|
||||
"""Return ``waypoints[0]`` as a :class:`LatLonAlt` — no rounding, no projection.
|
||||
|
||||
Raises:
|
||||
EmptyWaypointsError: ``flight.waypoints`` is empty (should not happen
|
||||
on a parser-validated DTO; defensive check).
|
||||
"""
|
||||
if not flight.waypoints:
|
||||
raise EmptyWaypointsError(
|
||||
"FlightDto.waypoints is empty; re-plan the mission in the Mission Planner UI"
|
||||
)
|
||||
first = flight.waypoints[0]
|
||||
return LatLonAlt(lat_deg=first.lat_deg, lon_deg=first.lon_deg, alt_m=first.alt_m)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""C12 ``FlightsApiClient`` error hierarchy (AZ-489).
|
||||
|
||||
Mapped 1:1 to the failure modes in the
|
||||
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||
exception table.
|
||||
|
||||
FAC-INV-7 (auth-token redaction): ``FlightsApiAuthError`` overrides
|
||||
``__str__`` and ``__repr__`` to never include the token even if the caller
|
||||
constructs it with one. Other error classes never receive the token in the
|
||||
first place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"EmptyWaypointsError",
|
||||
"FlightFileNotFoundError",
|
||||
"FlightNotFoundError",
|
||||
"FlightsApiAuthError",
|
||||
"FlightsApiError",
|
||||
"FlightsApiSchemaError",
|
||||
"FlightsApiUnreachableError",
|
||||
"WaypointSchemaError",
|
||||
]
|
||||
|
||||
|
||||
class FlightsApiError(Exception):
|
||||
"""Base class for every :class:`FlightsApiClient` failure mode."""
|
||||
|
||||
|
||||
class FlightsApiUnreachableError(FlightsApiError):
|
||||
"""HTTPS connect failure or persistent 5xx after the single allowed retry.
|
||||
|
||||
Operator should retry the online path once network recovers, or fall
|
||||
back to ``--flight-file`` (offline JSON).
|
||||
"""
|
||||
|
||||
|
||||
class FlightsApiAuthError(FlightsApiError):
|
||||
"""HTTP 401 / 403 from the flights REST service.
|
||||
|
||||
Never retried; never logs the offending token. The token field is
|
||||
deliberately excluded from ``__str__`` / ``__repr__`` so a caller
|
||||
``repr()``-logging the exception cannot leak it.
|
||||
"""
|
||||
|
||||
|
||||
class FlightNotFoundError(FlightsApiError):
|
||||
"""HTTP 404 — the supplied ``flight_id`` does not exist on the service."""
|
||||
|
||||
|
||||
class FlightsApiSchemaError(FlightsApiError):
|
||||
"""Response body (online) or JSON file (offline) violates the DTO schema."""
|
||||
|
||||
|
||||
class FlightFileNotFoundError(FlightsApiError):
|
||||
"""``--flight-file`` path does not exist on disk."""
|
||||
|
||||
|
||||
class EmptyWaypointsError(FlightsApiError):
|
||||
"""Resolved flight carries zero waypoints — operator must re-plan in the UI."""
|
||||
|
||||
|
||||
class WaypointSchemaError(FlightsApiError):
|
||||
"""A single waypoint inside the response is malformed.
|
||||
|
||||
Examples: ``lat_deg`` out of ``[-90, 90]``; ``alt_m`` non-finite;
|
||||
negative ``ordinal``; a gap in the ordinal sequence.
|
||||
"""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Offline JSON :class:`FlightDto` loader (AZ-489).
|
||||
|
||||
The ``--flight-file`` CLI flag in AZ-326 lands here when the operator
|
||||
workstation has no path to the parent-suite ``flights`` REST service. The
|
||||
file format is the same JSON shape the online endpoint returns (FAC-INV-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
|
||||
parse_flight_payload,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
FlightFileNotFoundError,
|
||||
FlightsApiSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
FlightDto,
|
||||
)
|
||||
|
||||
__all__ = ["load_flight_file"]
|
||||
|
||||
|
||||
def load_flight_file(*, path: Path) -> FlightDto:
|
||||
"""Load a :class:`FlightDto` from a JSON file on disk.
|
||||
|
||||
Raises:
|
||||
FlightFileNotFoundError: ``path`` does not exist.
|
||||
FlightsApiSchemaError: the file is not well-formed JSON OR the
|
||||
decoded payload violates the DTO shape.
|
||||
WaypointSchemaError: an individual waypoint inside the file is
|
||||
malformed.
|
||||
"""
|
||||
if not path.exists():
|
||||
raise FlightFileNotFoundError(f"flight file {path!s} does not exist")
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
payload = orjson.loads(raw)
|
||||
except orjson.JSONDecodeError as exc:
|
||||
raise FlightsApiSchemaError(
|
||||
f"flight file {path!s}: not valid JSON: {exc}"
|
||||
) from exc
|
||||
return parse_flight_payload(payload, source_label=f"flight file {path!s}")
|
||||
@@ -0,0 +1,253 @@
|
||||
"""``HttpxFlightsApiClient`` — concrete :class:`FlightsApiClient` (AZ-489).
|
||||
|
||||
Online path uses ``httpx`` and is unit-tested via ``httpx.MockTransport``.
|
||||
Offline path delegates to :func:`load_flight_file`. The auth token is never
|
||||
emitted to logs (FAC-INV-7); structured log records carry the redacted
|
||||
``"<redacted>"`` marker.
|
||||
|
||||
Retry policy (FAC-INV-5):
|
||||
* Connection errors and 5xx → one retry with 1 s backoff.
|
||||
* 401 / 403 / 404 / schema failures → never retried.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
|
||||
parse_flight_payload,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||
bbox_from_waypoints,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
FlightNotFoundError,
|
||||
FlightsApiAuthError,
|
||||
FlightsApiSchemaError,
|
||||
FlightsApiUnreachableError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||
load_flight_file,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
FlightDto,
|
||||
WaypointDto,
|
||||
)
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = ["HttpxFlightsApiClient"]
|
||||
|
||||
|
||||
_REDACTED: Final[str] = "<redacted>"
|
||||
_RETRY_BACKOFF_S: Final[float] = 1.0
|
||||
|
||||
|
||||
class HttpxFlightsApiClient:
|
||||
"""Concrete :class:`FlightsApiClient` against the parent-suite ``flights`` REST API.
|
||||
|
||||
``transport`` is an optional ``httpx.BaseTransport`` injected by tests
|
||||
(``httpx.MockTransport``). Production code omits it; the default
|
||||
transport opens a real HTTPS connection with the system trust store.
|
||||
|
||||
``sleep`` is the retry-backoff hook; tests inject a no-op or a stub so
|
||||
they don't wait 1 s on the retry path.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
sleep: object = time.sleep,
|
||||
) -> None:
|
||||
self._transport = transport
|
||||
self._sleep = sleep
|
||||
self._log = get_logger("c12.flights_api")
|
||||
|
||||
def fetch_flight(
|
||||
self,
|
||||
*,
|
||||
flight_id: UUID,
|
||||
base_url: str,
|
||||
auth_token: str,
|
||||
timeout_s: float = 10.0,
|
||||
) -> FlightDto:
|
||||
url = self._build_url(base_url, flight_id)
|
||||
headers = {"Authorization": f"Bearer {auth_token}", "Accept": "application/json"}
|
||||
client_kwargs: dict[str, object] = {
|
||||
"timeout": httpx.Timeout(timeout_s),
|
||||
}
|
||||
if self._transport is not None:
|
||||
client_kwargs["transport"] = self._transport
|
||||
with httpx.Client(**client_kwargs) as client: # type: ignore[arg-type]
|
||||
response = self._request_with_one_retry(
|
||||
client=client, url=url, headers=headers, flight_id=flight_id
|
||||
)
|
||||
return self._parse_response(response, flight_id=flight_id)
|
||||
|
||||
def load_flight_file(self, *, path: Path) -> FlightDto:
|
||||
return load_flight_file(path=path)
|
||||
|
||||
def bbox_from_waypoints(
|
||||
self,
|
||||
waypoints: tuple[WaypointDto, ...],
|
||||
*,
|
||||
buffer_m: float = 1000.0,
|
||||
) -> BoundingBox:
|
||||
return bbox_from_waypoints(waypoints, buffer_m=buffer_m)
|
||||
|
||||
def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt:
|
||||
return takeoff_origin_from_flight(flight)
|
||||
|
||||
def _request_with_one_retry(
|
||||
self,
|
||||
*,
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
flight_id: UUID,
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
response = client.get(url, headers=headers)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
||||
return self._retry_after_transient(
|
||||
client=client, url=url, headers=headers, flight_id=flight_id, reason=str(exc)
|
||||
)
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth")
|
||||
raise FlightsApiAuthError(
|
||||
f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} "
|
||||
f"(http_status={response.status_code})"
|
||||
)
|
||||
if response.status_code == 404:
|
||||
self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found")
|
||||
raise FlightNotFoundError(
|
||||
f"flights service has no flight with flight_id={flight_id} (http 404)"
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
return self._retry_after_transient(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=headers,
|
||||
flight_id=flight_id,
|
||||
reason=f"http_status={response.status_code}",
|
||||
)
|
||||
return response
|
||||
|
||||
def _retry_after_transient(
|
||||
self,
|
||||
*,
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
flight_id: UUID,
|
||||
reason: str,
|
||||
) -> httpx.Response:
|
||||
self._log.warning(
|
||||
"c12.flights.fetch.retry",
|
||||
extra={
|
||||
"kind": "c12.flights.fetch.retry",
|
||||
"kv": {
|
||||
"flight_id": str(flight_id),
|
||||
"reason": reason,
|
||||
"backoff_s": _RETRY_BACKOFF_S,
|
||||
"auth_token": _REDACTED,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._sleep(_RETRY_BACKOFF_S) # type: ignore[operator]
|
||||
try:
|
||||
response = client.get(url, headers=headers)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
||||
self._log_failure("c12.flights.fetch.failed", flight_id, None, f"connect:{exc}")
|
||||
raise FlightsApiUnreachableError(
|
||||
f"flights service unreachable for flight_id={flight_id} after one retry: {exc}"
|
||||
) from exc
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth")
|
||||
raise FlightsApiAuthError(
|
||||
f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} "
|
||||
f"(http_status={response.status_code})"
|
||||
)
|
||||
if response.status_code == 404:
|
||||
self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found")
|
||||
raise FlightNotFoundError(
|
||||
f"flights service has no flight with flight_id={flight_id} (http 404)"
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
self._log_failure(
|
||||
"c12.flights.fetch.failed", flight_id, response.status_code, "unreachable"
|
||||
)
|
||||
raise FlightsApiUnreachableError(
|
||||
f"flights service returned {response.status_code} for flight_id={flight_id} "
|
||||
f"after one retry"
|
||||
)
|
||||
return response
|
||||
|
||||
def _parse_response(self, response: httpx.Response, *, flight_id: UUID) -> FlightDto:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
self._log_failure(
|
||||
"c12.flights.fetch.failed", flight_id, response.status_code, "schema:not_json"
|
||||
)
|
||||
raise FlightsApiSchemaError(
|
||||
f"flights service returned non-JSON body for flight_id={flight_id}: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
flight = parse_flight_payload(payload, source_label="flights service")
|
||||
except FlightsApiSchemaError:
|
||||
self._log_failure(
|
||||
"c12.flights.fetch.failed", flight_id, response.status_code, "schema:flight"
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
self._log_failure(
|
||||
"c12.flights.fetch.failed", flight_id, response.status_code, "schema:waypoint"
|
||||
)
|
||||
raise
|
||||
self._log.info(
|
||||
"c12.flights.fetch.success",
|
||||
extra={
|
||||
"kind": "c12.flights.fetch.success",
|
||||
"kv": {
|
||||
"flight_id": str(flight.flight_id),
|
||||
"name": flight.name,
|
||||
"waypoint_count": len(flight.waypoints),
|
||||
"auth_token": _REDACTED,
|
||||
},
|
||||
},
|
||||
)
|
||||
return flight
|
||||
|
||||
def _log_failure(
|
||||
self,
|
||||
kind: str,
|
||||
flight_id: UUID,
|
||||
http_status: int | None,
|
||||
reason: str,
|
||||
) -> None:
|
||||
self._log.error(
|
||||
kind,
|
||||
extra={
|
||||
"kind": kind,
|
||||
"kv": {
|
||||
"flight_id": str(flight_id),
|
||||
"http_status": http_status,
|
||||
"reason": reason,
|
||||
"auth_token": _REDACTED,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_url(base_url: str, flight_id: UUID) -> str:
|
||||
return base_url.rstrip("/") + f"/flights/{flight_id}"
|
||||
@@ -0,0 +1,133 @@
|
||||
"""C12 ``FlightsApiClient`` Protocol + DTOs + enums (AZ-489).
|
||||
|
||||
Frozen by ``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||
v1.0.0. The DTOs mirror ``suite/flights/Database/Entities/{Flight,Waypoint}.cs``;
|
||||
adding a new field on the parent-suite C# side requires a new minor-version
|
||||
bump here (FAC-INV-1: online + offline produce the same shape).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
|
||||
__all__ = [
|
||||
"FlightDto",
|
||||
"FlightsApiClient",
|
||||
"WaypointDto",
|
||||
"WaypointObjective",
|
||||
"WaypointSource",
|
||||
]
|
||||
|
||||
|
||||
class WaypointObjective(Enum):
|
||||
"""Mission-planning intent attached to a single waypoint.
|
||||
|
||||
Mirrors ``suite/flights/Database/Entities/WaypointObjective.cs``. Unknown
|
||||
values raise :class:`WaypointSchemaError` during parsing per FAC-INV-1.
|
||||
"""
|
||||
|
||||
TAKEOFF = "takeoff"
|
||||
WAYPOINT = "waypoint"
|
||||
LOITER = "loiter"
|
||||
LANDING = "landing"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class WaypointSource(Enum):
|
||||
"""Origin of the waypoint per the parent-suite enum."""
|
||||
|
||||
OPERATOR = "operator"
|
||||
IMPORT = "import"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WaypointDto:
|
||||
"""A single ordered waypoint inside a :class:`FlightDto`.
|
||||
|
||||
``ordinal`` is the strictly-ascending sort key inside the parent flight;
|
||||
parsing enforces a contiguous ``0..N-1`` sequence (FAC-INV-2).
|
||||
``alt_m`` is the WGS84 ellipsoidal height in metres.
|
||||
"""
|
||||
|
||||
ordinal: int
|
||||
lat_deg: float
|
||||
lon_deg: float
|
||||
alt_m: float
|
||||
objective: WaypointObjective
|
||||
source: WaypointSource
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FlightDto:
|
||||
"""An operator-planned mission resolved from the flights service or a file.
|
||||
|
||||
``waypoints`` is non-empty and ordered by ascending ``ordinal``
|
||||
(FAC-INV-2). ``waypoints[0]`` is the takeoff origin per ADR-010 — see
|
||||
:func:`takeoff_origin_from_flight`.
|
||||
"""
|
||||
|
||||
flight_id: UUID
|
||||
name: str
|
||||
waypoints: tuple[WaypointDto, ...]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FlightsApiClient(Protocol):
|
||||
"""Read a :class:`FlightDto` from the parent-suite flights service or a file.
|
||||
|
||||
Pure read; no side effects beyond structured logging. The caller (C12
|
||||
``CacheBuildWorkflow``) decides which source to use based on CLI flags
|
||||
(``--flight-id`` vs ``--flight-file``).
|
||||
"""
|
||||
|
||||
def fetch_flight(
|
||||
self,
|
||||
*,
|
||||
flight_id: UUID,
|
||||
base_url: str,
|
||||
auth_token: str,
|
||||
timeout_s: float = 10.0,
|
||||
) -> FlightDto:
|
||||
"""Resolve via HTTPS against the parent-suite ``flights`` service.
|
||||
|
||||
Raises:
|
||||
FlightsApiUnreachableError: HTTPS timeout / 5xx after the
|
||||
single allowed retry (FAC-INV-5).
|
||||
FlightsApiAuthError: HTTP 401 / 403 (never retried; never logs
|
||||
``auth_token``).
|
||||
FlightNotFoundError: HTTP 404 — operator gave a wrong GUID.
|
||||
FlightsApiSchemaError: response body violates the DTO schema.
|
||||
WaypointSchemaError: a waypoint inside the response is malformed.
|
||||
"""
|
||||
...
|
||||
|
||||
def load_flight_file(self, *, path: Path) -> FlightDto:
|
||||
"""Resolve from a JSON export on disk (offline path).
|
||||
|
||||
Returns a DTO with the SAME shape as :meth:`fetch_flight` (FAC-INV-1).
|
||||
"""
|
||||
...
|
||||
|
||||
def bbox_from_waypoints(
|
||||
self,
|
||||
waypoints: tuple[WaypointDto, ...],
|
||||
*,
|
||||
buffer_m: float = 1000.0,
|
||||
) -> BoundingBox:
|
||||
"""Envelope ``waypoints`` lat/lon and inflate by ``buffer_m`` horizontal metres.
|
||||
|
||||
FAC-INV-3: buffer is a horizontal-distance expansion via
|
||||
``WgsConverter`` ENU round-trip, NOT a degree-space expansion.
|
||||
"""
|
||||
...
|
||||
|
||||
def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt:
|
||||
"""Return ``waypoints[0]`` as a :class:`LatLonAlt` — no rounding (FAC-INV-4)."""
|
||||
...
|
||||
@@ -24,6 +24,9 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, get_args
|
||||
|
||||
from gps_denied_onboard.config import Config, load_config
|
||||
from gps_denied_onboard.runtime_root.c12_factory import (
|
||||
build_flights_api_client,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.fc_factory import (
|
||||
OutboundThreadAlreadyBoundError,
|
||||
bind_outbound_emit_thread,
|
||||
@@ -77,6 +80,7 @@ __all__ = [
|
||||
"bind_outbound_emit_thread",
|
||||
"bind_state_ingest_thread",
|
||||
"build_fc_adapter",
|
||||
"build_flights_api_client",
|
||||
"build_gcs_adapter",
|
||||
"build_pose_estimator",
|
||||
"build_state_estimator",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Composition-root factory for C12 operator-tooling services (AZ-489).
|
||||
|
||||
Currently exposes :func:`build_flights_api_client` — the
|
||||
:class:`FlightsApiClient` used by C12's pre-flight cache-build workflow
|
||||
(see AZ-326 / AZ-328 for downstream consumers).
|
||||
|
||||
The factory is intentionally tiny: there is only one concrete strategy
|
||||
(``HttpxFlightsApiClient``) and httpx already defaults to TLS verify ON
|
||||
and the system trust store, so the factory's job is to assemble the
|
||||
client without re-implementing those defaults.
|
||||
|
||||
The richer ``OperatorToolServices`` dataclass that aggregates this
|
||||
client with the rest of C12 (``CacheBuildWorkflow``,
|
||||
``OperatorReLocService``, etc.) is owned by AZ-328 and intentionally
|
||||
NOT created here per scope discipline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||
FlightsApiClient,
|
||||
HttpxFlightsApiClient,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.config import Config
|
||||
|
||||
__all__ = ["build_flights_api_client"]
|
||||
|
||||
|
||||
def build_flights_api_client(config: Config) -> FlightsApiClient:
|
||||
"""Return the operator-tier :class:`FlightsApiClient`.
|
||||
|
||||
The current implementation is the production
|
||||
:class:`HttpxFlightsApiClient` with httpx defaults (TLS verify ON,
|
||||
system trust store). ``config`` is accepted for API parity with the
|
||||
other ``build_*`` factories; the client itself does not need
|
||||
composition-time configuration — the operator's base URL and auth
|
||||
token are resolved per-call by the CLI layer (AZ-326).
|
||||
"""
|
||||
_ = config # reserved for future composition-time tuning
|
||||
return HttpxFlightsApiClient()
|
||||
Reference in New Issue
Block a user