[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:
Oleksandr Bezdieniezhnykh
2026-05-12 01:28:49 +03:00
parent e0be591b06
commit 72a06edab0
15 changed files with 2057 additions and 2 deletions
@@ -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()