mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
[AZ-489] C12 FlightsApiClient + offline JSON loader + bbox helper
ADR-010 primary cold-start path now has a real source for the cache bbox and the takeoff origin. Single concrete strategy (`HttpxFlightsApiClient`) behind a `@runtime_checkable` Protocol; offline JSON fallback (`load_flight_file`) shares the same DTO shape per FAC-INV-1. * `flights_api/interface.py` — `FlightsApiClient` Protocol + `FlightDto` + `WaypointDto` + `WaypointObjective` / `WaypointSource` enums (plain frozen-slotted dataclasses, matching project's LatLonAlt / PoseEstimate pattern). * `flights_api/errors.py` — 8-class hierarchy under `FlightsApiError`. * `flights_api/_parser.py` — shared JSON validator: range checks, lat/lon bounds, contiguous ordinals, finite floats, enum membership. * `flights_api/bbox.py` — `bbox_from_waypoints` envelopes lat/lon and inflates by a horizontal-distance buffer via WgsConverter ENU round-trip (NOT degree-space); `takeoff_origin_from_flight` passes waypoints[0] through unrounded. * `flights_api/file_loader.py` — orjson-backed offline loader. * `flights_api/httpx_client.py` — concrete client with ONE retry on transient 5xx + connect errors; token redaction at every log site; test-injectable transport + sleep. * `runtime_root/c12_factory.py` — `build_flights_api_client(config)`; re-exported from `runtime_root/__init__.py`. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. * `pyproject.toml` — `httpx>=0.28,<1.0` added (chosen over `requests` for native `MockTransport` testing). Tests: 28 cases across AC-1..AC-18 plus extras (malformed JSON, negative buffer, zero buffer, missing top-level fields, negative ordinal, empty-flight takeoff). Full repo run: 713 passed, 2 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/`.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user