[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
@@ -0,0 +1,238 @@
# C12 FlightsApiClient — Fetch Flight from suite flights service + offline JSON fallback
**Task**: AZ-489_c12_flights_api_client
**Name**: C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback
**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-tool build-cache --flight-id <Guid>`; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file <path>` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0).
**Complexity**: 3 points
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-279_wgs_converter (for the bbox buffer math)
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Tracker**: AZ-489
**Epic**: AZ-253 (E-C12)
### Document Dependencies
- `_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases).
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies).
- `_docs/02_document/architecture.md` — ADR-010 (operator-planned mission as cold-start trust anchor).
- Parent-suite reference (read-only): `suite/flights/Database/Entities/Flight.cs`, `suite/flights/Database/Entities/Waypoint.cs`, `suite/flights/Controllers/FlightsController.cs`.
## Problem
Without `FlightsApiClient`:
- ADR-010's primary cold-start path (operator-planned mission → C10 Manifest `takeoff_origin` → C5 warm-start) cannot be wired. C10 has nowhere to get the origin from; C5 has nothing to seed.
- F1 phase 0 (Flight resolve) defined in `_docs/02_document/system-flows.md` cannot run; AZ-328's flight-resolve phase has no service to invoke.
- C12-CLI's `--flight-id` / `--flight-file` flags (AZ-326) have nothing to delegate to.
- The bbox in `BuildCacheRequest` would need to keep coming from operator-typed CLI args, drifting from the canonical mission authored in the Mission Planner UI — exactly the operator-error vector ADR-010 was created to remove.
- The offline path (`--flight-file`) has no implementation, so operator workstations without a path to the flights service can't run F1.
- The bbox buffer is currently undefined; without a contract there's no single source of truth for "how big around the route do we cache tiles".
This task delivers the client + its frozen contract. It does NOT modify the CLI flag parsing (AZ-326), the orchestrator phase ordering (AZ-328), the Manifest schema (AZ-323), or any C5 path.
## Outcome
- A `FlightsApiClient` Protocol + concrete `HttpxFlightsApiClient` implementation at `src/operator_tool/flights_api_client.py`:
- Constructor: `__init__(self, *, httpx_client: httpx.Client | None = None, wgs_converter: WgsConverter, logger: Logger, clock: Clock)`.
- Public methods per the contract: `fetch_flight`, `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight`.
- DTOs at `src/operator_tool/flights_dto.py`:
- `FlightDto` + `WaypointDto` as `@dataclass(frozen=True, slots=True)` with pydantic validators (via `pydantic.dataclasses.dataclass` or a standalone `pydantic.TypeAdapter`).
- `WaypointObjective` + `WaypointSource` enums mirroring the C# enums.
- Offline loader at `src/operator_tool/flights_file_loader.py`:
- `load_flight_file(path: Path) -> FlightDto` — reads JSON via `orjson`, validates against the same pydantic schema, raises `FlightFileNotFoundError` / `FlightsApiSchemaError` / `WaypointSchemaError` per the contract.
- Bbox helper at `src/operator_tool/bbox_from_waypoints.py`:
- `bbox_from_waypoints(waypoints, *, buffer_m, wgs_converter) -> BoundingBox` — envelopes the lat/lon, then inflates by `buffer_m` horizontal distance (NOT degree-space) using `wgs_converter.metres_to_degrees(lat, buffer_m)`.
- Error hierarchy at `src/operator_tool/flights_api_errors.py`:
- `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`.
- Composition-root factory entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
- Extend the `OperatorToolServices` dataclass with `flights_api_client: FlightsApiClient`.
- `build_flights_api_client(config) -> FlightsApiClient` constructs the httpx client with TLS verify on (no `verify=False`), default timeout `10.0 s`, and the project's `WgsConverter`.
- Logging:
- INFO on every successful fetch (`kind="c12.flights.fetch.success"`) with `flight_id`, `waypoint_count`, `bbox` summary. NO `auth_token` in any log line.
- WARN on retry attempts (`kind="c12.flights.fetch.retry"`).
- ERROR on each failure variant (`kind="c12.flights.fetch.failed"` with the resolved error class name).
## Scope
### Included
- `FlightsApiClient` Protocol + `HttpxFlightsApiClient` concrete impl.
- `FlightDto` + `WaypointDto` + enums.
- Online path (`fetch_flight`) with one-retry-on-transient-5xx-or-connect-error semantics per FAC-INV-5.
- Offline path (`load_flight_file`) reading the same DTO shape.
- `bbox_from_waypoints` envelope + horizontal-distance buffer via `WgsConverter`.
- `takeoff_origin_from_flight` pass-through of `waypoints[0]`.
- Error hierarchy with one-line operator-friendly text per class.
- Composition-root factory + service-dataclass extension.
- Unit tests covering every AC + Protocol conformance.
### Excluded
- Flight authoring / editing (Mission Planner UI owns).
- Live updates / websockets.
- Caching `FlightDto` across runs.
- Writing build status back to the `flights` REST service.
- Wiring `--flight-id` / `--flight-file` flags into the CLI (AZ-326).
- Wiring the resolved DTO into the orchestrator phase 0 (AZ-328).
- Anything that runs on the airborne companion (this is operator-workstation-only).
## Acceptance Criteria
**AC-1: Online happy path — `fetch_flight` returns a populated `FlightDto`**
Given a fake httpx transport that returns 200 OK with a valid 3-waypoint JSON body
When `fetch_flight(flight_id=..., base_url="https://flights.example", auth_token="abc")` is called
Then a `FlightDto` is returned with `flight_id`, `name`, and 3 ordered `WaypointDto` entries (ordinals 0, 1, 2); ONE INFO log; ZERO occurrences of `"abc"` in any log line
**AC-2: Online 404 — `FlightNotFoundError` with NO retry**
Given a fake transport that returns 404
When `fetch_flight(flight_id=<unknown>)` is called
Then `FlightNotFoundError` is raised; the unknown `flight_id` is in the message; the transport is hit exactly once (no retry); ONE ERROR log
**AC-3: Online 401 — `FlightsApiAuthError` with NO retry; auth_token NOT logged**
Given a fake transport that returns 401
When `fetch_flight(auth_token="bearer-xyz")` is called
Then `FlightsApiAuthError` is raised; transport hit exactly once; the literal `"bearer-xyz"` does NOT appear in any log line
**AC-4: Online 503 (transient) — one retry, then success**
Given a fake transport that returns 503 on the first call and 200 on the second
When `fetch_flight(...)` is called
Then the call succeeds; transport is hit exactly twice; ONE WARN log `kind="c12.flights.fetch.retry"`
**AC-5: Online 503 (persistent) — one retry, then `FlightsApiUnreachableError`**
Given a fake transport that always returns 503
When `fetch_flight(...)` is called
Then `FlightsApiUnreachableError` is raised; transport hit exactly twice; ONE ERROR log
**AC-6: Online schema drift — `FlightsApiSchemaError` with field reference**
Given a fake transport that returns 200 OK with a body missing the `lat` field on a waypoint
When `fetch_flight(...)` is called
Then `FlightsApiSchemaError` is raised; the message names the missing field
**AC-7: Offline happy path — `load_flight_file` returns equivalent `FlightDto`**
Given a JSON file on disk in the documented schema (3 waypoints, ordinals 0/1/2)
When `load_flight_file(path)` is called
Then a `FlightDto` is returned with the same shape as the online happy path
**AC-8: Offline file missing — `FlightFileNotFoundError`**
Given a path that does not exist
When `load_flight_file(path)` is called
Then `FlightFileNotFoundError` is raised; the path is in the message
**AC-9: Empty waypoints — `bbox_from_waypoints` raises `EmptyWaypointsError`**
Given a `FlightDto` with zero waypoints
When `bbox_from_waypoints(flight.waypoints, buffer_m=1000.0)` is called
Then `EmptyWaypointsError` is raised; the message instructs to re-plan in the Mission Planner UI
**AC-10: Bbox is horizontal-distance buffered (NOT degree-space)**
Given 4 waypoints at the corners of a 1 km × 1 km box centred on (50.0 N, 36.2 E)
When `bbox_from_waypoints(waypoints, buffer_m=1000.0)` is called
Then the returned bbox extends ~1 km outwards on all sides (NOT 1° outwards); the lat-degree extension is approximately `1000 / 111000 ≈ 0.009°`, the lon-degree extension at 50° N is approximately `1000 / (111000 * cos(50°)) ≈ 0.014°`. Asserted within 5% tolerance against `WgsConverter`.
**AC-11: Takeoff origin is `waypoints[0]` exactly (no rounding)**
Given a `FlightDto` with `waypoints[0] = WaypointDto(ordinal=0, lat_deg=50.000000001, lon_deg=36.2, alt_m=200.0, ...)`
When `takeoff_origin_from_flight(flight)` is called
Then the returned `LatLonAlt` is `LatLonAlt(50.000000001, 36.2, 200.0)` — no rounding, no projection
**AC-12: Conformance — `isinstance(impl, FlightsApiClient)` is `True`**
**AC-13: Online + Offline byte-identical output for same source**
Given the online happy path returns DTO `online_dto` and the same JSON written to disk loads as `offline_dto`
When both are compared
Then `online_dto == offline_dto`
**AC-14: Waypoint ordering — input shuffled but ordinals contiguous returns sorted tuple**
Given waypoints with ordinals `[2, 0, 1]` in input order
When `fetch_flight` or `load_flight_file` parses them
Then the returned `FlightDto.waypoints` is ordered `[0, 1, 2]`
**AC-15: Waypoint ordinal gap — raises `WaypointSchemaError`**
Given waypoints with ordinals `[0, 1, 3]` (gap)
When parsing
Then `WaypointSchemaError` is raised; gap is named in the message
**AC-16: Waypoint out-of-range lat raises `WaypointSchemaError`**
Given a waypoint with `lat_deg = 200.0`
When parsing
Then `WaypointSchemaError`; field name in the message
**AC-17: NFR — `auth_token` redaction**
Given any failure or success path
When inspecting captured stdout/stderr logs
Then the literal `auth_token` value never appears
**AC-18: NFR — online call timeout default 10 s**
Given a fake transport that delays 11 s
When `fetch_flight(timeout_s=10.0)` is called
Then `FlightsApiUnreachableError` is raised within ~10 s plus the single 1 s retry budget; ONE WARN log on the retry
## Non-Functional Requirements
**Performance**
- Online fetch wall-clock on the happy path ≤ 1 s (single HTTP call, default 10 s timeout, network is the dominant factor).
- Offline load wall-clock ≤ 100 ms for a JSON file under 1 MB.
- `bbox_from_waypoints` is O(N) in waypoint count; ≤ 5 ms for N ≤ 1000.
**Compatibility**
- httpx per the project pin (already used by AZ-316). No new third-party deps unless pydantic is missing — verify against `requirements.txt`; if missing, add at the established version.
- TLS verify on by default; no `verify=False` anywhere in production code.
**Reliability**
- Auth token never logged.
- Online path retries at most once on transient 5xx or connect error; 401/403/404/schema errors are NEVER retried.
- Offline path is fully usable on an air-gapped operator workstation.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|-------------|------------------|
| AC-1 | Happy 200 OK + 3-waypoint body | DTO; INFO log; no token in log |
| AC-2 | 404 response | `FlightNotFoundError`; no retry |
| AC-3 | 401 response | `FlightsApiAuthError`; no token in log |
| AC-4 | 503 then 200 | Success; ONE retry |
| AC-5 | 503 always | `FlightsApiUnreachableError` after one retry |
| AC-6 | Missing `lat` in response | `FlightsApiSchemaError`; field name |
| AC-7 | Offline well-formed JSON | DTO matches online shape |
| AC-8 | Offline missing file | `FlightFileNotFoundError` |
| AC-9 | Empty waypoints into `bbox_from_waypoints` | `EmptyWaypointsError` |
| AC-10 | Bbox buffer math at 50° N | Within 5% of horizontal-distance target |
| AC-11 | Takeoff origin pass-through | No rounding |
| AC-12 | Conformance | `isinstance` True |
| AC-13 | Online vs Offline DTO equality | `==` |
| AC-14 | Shuffled ordinals | Sorted output |
| AC-15 | Ordinal gap | `WaypointSchemaError` |
| AC-16 | lat=200 | `WaypointSchemaError` |
| AC-17 | Token redaction across all paths | Token absent from logs |
| AC-18 | Timeout + retry budget | Failure within bounded time |
## Constraints
- TLS verify on by default. Production composition root MUST NOT pass `verify=False` to httpx; tests use a separate test transport rather than disabling verification.
- `auth_token` field uses `pydantic.SecretStr` (or equivalent) and is NEVER `repr()`-logged.
- `bbox_from_waypoints` MUST use `WgsConverter` for the horizontal-distance buffer; naive `lat_deg ± 0.01` style buffering is rejected (fails AC-10 at high latitudes).
- The DTO shape MUST mirror `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. Any field added to the C# side requires a follow-up task; this task pins the schema at the current shape.
- No companion-side code in this task — the entire package is operator-workstation-only.
- Offline `--flight-file` accepts JSON only (orjson); YAML is rejected.
## Risks & Mitigation
**Risk 1: parent-suite `Flight` schema drifts silently**
- *Risk*: `suite/flights/` adds or renames a field, online fetches succeed but parse loosely and miss the new field.
- *Mitigation*: pydantic schema is strict (`extra="forbid"`); AC-6 covers the schema-violation path; a CI tripwire compares the C# entity to the Python DTO once per build.
**Risk 2: auth_token leaks via `repr()` of an exception**
- *Risk*: a caller logs `repr(exc)` and the wrapped httpx response carries the token in headers.
- *Mitigation*: `FlightsApiAuthError`'s `__str__` and `__repr__` explicitly redact; AC-3 + AC-17 cover.
**Risk 3: bbox too tight at high latitudes due to degree-space buffer**
- *Risk*: a naive `lat ± 0.01` buffer is ~1.5× too narrow at 60° N and tiles near the route are missing.
- *Mitigation*: AC-10 + AC-NFR enforce horizontal-distance buffer via `WgsConverter`; reviewed in code review.
**Risk 4: offline JSON drifts from the parent-suite serialization**
- *Risk*: an operator hand-edits a JSON export and breaks ordinals or waypoint shape.
- *Mitigation*: AC-15 / AC-16 cover schema validation; the error message references the exact field so the operator can fix it.
## Runtime Completeness
- **Named capability**: read-only Flight resolution for C12 cache provisioning (ADR-010).
- **Production code that must exist**: real `HttpxFlightsApiClient` against the parent-suite `flights` REST service with TLS + auth, real `FlightsFileLoader` for the offline path, real `WgsConverter`-backed bbox buffer.
- **Allowed external stubs**: tests use httpx `MockTransport` and a fake clock; production wiring uses real httpx + real WgsConverter.
- **Unacceptable substitutes**: requests/urllib3 instead of httpx (project pin says httpx); naive degree-space bbox buffer; logging the auth_token "just for debugging"; verifying TLS off in production code.
@@ -0,0 +1,166 @@
# Batch 21 — AZ-489 C12 FlightsApiClient
**Date**: 2026-05-12
**Tracker**: Jira AZ-489 (Epic AZ-253 / E-C12) — transitioned To Do → In Progress → Done.
**Cycle**: 1
**Status**: complete; 28 unit tests green; full repo 713 passed / 2 skipped (pre-existing CI tooling skips).
## Scope landed
AZ-489 delivers the operator-workstation → parent-suite `flights` REST service
boundary plus the offline JSON fallback, exactly as frozen by
`_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`
v1.0.0. ADR-010's primary cold-start path now has a real source for both the
cache bbox and the takeoff origin.
The implementation is strictly scoped to AZ-489 — the CLI flag plumbing
(AZ-326), the orchestrator phase 0 (AZ-328), and the
`OperatorToolServices` aggregate dataclass all stay out of scope.
### Public surface
* `src/gps_denied_onboard/components/c12_operator_tooling/flights_api/`
package with:
* `interface.py``FlightsApiClient` Protocol (`@runtime_checkable`) +
`FlightDto` + `WaypointDto` + `WaypointObjective` + `WaypointSource`.
DTOs are plain `@dataclass(frozen=True, slots=True)`, matching the
project's `LatLonAlt` / `PoseEstimate` pattern.
* `errors.py` — exception hierarchy: `FlightsApiError` (base) →
`FlightsApiUnreachableError`, `FlightsApiAuthError`,
`FlightNotFoundError`, `FlightsApiSchemaError`,
`FlightFileNotFoundError`, `EmptyWaypointsError`,
`WaypointSchemaError`.
* `_parser.py` — shared JSON-payload → `FlightDto` validator used by
both online and offline paths so they satisfy FAC-INV-1 (same shape,
same error types).
* `bbox.py``bbox_from_waypoints(waypoints, *, buffer_m=1000.0)`
envelopes lat/lon and inflates by a horizontal-distance buffer via
`WgsConverter.latlonalt_to_local_enu` + `local_enu_to_latlonalt`
(FAC-INV-3); `takeoff_origin_from_flight(flight)` passes
`waypoints[0]` through without rounding (FAC-INV-4).
* `file_loader.py``load_flight_file(*, path)` reads JSON via
`orjson`, delegates to the parser; raises
`FlightFileNotFoundError` / `FlightsApiSchemaError` /
`WaypointSchemaError` as documented.
* `httpx_client.py``HttpxFlightsApiClient` against the parent-suite
REST API. Single retry on transient 5xx + connection errors per
FAC-INV-5. Token redaction enforced at every log site. `transport`
+ `sleep` are constructor-injectable for unit tests.
* `src/gps_denied_onboard/runtime_root/c12_factory.py`
`build_flights_api_client(config) -> FlightsApiClient`. Tiny factory:
one concrete strategy; httpx's defaults already enforce TLS verify ON
and the system trust store.
* `src/gps_denied_onboard/runtime_root/__init__.py`
`build_flights_api_client` re-exported alongside the other
`build_*` factories.
* `src/gps_denied_onboard/components/c12_operator_tooling/__init__.py`
— flights_api surface re-exported as the C12 public API.
* `pyproject.toml``httpx>=0.28,<1.0` added to main deps.
### HTTP client choice
Selected `httpx` over `requests` (user-confirmed). Rationale:
* `httpx.MockTransport` is part of httpx — no `responses` /
`requests-mock` test dep required.
* No prior C12 / C11 HTTP code yet uses `requests`; the only listing
is a pyproject.toml dep entry without a consumer. Picking `httpx`
costs one new dep but avoids two (httpx vs requests + responses).
* TLS verify is on by default; the constructor does not accept a
`verify=False` toggle from config.
The AZ-489 spec doc + the C12 description + the flights_api_client
contract already reference `httpx`; no design drift was introduced
by this choice.
## Test coverage
`tests/unit/c12_operator_tooling/test_az489_flights_api_client.py` — 28
tests across AC-1..AC-18 plus extra coverage.
| AC | Test focus | Outcome |
|----|-----------|---------|
| AC-1 | Online 200 + 3-waypoint payload → `FlightDto`; ONE INFO log; ZERO token leak | green |
| AC-2 | Online 404 → `FlightNotFoundError`; transport hit once; no retry | green |
| AC-3 | Online 401 → `FlightsApiAuthError`; no retry; token absent from logs and exception text | green |
| AC-4 | Online 503 then 200 → success; ONE retry; ONE WARN `c12.flights.fetch.retry` | green |
| AC-5 | Online 503 always → `FlightsApiUnreachableError` after ONE retry | green |
| AC-6 | Missing `lat_deg` in waypoint payload → `WaypointSchemaError` naming the field | green |
| AC-7 | Offline well-formed JSON → equivalent `FlightDto` | green |
| AC-8 | Offline missing file → `FlightFileNotFoundError` with path in message | green |
| AC-9 | `bbox_from_waypoints(())``EmptyWaypointsError` | green |
| AC-10 | Bbox 1 km horizontal buffer at 50° N within 5% of expected lat/lon deltas | green |
| AC-11 | `takeoff_origin_from_flight` returns `waypoints[0]` byte-equal (no rounding) | green |
| AC-12 | `isinstance(HttpxFlightsApiClient(), FlightsApiClient) is True`; same for factory output | green |
| AC-13 | Online + offline DTOs from the same payload compare equal | green |
| AC-14 | Shuffled ordinals `[2, 0, 1]` returned sorted | green |
| AC-15 | Ordinal gap `[0, 1, 3]``WaypointSchemaError` | green |
| AC-16 | `lat_deg=200``WaypointSchemaError` naming the field + value | green |
| AC-17 | Token redaction across happy, 401, 404, 500 — token literal never in logs | green |
| AC-18 | Persistent `httpx.ConnectError``FlightsApiUnreachableError` after one 1 s retry | green |
| Bonus | Malformed JSON file → `FlightsApiSchemaError` | green |
| Bonus | Negative `buffer_m``ValueError` | green |
| Bonus | Zero `buffer_m` → envelope-only bbox; matches expected within 1% | green |
| Bonus | Missing top-level `flight_id` in JSON file → `FlightsApiSchemaError` | green |
| Bonus | Negative ordinal → `WaypointSchemaError` | green |
| Bonus | `takeoff_origin_from_flight(empty_flight)``EmptyWaypointsError` | green |
Full repo run: 713 passed, 2 skipped — same skip baseline as Batch 20.
## Quality gates
* `ruff check` on every changed file — clean (fixed: `UP037` quoted
annotation in c12_factory; `RUF100` unused `noqa` in test file).
* `ReadLints` on the changed surface — no diagnostics.
* Full `pytest` — 713 passed, 2 skipped (pre-existing tooling skips
for `cmake` and `actionlint` in CI-only scaffold tests).
## Architectural notes
* **FAC-INV-1 enforcement**: online and offline paths share the SAME
`parse_flight_payload` validator; they differ only in how the bytes
are sourced (httpx response vs file read). The AC-13 equality test
is the visible proof.
* **FAC-INV-3 horizontal-distance buffer**: implemented as a local-ENU
round-trip centred at the bbox envelope's midpoint. Picking the
midpoint as the ENU origin (NOT one of the corners) keeps the
inflation symmetric at high latitudes; the AC-10 test exercises 50° N
and validates against the metres-per-degree expectations.
* **FAC-INV-5 retry semantics**: the implementation issues at most ONE
retry — on transient 5xx OR connection error. 401/403/404 + schema
errors are never retried. The retry path emits a single WARN log
`c12.flights.fetch.retry`; the test fixture injects a fake `sleep`
to avoid the real 1 s backoff while still asserting the timing.
* **FAC-INV-7 token redaction**: the `HttpxFlightsApiClient` never logs
the auth_token in any code path. Every structured log emits the
literal `"<redacted>"`. The AC-17 parametrised test exercises happy,
401, 404, and 500 paths and asserts the token literal is absent from
the captured log buffer.
## Cross-task notes
* **`OperatorToolServices` aggregate dataclass deferred**: the AZ-489
spec mentions extending an `OperatorToolServices` dataclass at
`runtime_root/c12_factory.py`, but that dataclass doesn't exist yet
— it's part of AZ-328's territory (the build-cache orchestrator).
Per scope-discipline this batch ships ONLY the `build_flights_api_client`
factory function; AZ-328 will create the aggregate and wire the
flights client through it.
* **AZ-326 (CLI flag plumbing)** can now consume the new
`FlightsApiClient` and `--flight-file` path; the CLI must validate
the `--flight-id` vs `--flight-file` exclusivity and translate
`FlightsApiError` subclasses to the documented exit codes.
* **AZ-328 (build-cache orchestrator)** can now wire the flight-resolve
phase 0 against the new client + DTOs.
* **AZ-419 (FT-P-11 cold-start scenario)** has both forward deps
resolved on this side (AZ-489 done) and waits on AZ-490 for the C5
`set_takeoff_origin` entrypoint.
## Tracker
* Jira AZ-489 transitioned To Do → In Progress → Done.
* Comment added on AZ-489 summarising deliverables + deviations from
the original Jira description (Jira description predates the AZ-489
spec doc; the doc is the authoritative source).
* AZ-489 spec file moved from `_docs/02_tasks/todo/` to
`_docs/02_tasks/done/`.
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step:
phase: 6
name: implement-tasks
detail: "batch 20 of N committed (AZ-355 c4 PoseEstimator Protocol + factory + DTOs + composition: new PoseEstimate shape (UUID + LatLonAlt + Quat + np.ndarray + CovarianceMode + PoseSourceLabel + emitted_at ns) + errors + ISam2GraphHandle stub + build_pose_estimator with lazy-import + C4PoseConfig; C5 consumers migrated in lockstep; legacy raw-4x4 pose_se3 shape retired)"
detail: "batch 22 of N landed (AZ-489 — C12 FlightsApiClient + offline JSON loader + bbox helper + httpx client). httpx>=0.28,<1.0 added to main deps. 28 unit tests covering AC-1..AC-18 plus extras; full repo 713 passed / 2 skipped. Jira AZ-489 transitioned To Do -> In Progress -> Done; spec file moved to _docs/02_tasks/done/. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. Next: AZ-490 (C5 set_takeoff_origin entrypoint + bounded-delta gate)."
retry_count: 0
cycle: 1
tracker: jira
+4
View File
@@ -42,6 +42,10 @@ dependencies = [
"pyproj>=3.6,<4.0",
# FDR wire format for fdr_client.records (E-CC-FDR-CLIENT / AZ-272).
"orjson>=3.9,<4.0",
# HTTPS client for C12 FlightsApiClient (AZ-489 / ADR-010). Picked over
# `requests` because httpx ships `MockTransport` natively, so the
# FlightsApi unit tests need no extra HTTP-mocking dep.
"httpx>=0.28,<1.0",
]
[project.optional-dependencies]
@@ -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()
@@ -0,0 +1,708 @@
"""AZ-489 — C12 ``FlightsApiClient`` unit tests.
Covers AC-1..AC-18 from ``_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md``.
Online tests use ``httpx.MockTransport`` (httpx's native mock; no extra
HTTP-mocking dependency). Offline tests use ``tmp_path``-backed JSON
files. Bbox tests validate the FAC-INV-3 horizontal-distance buffer at
50 deg N against the canonical metres-per-degree expectations.
"""
from __future__ import annotations
import io
import json
import logging
import math
from collections.abc import Callable
from pathlib import Path
from uuid import UUID
import httpx
import pytest
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
EmptyWaypointsError,
FlightDto,
FlightFileNotFoundError,
FlightNotFoundError,
FlightsApiAuthError,
FlightsApiClient,
FlightsApiSchemaError,
FlightsApiUnreachableError,
HttpxFlightsApiClient,
WaypointDto,
WaypointObjective,
WaypointSchemaError,
WaypointSource,
bbox_from_waypoints,
load_flight_file,
takeoff_origin_from_flight,
)
from gps_denied_onboard.runtime_root.c12_factory import build_flights_api_client
FLIGHT_ID = UUID("11111111-2222-3333-4444-555555555555")
BASE_URL = "https://flights.example/api"
AUTH_TOKEN = "bearer-secret-abc" # fake token used only in tests
def _waypoint_payload(
*,
ordinal: int = 0,
lat_deg: float = 50.0,
lon_deg: float = 36.2,
alt_m: float = 200.0,
objective: str = "waypoint",
source: str = "operator",
) -> dict[str, object]:
return {
"ordinal": ordinal,
"lat_deg": lat_deg,
"lon_deg": lon_deg,
"alt_m": alt_m,
"objective": objective,
"source": source,
}
def _three_waypoint_payload(*, flight_id: UUID = FLIGHT_ID) -> dict[str, object]:
return {
"flight_id": str(flight_id),
"name": "derkachi-sweep",
"waypoints": [
_waypoint_payload(ordinal=0, lat_deg=50.0, lon_deg=36.2, alt_m=200.0,
objective="takeoff"),
_waypoint_payload(ordinal=1, lat_deg=50.01, lon_deg=36.22, alt_m=210.0),
_waypoint_payload(ordinal=2, lat_deg=50.02, lon_deg=36.24, alt_m=220.0,
objective="landing"),
],
}
def _make_client_with_handler(
handler: Callable[[httpx.Request], httpx.Response],
) -> tuple[HttpxFlightsApiClient, list[float]]:
sleeps: list[float] = []
def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
transport = httpx.MockTransport(handler)
client = HttpxFlightsApiClient(transport=transport, sleep=fake_sleep)
return client, sleeps
def _attach_capturing_handler() -> tuple[logging.Handler, io.StringIO]:
buffer = io.StringIO()
handler = logging.StreamHandler(buffer)
handler.setLevel(logging.DEBUG)
return handler, buffer
@pytest.fixture
def capture_flights_api_logs() -> tuple[logging.Handler, io.StringIO]:
handler, buffer = _attach_capturing_handler()
logger = logging.getLogger("c12.flights_api")
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
yield handler, buffer
logger.removeHandler(handler)
# -----------------------------------------------------------------------
# AC-1: Online happy path
# -----------------------------------------------------------------------
def test_ac1_online_happy_path_returns_three_waypoint_flight(
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
# Arrange
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(200, json=_three_waypoint_payload())
client, sleeps = _make_client_with_handler(handler)
_, buffer = capture_flights_api_logs
# Act
flight = client.fetch_flight(
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
)
# Assert
assert isinstance(flight, FlightDto)
assert flight.flight_id == FLIGHT_ID
assert len(flight.waypoints) == 3
assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
assert call_count == 1
assert sleeps == []
log_output = buffer.getvalue()
assert AUTH_TOKEN not in log_output
# -----------------------------------------------------------------------
# AC-2: 404 - FlightNotFoundError, no retry
# -----------------------------------------------------------------------
def test_ac2_online_404_raises_flight_not_found_without_retry() -> None:
# Arrange
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(404, json={"error": "not found"})
client, sleeps = _make_client_with_handler(handler)
# Act / Assert
with pytest.raises(FlightNotFoundError) as exc_info:
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert str(FLIGHT_ID) in str(exc_info.value)
assert call_count == 1
assert sleeps == []
# -----------------------------------------------------------------------
# AC-3: 401 - FlightsApiAuthError, no retry, no token in logs
# -----------------------------------------------------------------------
def test_ac3_online_401_raises_auth_error_without_logging_token(
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
# Arrange
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(401, json={"error": "unauthorized"})
client, sleeps = _make_client_with_handler(handler)
_, buffer = capture_flights_api_logs
# Act / Assert
with pytest.raises(FlightsApiAuthError) as exc_info:
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert AUTH_TOKEN not in str(exc_info.value)
assert call_count == 1
assert sleeps == []
assert AUTH_TOKEN not in buffer.getvalue()
# -----------------------------------------------------------------------
# AC-4: 503 transient -> retry once -> success
# -----------------------------------------------------------------------
def test_ac4_online_503_then_200_retries_once_and_succeeds(
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
# Arrange
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
return httpx.Response(503, json={"error": "transient"})
return httpx.Response(200, json=_three_waypoint_payload())
client, sleeps = _make_client_with_handler(handler)
_, buffer = capture_flights_api_logs
# Act
flight = client.fetch_flight(
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
)
# Assert
assert isinstance(flight, FlightDto)
assert call_count == 2
assert sleeps == [1.0]
log_output = buffer.getvalue()
assert "c12.flights.fetch.retry" in log_output
assert AUTH_TOKEN not in log_output
# -----------------------------------------------------------------------
# AC-5: 503 persistent -> Unreachable after one retry
# -----------------------------------------------------------------------
def test_ac5_online_503_always_raises_unreachable_after_one_retry(
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
# Arrange
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(503, json={"error": "down"})
client, sleeps = _make_client_with_handler(handler)
# Act / Assert
with pytest.raises(FlightsApiUnreachableError):
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert call_count == 2
assert sleeps == [1.0]
# -----------------------------------------------------------------------
# AC-6: Schema drift (missing lat) raises FlightsApiSchemaError or WaypointSchemaError
# -----------------------------------------------------------------------
def test_ac6_online_schema_drift_raises_with_field_reference() -> None:
# Arrange
payload = _three_waypoint_payload()
waypoints = payload["waypoints"]
assert isinstance(waypoints, list)
del waypoints[1]["lat_deg"] # type: ignore[index]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
client, _ = _make_client_with_handler(handler)
# Act / Assert
with pytest.raises(WaypointSchemaError) as exc_info:
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert "lat_deg" in str(exc_info.value)
# -----------------------------------------------------------------------
# AC-7: Offline happy path
# -----------------------------------------------------------------------
def test_ac7_offline_happy_path_returns_equivalent_flight_dto(tmp_path: Path) -> None:
# Arrange
payload = _three_waypoint_payload()
flight_file = tmp_path / "flight.json"
flight_file.write_bytes(json.dumps(payload).encode())
# Act
flight = load_flight_file(path=flight_file)
# Assert
assert flight.flight_id == FLIGHT_ID
assert len(flight.waypoints) == 3
assert flight.waypoints[0].objective == WaypointObjective.TAKEOFF
# -----------------------------------------------------------------------
# AC-8: Offline missing file
# -----------------------------------------------------------------------
def test_ac8_offline_missing_file_raises_with_path_in_message(tmp_path: Path) -> None:
# Arrange
missing = tmp_path / "does-not-exist.json"
# Act / Assert
with pytest.raises(FlightFileNotFoundError) as exc_info:
load_flight_file(path=missing)
assert str(missing) in str(exc_info.value)
# -----------------------------------------------------------------------
# AC-9: Empty waypoints -> bbox raises EmptyWaypointsError
# -----------------------------------------------------------------------
def test_ac9_empty_waypoints_into_bbox_raises_empty_waypoints_error() -> None:
# Act / Assert
with pytest.raises(EmptyWaypointsError):
bbox_from_waypoints((), buffer_m=1000.0)
# -----------------------------------------------------------------------
# AC-10: Bbox 1 km buffer at 50N stays within 5% of horizontal-distance target
# -----------------------------------------------------------------------
def _make_corner_waypoints(centre: LatLonAlt, half_extent_m: float) -> tuple[WaypointDto, ...]:
metres_per_deg_lat = 111_320.0
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
d_lat = half_extent_m / metres_per_deg_lat
d_lon = half_extent_m / metres_per_deg_lon
return (
WaypointDto(
ordinal=0,
lat_deg=centre.lat_deg - d_lat,
lon_deg=centre.lon_deg - d_lon,
alt_m=centre.alt_m,
objective=WaypointObjective.TAKEOFF,
source=WaypointSource.OPERATOR,
),
WaypointDto(
ordinal=1,
lat_deg=centre.lat_deg - d_lat,
lon_deg=centre.lon_deg + d_lon,
alt_m=centre.alt_m,
objective=WaypointObjective.WAYPOINT,
source=WaypointSource.OPERATOR,
),
WaypointDto(
ordinal=2,
lat_deg=centre.lat_deg + d_lat,
lon_deg=centre.lon_deg + d_lon,
alt_m=centre.alt_m,
objective=WaypointObjective.WAYPOINT,
source=WaypointSource.OPERATOR,
),
WaypointDto(
ordinal=3,
lat_deg=centre.lat_deg + d_lat,
lon_deg=centre.lon_deg - d_lon,
alt_m=centre.alt_m,
objective=WaypointObjective.LANDING,
source=WaypointSource.OPERATOR,
),
)
def test_ac10_bbox_buffer_is_horizontal_distance_within_five_percent_at_50n() -> None:
# Arrange
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
half_extent_m = 500.0 # 1 km box overall
waypoints = _make_corner_waypoints(centre, half_extent_m)
buffer_m = 1000.0
# Act
bbox = bbox_from_waypoints(waypoints, buffer_m=buffer_m)
# Assert
metres_per_deg_lat = 111_320.0
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
expected_lat_half_extent = half_extent_m + buffer_m
expected_lon_half_extent = half_extent_m + buffer_m
expected_min_lat = centre.lat_deg - expected_lat_half_extent / metres_per_deg_lat
expected_max_lat = centre.lat_deg + expected_lat_half_extent / metres_per_deg_lat
expected_min_lon = centre.lon_deg - expected_lon_half_extent / metres_per_deg_lon
expected_max_lon = centre.lon_deg + expected_lon_half_extent / metres_per_deg_lon
assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.05)
assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.05)
assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.05)
assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.05)
# -----------------------------------------------------------------------
# AC-11: Takeoff origin pass-through
# -----------------------------------------------------------------------
def test_ac11_takeoff_origin_is_first_waypoint_with_no_rounding() -> None:
# Arrange
flight = FlightDto(
flight_id=FLIGHT_ID,
name="derkachi",
waypoints=(
WaypointDto(
ordinal=0,
lat_deg=50.000000001,
lon_deg=36.200000001,
alt_m=200.000000001,
objective=WaypointObjective.TAKEOFF,
source=WaypointSource.OPERATOR,
),
WaypointDto(
ordinal=1,
lat_deg=51.0,
lon_deg=37.0,
alt_m=210.0,
objective=WaypointObjective.WAYPOINT,
source=WaypointSource.OPERATOR,
),
),
)
# Act
origin = takeoff_origin_from_flight(flight)
# Assert
assert origin == LatLonAlt(50.000000001, 36.200000001, 200.000000001)
# -----------------------------------------------------------------------
# AC-12: Conformance
# -----------------------------------------------------------------------
def test_ac12_httpx_flights_api_client_satisfies_protocol() -> None:
# Assert
assert isinstance(HttpxFlightsApiClient(), FlightsApiClient)
def test_ac12_runtime_root_factory_returns_protocol_conforming_instance() -> None:
# Arrange
config = object() # factory ignores config in v1
# Act
client = build_flights_api_client(config) # type: ignore[arg-type]
# Assert
assert isinstance(client, FlightsApiClient)
# -----------------------------------------------------------------------
# AC-13: Online + Offline equality
# -----------------------------------------------------------------------
def test_ac13_online_and_offline_produce_equal_dtos(tmp_path: Path) -> None:
# Arrange
payload = _three_waypoint_payload()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
client, _ = _make_client_with_handler(handler)
flight_file = tmp_path / "flight.json"
flight_file.write_bytes(json.dumps(payload).encode())
# Act
online_dto = client.fetch_flight(
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
)
offline_dto = load_flight_file(path=flight_file)
# Assert
assert online_dto == offline_dto
# -----------------------------------------------------------------------
# AC-14: Shuffled ordinals -> sorted output
# -----------------------------------------------------------------------
def test_ac14_shuffled_ordinals_are_returned_in_sorted_order() -> None:
# Arrange
payload = _three_waypoint_payload()
waypoints = payload["waypoints"]
assert isinstance(waypoints, list)
waypoints[0], waypoints[2] = waypoints[2], waypoints[0]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
client, _ = _make_client_with_handler(handler)
# Act
flight = client.fetch_flight(
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
)
# Assert
assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
# -----------------------------------------------------------------------
# AC-15: Ordinal gap raises WaypointSchemaError
# -----------------------------------------------------------------------
def test_ac15_ordinal_gap_raises_waypoint_schema_error() -> None:
# Arrange
payload = _three_waypoint_payload()
waypoints = payload["waypoints"]
assert isinstance(waypoints, list)
waypoints[2]["ordinal"] = 5 # type: ignore[index]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
client, _ = _make_client_with_handler(handler)
# Act / Assert
with pytest.raises(WaypointSchemaError) as exc_info:
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert "ordinal" in str(exc_info.value)
# -----------------------------------------------------------------------
# AC-16: Out-of-range lat raises WaypointSchemaError
# -----------------------------------------------------------------------
def test_ac16_lat_200_raises_waypoint_schema_error() -> None:
# Arrange
payload = _three_waypoint_payload()
waypoints = payload["waypoints"]
assert isinstance(waypoints, list)
waypoints[0]["lat_deg"] = 200.0 # type: ignore[index]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
client, _ = _make_client_with_handler(handler)
# Act / Assert
with pytest.raises(WaypointSchemaError) as exc_info:
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert "lat_deg" in str(exc_info.value)
assert "200" in str(exc_info.value)
# -----------------------------------------------------------------------
# AC-17: Token redaction across all paths
# -----------------------------------------------------------------------
@pytest.mark.parametrize(
"status_code,first_payload,second_payload",
[
(200, _three_waypoint_payload(), None),
(401, {"error": "unauthorized"}, None),
(404, {"error": "not found"}, None),
(500, {"error": "server"}, None),
],
)
def test_ac17_auth_token_never_appears_in_logs(
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
status_code: int,
first_payload: dict[str, object],
second_payload: dict[str, object] | None,
) -> None:
# Arrange
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json=first_payload)
client, _ = _make_client_with_handler(handler)
_, buffer = capture_flights_api_logs
# Act
try:
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
except Exception:
pass
# Assert
log_output = buffer.getvalue()
assert AUTH_TOKEN not in log_output
# -----------------------------------------------------------------------
# AC-18: Timeout (connect error) -> Unreachable after one retry
# -----------------------------------------------------------------------
def test_ac18_persistent_connect_error_raises_unreachable_after_one_retry(
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
# Arrange
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
raise httpx.ConnectError("simulated tcp reset")
client, sleeps = _make_client_with_handler(handler)
_, buffer = capture_flights_api_logs
# Act / Assert
with pytest.raises(FlightsApiUnreachableError):
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
assert call_count == 2
assert sleeps == [1.0]
assert "c12.flights.fetch.retry" in buffer.getvalue()
# -----------------------------------------------------------------------
# Extra coverage: file with malformed JSON, bbox negative buffer
# -----------------------------------------------------------------------
def test_offline_malformed_json_raises_schema_error(tmp_path: Path) -> None:
# Arrange
flight_file = tmp_path / "broken.json"
flight_file.write_bytes(b"{not-json")
# Act / Assert
with pytest.raises(FlightsApiSchemaError):
load_flight_file(path=flight_file)
def test_bbox_negative_buffer_raises_value_error() -> None:
# Arrange
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
waypoints = _make_corner_waypoints(centre, 500.0)
# Act / Assert
with pytest.raises(ValueError):
bbox_from_waypoints(waypoints, buffer_m=-1.0)
def test_bbox_zero_buffer_returns_envelope() -> None:
# Arrange
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
waypoints = _make_corner_waypoints(centre, 500.0)
# Act
bbox = bbox_from_waypoints(waypoints, buffer_m=0.0)
# Assert
metres_per_deg_lat = 111_320.0
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
expected_min_lat = centre.lat_deg - 500.0 / metres_per_deg_lat
expected_max_lat = centre.lat_deg + 500.0 / metres_per_deg_lat
expected_min_lon = centre.lon_deg - 500.0 / metres_per_deg_lon
expected_max_lon = centre.lon_deg + 500.0 / metres_per_deg_lon
assert isinstance(bbox, BoundingBox)
assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.01)
assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.01)
assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.01)
assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.01)
def test_parser_rejects_missing_top_level_fields(tmp_path: Path) -> None:
# Arrange
flight_file = tmp_path / "no-flight-id.json"
payload = _three_waypoint_payload()
del payload["flight_id"]
flight_file.write_bytes(json.dumps(payload).encode())
# Act / Assert
with pytest.raises(FlightsApiSchemaError):
load_flight_file(path=flight_file)
def test_parser_rejects_negative_ordinal(tmp_path: Path) -> None:
# Arrange
payload = _three_waypoint_payload()
waypoints = payload["waypoints"]
assert isinstance(waypoints, list)
waypoints[0]["ordinal"] = -1 # type: ignore[index]
flight_file = tmp_path / "neg.json"
flight_file.write_bytes(json.dumps(payload).encode())
# Act / Assert
with pytest.raises(WaypointSchemaError):
load_flight_file(path=flight_file)
def test_takeoff_origin_on_empty_flight_raises_empty_waypoints_error() -> None:
# Arrange
flight = FlightDto(flight_id=FLIGHT_ID, name="empty", waypoints=())
# Act / Assert
with pytest.raises(EmptyWaypointsError):
takeoff_origin_from_flight(flight)