From 1621752414fae630f7f4e956cf7b8cb79f678ed4 Mon Sep 17 00:00:00 2001 From: Monotox Date: Mon, 1 Jun 2026 06:50:38 +0200 Subject: [PATCH] feat: add atomic output writes and clean cancellation Route every on-disk write through a temp-file-then-os.replace helper so a killed or interrupted run never leaves a truncated artifact; the destination is either the prior content or absent. Add CliExitCode.CANCELLED (14) and a SIGTERM/SIGINT handler installed by the console-script entrypoint so an interrupt produces a defined exit code instead of the shell default. --- CHANGELOG.md | 9 + adapters/atomic_write.py | 48 ++++++ adapters/calibration/io.py | 3 +- adapters/cli.py | 26 +++ adapters/cli_support.py | 17 +- adapters/flight_log/io.py | 3 +- adapters/phase_segmentation/io.py | 3 +- adapters/sitl/artifacts.py | 6 +- adapters/validation/io.py | 3 +- docs/CLI_EXIT_CODES.md | 17 ++ docs/USAGE.md | 5 + docs/VERSIONING_POLICY.md | 3 +- ...4-atomic-output-writes-and-cancellation.md | 20 ++- docs/tickets/README.md | 4 +- main.py | 5 +- tests/test_atomic_write.py | 83 +++++++++ tests/test_cli.py | 163 +++++++++++------- 17 files changed, 340 insertions(+), 78 deletions(-) create mode 100644 adapters/atomic_write.py create mode 100644 tests/test_atomic_write.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7198f04..d5dadc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to semantic versioning once public releases begin. ### Added +- Atomic output writes and clean cancellation (Ticket 104). Every `--output` + write and every on-disk artifact writer (flight trace, phase segments, + validation report, calibration profile, SITL artifacts) now writes to a sibling + temp file, `fsync`s, and `os.replace`s it onto the target, so a killed or + interrupted run never leaves a truncated file — the destination is either the + prior content or absent. A new `CliExitCode.CANCELLED` (`14`) is returned when a + run receives `SIGTERM`/`SIGINT`, installed by the console-script entrypoint, in + place of the shell defaults (`143`/`130`). The cancellation contract is + documented in `docs/CLI_EXIT_CODES.md` and `docs/VERSIONING_POLICY.md`. - Calibration profiles and parameter fitting (Ticket 083). A new `bvlos-sim calibrate VEHICLE TRACE [TRACE ...]` command fits a narrow set of vehicle performance parameters from observed flights and emits a versioned, diff --git a/adapters/atomic_write.py b/adapters/atomic_write.py new file mode 100644 index 0000000..56fd253 --- /dev/null +++ b/adapters/atomic_write.py @@ -0,0 +1,48 @@ +"""Atomic file writes (Ticket 104). + +A killed or interrupted process must never leave a truncated artifact at a path a +consuming backend then reads. ``atomic_write_text`` writes to a sibling temp file +in the destination directory, flushes it to disk, then ``os.replace``s it onto the +target. ``os.replace`` is atomic on POSIX within a single filesystem, so an +interrupted run leaves either the prior file or nothing — never a partial one. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +__all__ = ["atomic_write_text"] + + +def atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None: + """Write ``text`` to ``path`` atomically. + + The temp file is created in ``path``'s parent directory so the final + ``os.replace`` stays within one filesystem (a cross-device rename is not + atomic). On any failure the temp file is removed and the original ``OSError`` + propagates; the destination is left untouched. + """ + directory = path.parent + handle = tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + dir=directory, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) + temp_path = Path(handle.name) + try: + with handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + except BaseException: + try: + temp_path.unlink() + except OSError: + pass + raise diff --git a/adapters/calibration/io.py b/adapters/calibration/io.py index ee183b8..29380c5 100644 --- a/adapters/calibration/io.py +++ b/adapters/calibration/io.py @@ -6,6 +6,7 @@ from pydantic import ValidationError +from adapters.atomic_write import atomic_write_text from adapters.calibration.apply import CalibrationMismatchError, apply_calibration from adapters.canonical_json import render_canonical_json from adapters.io import ( @@ -23,7 +24,7 @@ def write_calibration_profile(profile: CalibrationProfile, path: Path) -> None: """Write a calibration profile to a JSON file with canonical formatting.""" payload = profile.model_dump(mode="json") - path.write_text(render_canonical_json(payload), encoding="utf-8") + atomic_write_text(path, render_canonical_json(payload)) def load_calibration_profile( diff --git a/adapters/cli.py b/adapters/cli.py index 9d20613..51ca919 100644 --- a/adapters/cli.py +++ b/adapters/cli.py @@ -1,7 +1,9 @@ """Typer CLI adapter for estimator execution.""" import json +import signal from enum import IntEnum, StrEnum +from types import FrameType from typing import NoReturn import typer @@ -22,6 +24,7 @@ class CliExitCode(IntEnum): INVALID_INPUT = 11 UNSUPPORTED = 12 INTERNAL_ERROR = 13 + CANCELLED = 14 class ScenarioExitCode(IntEnum): @@ -74,12 +77,35 @@ class SoraOutputFormat(StrEnum): "ScenarioExitCode", "SoraOutputFormat", "SummaryOutputFormat", + "install_cancellation_handlers", "run_monte_carlo", "run_stochastic_propagation", "try_estimate_mission_distance_time", ] +def _handle_cancellation_signal(signum: int, _frame: FrameType | None) -> NoReturn: + """Exit with the documented CANCELLED code on SIGTERM/SIGINT. + + Atomic output writes (Ticket 104) guarantee no partial ``--output`` file is + left behind; this just turns an interrupt into a defined exit code instead of + the shell's default (``143`` for SIGTERM, ``130`` for SIGINT) so a backend + worker can branch on it. ``raise SystemExit`` unwinds the stack, running + ``finally`` blocks and context managers. + """ + raise SystemExit(int(CliExitCode.CANCELLED)) + + +def install_cancellation_handlers() -> None: + """Route SIGTERM and SIGINT to the CANCELLED exit code. + + Called from the console-script entrypoint, not at import, so the in-process + Typer test runner keeps Python's default ``KeyboardInterrupt`` behaviour. + """ + signal.signal(signal.SIGTERM, _handle_cancellation_signal) + signal.signal(signal.SIGINT, _handle_cancellation_signal) + + def _version_callback(value: bool) -> None: if value: typer.echo(f"bvlos-sim {tool_version()}") diff --git a/adapters/cli_support.py b/adapters/cli_support.py index 45a18d7..16c9d4a 100644 --- a/adapters/cli_support.py +++ b/adapters/cli_support.py @@ -15,6 +15,7 @@ OutputFormat, render_envelope_json, ) +from adapters.atomic_write import atomic_write_text from adapters.assets.geofence_geojson import GeofenceLoadError, load_geofences from adapters.assets.obstacle_geojson import ObstacleLoadError, load_obstacles from adapters.io import ( @@ -23,7 +24,10 @@ InputLoadStage, validation_error_summary, ) -from adapters.assets.landing_zone_geojson import LandingZoneLoadError, load_landing_zones +from adapters.assets.landing_zone_geojson import ( + LandingZoneLoadError, + load_landing_zones, +) from adapters.assets.population_grid import load_population_grid from adapters.checklist_markdown import ( render_checklist_markdown, @@ -46,7 +50,12 @@ render_stochastic_envelope_json, ) from adapters.stochastic_markdown import render_stochastic_markdown -from adapters.summary import format_estimate_summary, format_scenario_summary, format_stochastic_summary, format_uncertainty_summary +from adapters.summary import ( + format_estimate_summary, + format_scenario_summary, + format_stochastic_summary, + format_uncertainty_summary, +) from adapters.assets.terrain_grid import load_terrain_grid from adapters.uncertainty_envelope import ( UncertaintyResultEnvelope, @@ -204,6 +213,7 @@ def _render_scenario_summary(envelope: ScenarioResultEnvelope) -> str: OutputFormat.GROUND_RISK: render_ground_risk_markdown_from_scenario, } + def _render_uncertainty_summary(envelope: UncertaintyResultEnvelope) -> str: return format_uncertainty_summary(envelope.result) @@ -214,6 +224,7 @@ def _render_uncertainty_summary(envelope: UncertaintyResultEnvelope) -> str: OutputFormat.SUMMARY: _render_uncertainty_summary, } + def _render_stochastic_summary(envelope: StochasticResultEnvelope) -> str: return format_stochastic_summary(envelope.result) @@ -506,7 +517,7 @@ def _write_output(rendered: str, output: Path | None) -> None: if output is None: typer.echo(rendered, nl=False) return - output.write_text(rendered, encoding="utf-8") + atomic_write_text(output, rendered) except OSError as exc: raise OutputWriteError("Failed to write output.") from exc diff --git a/adapters/flight_log/io.py b/adapters/flight_log/io.py index 47245c4..287609f 100644 --- a/adapters/flight_log/io.py +++ b/adapters/flight_log/io.py @@ -6,6 +6,7 @@ from pydantic import ValidationError +from adapters.atomic_write import atomic_write_text from adapters.canonical_json import render_canonical_json from adapters.io import ( InputDocument, @@ -21,7 +22,7 @@ def write_flight_trace(trace: NormalizedFlightTrace, path: Path) -> None: """Write a normalized flight trace to a JSON file with canonical formatting.""" payload = trace.model_dump(mode="json") - path.write_text(render_canonical_json(payload), encoding="utf-8") + atomic_write_text(path, render_canonical_json(payload)) def read_flight_trace(path: Path) -> NormalizedFlightTrace: diff --git a/adapters/phase_segmentation/io.py b/adapters/phase_segmentation/io.py index 6a7ebca..3d8e0f5 100644 --- a/adapters/phase_segmentation/io.py +++ b/adapters/phase_segmentation/io.py @@ -6,6 +6,7 @@ from pydantic import ValidationError +from adapters.atomic_write import atomic_write_text from adapters.canonical_json import render_canonical_json from adapters.io import ( InputDocument, @@ -21,7 +22,7 @@ def write_phase_segments(result: PhaseSegmentResult, path: Path) -> None: """Write a phase segment result to a JSON file with canonical formatting.""" payload = result.model_dump(mode="json") - path.write_text(render_canonical_json(payload), encoding="utf-8") + atomic_write_text(path, render_canonical_json(payload)) def load_phase_segments(path: Path) -> tuple[PhaseSegmentResult, InputDocument]: diff --git a/adapters/sitl/artifacts.py b/adapters/sitl/artifacts.py index af70d45..457917f 100644 --- a/adapters/sitl/artifacts.py +++ b/adapters/sitl/artifacts.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from pathlib import Path +from adapters.atomic_write import atomic_write_text from schemas.sitl import ( SitlArtifactReference, SitlArtifactRole, @@ -268,10 +269,7 @@ def _write_artifact( "schema_version": schema_version, payload_key: payload, } - path.write_text( - json.dumps(content, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) + atomic_write_text(path, json.dumps(content, indent=2, sort_keys=True) + "\n") def _write_artifact_reference( diff --git a/adapters/validation/io.py b/adapters/validation/io.py index bc5ecb4..4fcfcb4 100644 --- a/adapters/validation/io.py +++ b/adapters/validation/io.py @@ -6,6 +6,7 @@ from pydantic import ValidationError +from adapters.atomic_write import atomic_write_text from adapters.canonical_json import render_canonical_json from adapters.io import ( InputDocument, @@ -21,7 +22,7 @@ def write_validation_report(report: ValidationReport, path: Path) -> None: """Write a validation report to a JSON file with canonical formatting.""" payload = report.model_dump(mode="json") - path.write_text(render_canonical_json(payload), encoding="utf-8") + atomic_write_text(path, render_canonical_json(payload)) def load_validation_report(path: Path) -> tuple[ValidationReport, InputDocument]: diff --git a/docs/CLI_EXIT_CODES.md b/docs/CLI_EXIT_CODES.md index 5229c3f..ef409c5 100644 --- a/docs/CLI_EXIT_CODES.md +++ b/docs/CLI_EXIT_CODES.md @@ -18,12 +18,29 @@ divergences below are intentional and are called out explicitly. | `11` | `INVALID_INPUT` | Input files, arguments, or referenced assets failed to load or validate. | | `12` | `UNSUPPORTED` | The requested computation is not supported for these inputs. | | `13` | `INTERNAL_ERROR` | An output could not be written, or an unexpected error occurred. | +| `14` | `CANCELLED` | The run received `SIGTERM`/`SIGINT` and aborted; no output was written. | Every command returns `13` rather than a bare traceback (shell status `1`) when an unexpected exception escapes. A shell status `2` comes from the argument parser (Typer/Click) for malformed invocations (unknown option, missing argument); it is not one of the codes above. +## Cancellation contract + +Any command may receive `SIGTERM` or `SIGINT` (e.g. a worker cancelling a job or +enforcing a timeout). When it does: + +- The process exits `14` (`CANCELLED`) instead of the shell defaults (`143` for + `SIGTERM`, `130` for `SIGINT`), so a caller can branch on a defined code. +- No `--output` file is left in a partial state. All on-disk writes go through an + atomic temp-file-then-`os.replace`, so an interrupted run leaves the + destination either at its prior content or absent — never truncated. A consumer + can therefore trust that any file that exists is complete. + +The `CANCELLED` code is only installed by the console-script entrypoint +(`main:main`); importing the Typer app in-process (as the test runner does) keeps +Python's default `KeyboardInterrupt` behaviour. + ## Per-command exit codes | Command | `0` | `10` | `11` | `12` | `13` | Notes | diff --git a/docs/USAGE.md b/docs/USAGE.md index 1189b63..afbe61b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -64,6 +64,11 @@ the body, never `10`), `scenario` has no `12` (every non-passed outcome collapse to `10`), and `estimate` returns `11` for a computed invalid-input failure even when the input files are valid. +A run interrupted by `SIGTERM`/`SIGINT` exits `14` (`CANCELLED`) and writes no +output file. All `--output` writes are atomic (temp file then `os.replace`), so +an interrupted run never leaves a truncated file — the destination is either the +prior content or absent. + Mission-scoped functionality is exposed through `estimate` by mission and vehicle YAML: fidelity settings, terrain, wind grids, geofences, landing zones, obstacles, resource systems, communication links, energy feasibility, and route diff --git a/docs/VERSIONING_POLICY.md b/docs/VERSIONING_POLICY.md index 3877750..03e4dd8 100644 --- a/docs/VERSIONING_POLICY.md +++ b/docs/VERSIONING_POLICY.md @@ -24,7 +24,8 @@ Current public contracts: - SITL evidence bundle: `sitl-evidence.v1` - SITL comparison report: `sitl-comparison.v1` - CLI exit-code semantics (enumerated per command in - [`CLI_EXIT_CODES.md`](CLI_EXIT_CODES.md)) + [`CLI_EXIT_CODES.md`](CLI_EXIT_CODES.md)), including the `14` (`CANCELLED`) + signal-abort code and the atomic-output-write guarantee - supported Markdown report shape covered by golden fixtures Internal module layout is not a public contract. Refactors are allowed when the diff --git a/docs/tickets/104-atomic-output-writes-and-cancellation.md b/docs/tickets/104-atomic-output-writes-and-cancellation.md index 30edd63..fad8456 100644 --- a/docs/tickets/104-atomic-output-writes-and-cancellation.md +++ b/docs/tickets/104-atomic-output-writes-and-cancellation.md @@ -2,7 +2,7 @@ ## Status -Planned. +Implemented. ## Goal @@ -59,3 +59,21 @@ raises `KeyboardInterrupt` (a `BaseException`) that bypasses every the signal-handling half; either can ship first. - Adding `CliExitCode.CANCELLED` is an additive contract change — record it in `docs/CLI_EXIT_CODES.md` and `docs/VERSIONING_POLICY.md` in the same commit. + +## Implementation + +| File | Change | +| --- | --- | +| `adapters/atomic_write.py` | New `atomic_write_text(path, text)`: temp file in the destination directory → `flush` + `os.fsync` → `os.replace`; cleans up the temp file and re-raises on any failure. | +| `adapters/cli_support.py` | `_write_output` routes file writes through `atomic_write_text` (stdout path unchanged). | +| `adapters/flight_log/io.py`, `validation/io.py`, `phase_segmentation/io.py`, `calibration/io.py`, `sitl/artifacts.py` | The five other on-disk writers now use `atomic_write_text`. | +| `adapters/cli.py` | Added `CliExitCode.CANCELLED = 14`, `_handle_cancellation_signal` (raises `SystemExit(14)`), and `install_cancellation_handlers()` routing `SIGTERM`/`SIGINT` to it. | +| `main.py` | The console-script entrypoint installs the cancellation handlers before running the app. | +| `docs/CLI_EXIT_CODES.md`, `docs/VERSIONING_POLICY.md`, `docs/USAGE.md` | Documented the `14`/`CANCELLED` code and the atomic-write guarantee. | +| `tests/test_atomic_write.py` | Covers the temp-then-replace path, no-leftover-temp-files, original-preserved-on-failure, the missing-parent error, and the signal exit code / handler registration. `tests/test_cli.py`'s output-write-failure test was retargeted from `Path.write_text` to `os.replace`. | + +The signal handlers are installed only by the console-script entrypoint, not at +import, so the in-process Typer test runner keeps Python's default +`KeyboardInterrupt` behaviour and existing tests are unaffected. The atomic-write +change does not alter output bytes for a normal run, so the golden fixtures are +unchanged. diff --git a/docs/tickets/README.md b/docs/tickets/README.md index fc3034b..3cb416c 100644 --- a/docs/tickets/README.md +++ b/docs/tickets/README.md @@ -1,6 +1,6 @@ # Ticket Backlog -**59 implemented · 19 planned · 1230 tests passing** +**60 implemented · 18 planned · 1237 tests passing** This directory tracks every capability from idea to implementation. Completed tickets are kept as historical records. Open tickets describe what to build @@ -53,7 +53,6 @@ worker depends on. | # | Ticket | What it adds | |---|---|---| -| 104 | [Atomic output writes and clean cancellation](./104-atomic-output-writes-and-cancellation.md) | Temp-then-`os.replace` output so a killed run never leaves a partial file; `SIGTERM` exit code | | 105 | [Contract-version discovery command](./105-contract-version-discovery-command.md) | `schema-versions` command printing supported input/output contract versions without running a job | | 106 | [Machine-readable run progress](./106-machine-readable-run-progress.md) | JSONL progress for `propagate`/`sample`/`batch` so a non-TTY worker can show live progress (extends 067) | | 107 | [Machine-readable preflight report](./107-machine-readable-preflight-report.md) | JSON `--validate-only` envelope plus GeoJSON asset preflight across run types (composes with 089) | @@ -203,3 +202,4 @@ New capabilities should work *with* existing pieces, not alongside them in isola 56. [082](./082-predicted-vs-observed-validation-metrics.md) Predicted vs. observed validation metrics 57. [098](./098-version-bump-and-release-tooling.md) Version bump and release tooling 58. [083](./083-calibration-profile-data-and-fitting.md) Calibration profile data and fitting +59. [104](./104-atomic-output-writes-and-cancellation.md) Atomic output writes and clean cancellation diff --git a/main.py b/main.py index b7eb15e..935dc06 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,10 @@ -from adapters.cli import app +"""Entry point for the bvlos-sim CLI.""" + +from adapters.cli import app, install_cancellation_handlers def main() -> None: + install_cancellation_handlers() app() diff --git a/tests/test_atomic_write.py b/tests/test_atomic_write.py new file mode 100644 index 0000000..9928cdf --- /dev/null +++ b/tests/test_atomic_write.py @@ -0,0 +1,83 @@ +"""Atomic output writes and clean cancellation (Ticket 104).""" + +import os +import signal +from pathlib import Path + +import pytest + +from adapters.atomic_write import atomic_write_text +from adapters.cli import ( + CliExitCode, + _handle_cancellation_signal, + install_cancellation_handlers, +) + + +# --- atomic_write_text ---------------------------------------------------- + + +def test_atomic_write_creates_file_with_content(tmp_path: Path) -> None: + target = tmp_path / "out.json" + atomic_write_text(target, '{"ok": true}\n') + assert target.read_text(encoding="utf-8") == '{"ok": true}\n' + + +def test_atomic_write_overwrites_existing(tmp_path: Path) -> None: + target = tmp_path / "out.json" + target.write_text("old", encoding="utf-8") + atomic_write_text(target, "new") + assert target.read_text(encoding="utf-8") == "new" + + +def test_atomic_write_leaves_no_temp_files(tmp_path: Path) -> None: + target = tmp_path / "out.json" + atomic_write_text(target, "content") + assert [p.name for p in tmp_path.iterdir()] == ["out.json"] + + +def test_failed_write_preserves_original_and_cleans_up( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "out.json" + target.write_text("prior", encoding="utf-8") + + def _boom(*_args: object, **_kwargs: object) -> None: + raise OSError("replace failed") + + monkeypatch.setattr(os, "replace", _boom) + with pytest.raises(OSError): + atomic_write_text(target, "partial-data-that-must-not-land") + + # The destination keeps its prior content, never a truncated write... + assert target.read_text(encoding="utf-8") == "prior" + # ...and the temp file is cleaned up. + assert [p.name for p in tmp_path.iterdir()] == ["out.json"] + + +def test_missing_parent_directory_raises_oserror(tmp_path: Path) -> None: + target = tmp_path / "missing" / "out.json" + with pytest.raises(OSError): + atomic_write_text(target, "content") + + +# --- cancellation signal handling ----------------------------------------- + + +def test_cancellation_handler_exits_with_cancelled_code() -> None: + with pytest.raises(SystemExit) as excinfo: + _handle_cancellation_signal(signal.SIGTERM, None) + assert excinfo.value.code == int(CliExitCode.CANCELLED) + assert int(CliExitCode.CANCELLED) == 14 + + +def test_install_cancellation_handlers_registers_both_signals() -> None: + previous_term = signal.getsignal(signal.SIGTERM) + previous_int = signal.getsignal(signal.SIGINT) + try: + install_cancellation_handlers() + assert signal.getsignal(signal.SIGTERM) is _handle_cancellation_signal + assert signal.getsignal(signal.SIGINT) is _handle_cancellation_signal + finally: + signal.signal(signal.SIGTERM, previous_term) + signal.signal(signal.SIGINT, previous_int) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4edfa8c..d4d1f41 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import json +import os from pathlib import Path import yaml @@ -156,9 +157,9 @@ def test_cli_infeasible_result_maps_to_exit_code(tmp_path: Path) -> None: assert envelope["diagnostics"][-1]["kind"] == "infeasible" -def test_cli_energy_infeasible_result_has_complete_result_validity( - tmp_path: Path, -) -> None: +def test_cli_energy_infeasible_result_has_complete_result_validity( + tmp_path: Path, +) -> None: mission_path = tmp_path / "mission.yaml" vehicle_path = tmp_path / "vehicle.yaml" vehicle_payload = make_vehicle_payload() @@ -174,56 +175,56 @@ def test_cli_energy_infeasible_result_has_complete_result_validity( assert envelope["diagnostics"][-1]["code"] == "RESERVE_BELOW_THRESHOLD" assert envelope["result_validity"]["is_complete"] is True assert envelope["result_validity"]["is_valid_for_full_mission"] is True - assert envelope["result"]["totals_are_partial"] is False - assert envelope["result"]["energy"]["is_feasible"] is False - - -def test_cli_rth_reserve_gate_failure_maps_to_infeasible_exit_code( - tmp_path: Path, -) -> None: - mission_payload = make_mission_payload() - home = mission_payload["planned_home"] - mission_payload["constraints"]["require_rth_reserve"] = True - mission_payload["route"] = [ - { - "id": "far", - "action": "waypoint", - "lat": home["lat"], - "lon": home["lon"] + 0.05, - "altitude_m": 120.0, - }, - { - "id": "near_far", - "action": "waypoint", - "lat": home["lat"] + 0.001, - "lon": home["lon"] + 0.05, - "altitude_m": 120.0, - }, - ] - vehicle_payload = make_vehicle_payload() - vehicle_payload["energy"]["battery_capacity_wh"] = 60.0 - mission_path = tmp_path / "mission.yaml" - vehicle_path = tmp_path / "vehicle.yaml" - _write_yaml(mission_path, mission_payload) - _write_yaml(vehicle_path, vehicle_payload) - - result = runner.invoke(app, ["estimate", str(mission_path), str(vehicle_path)]) - - assert result.exit_code == int(CliExitCode.INFEASIBLE) - envelope = json.loads(result.stdout) - assert envelope["status"] == "infeasible" - assert envelope["diagnostics"][-1]["code"] == "RTH_RESERVE_BELOW_THRESHOLD" - assert envelope["diagnostics"][-1]["leg_index"] == 0 - assert envelope["diagnostics"][-1]["route_item_id"] == "far" - assert envelope["result_validity"]["is_complete"] is True - assert envelope["result_validity"]["is_valid_for_full_mission"] is True - assert envelope["result"]["rth_is_feasible"] is False - assert envelope["result"]["metadata"]["require_rth_reserve"] is True - - -def test_cli_loads_relative_geofence_asset_and_reports_conflict( - tmp_path: Path, -) -> None: + assert envelope["result"]["totals_are_partial"] is False + assert envelope["result"]["energy"]["is_feasible"] is False + + +def test_cli_rth_reserve_gate_failure_maps_to_infeasible_exit_code( + tmp_path: Path, +) -> None: + mission_payload = make_mission_payload() + home = mission_payload["planned_home"] + mission_payload["constraints"]["require_rth_reserve"] = True + mission_payload["route"] = [ + { + "id": "far", + "action": "waypoint", + "lat": home["lat"], + "lon": home["lon"] + 0.05, + "altitude_m": 120.0, + }, + { + "id": "near_far", + "action": "waypoint", + "lat": home["lat"] + 0.001, + "lon": home["lon"] + 0.05, + "altitude_m": 120.0, + }, + ] + vehicle_payload = make_vehicle_payload() + vehicle_payload["energy"]["battery_capacity_wh"] = 60.0 + mission_path = tmp_path / "mission.yaml" + vehicle_path = tmp_path / "vehicle.yaml" + _write_yaml(mission_path, mission_payload) + _write_yaml(vehicle_path, vehicle_payload) + + result = runner.invoke(app, ["estimate", str(mission_path), str(vehicle_path)]) + + assert result.exit_code == int(CliExitCode.INFEASIBLE) + envelope = json.loads(result.stdout) + assert envelope["status"] == "infeasible" + assert envelope["diagnostics"][-1]["code"] == "RTH_RESERVE_BELOW_THRESHOLD" + assert envelope["diagnostics"][-1]["leg_index"] == 0 + assert envelope["diagnostics"][-1]["route_item_id"] == "far" + assert envelope["result_validity"]["is_complete"] is True + assert envelope["result_validity"]["is_valid_for_full_mission"] is True + assert envelope["result"]["rth_is_feasible"] is False + assert envelope["result"]["metadata"]["require_rth_reserve"] is True + + +def test_cli_loads_relative_geofence_asset_and_reports_conflict( + tmp_path: Path, +) -> None: mission_payload = make_mission_payload() mission_payload["assets"] = {"geofences_file": "geofences.geojson"} mission_payload["route"] = [mission_payload["route"][1]] @@ -568,7 +569,13 @@ def test_cli_max_segment_length_without_fidelity_respects_mission_fidelity( result = runner.invoke( app, - ["estimate", str(mission_path), str(vehicle_path), "--max-segment-length-m", "5000"], + [ + "estimate", + str(mission_path), + str(vehicle_path), + "--max-segment-length-m", + "5000", + ], ) assert result.exit_code == int(CliExitCode.SUCCESS) @@ -619,7 +626,9 @@ def mock_estimator(*args, **kwargs): called.append(True) raise AssertionError("estimator must not be called with --validate-only") - monkeypatch.setattr(cli_module, "try_estimate_mission_distance_time", mock_estimator) + monkeypatch.setattr( + cli_module, "try_estimate_mission_distance_time", mock_estimator + ) result = runner.invoke( app, @@ -657,10 +666,12 @@ def test_cli_output_write_failure_falls_back_to_stdout_internal_error( _write_yaml(mission_path, make_mission_payload()) _write_yaml(vehicle_path, make_vehicle_payload()) - def fail_write_text(self, data: str, encoding: str = "utf-8") -> int: + def fail_replace(src: object, dst: object) -> None: raise OSError("disk full") - monkeypatch.setattr(Path, "write_text", fail_write_text) + # Output writes are atomic (Ticket 104): inject the failure at the final + # os.replace, which is where _write_output's write path can now raise. + monkeypatch.setattr(os, "replace", fail_replace) result = runner.invoke( app, @@ -684,11 +695,18 @@ def fail_write_text(self, data: str, encoding: str = "utf-8") -> int: # convert command # --------------------------------------------------------------------------- -PLAN_FILE = Path(__file__).resolve().parents[1] / "examples" / "missions" / "pipeline_demo_001.plan" +PLAN_FILE = ( + Path(__file__).resolve().parents[1] + / "examples" + / "missions" + / "pipeline_demo_001.plan" +) def test_convert_command_outputs_valid_yaml() -> None: - result = runner.invoke(app, ["convert", str(PLAN_FILE), "--vehicle-profile", "quadplane_v1"]) + result = runner.invoke( + app, ["convert", str(PLAN_FILE), "--vehicle-profile", "quadplane_v1"] + ) assert result.exit_code == int(CliExitCode.SUCCESS) yaml_text = "\n".join( line for line in result.output.splitlines() if not line.startswith("Warning:") @@ -700,7 +718,9 @@ def test_convert_command_outputs_valid_yaml() -> None: def test_convert_command_route_items_are_block_style() -> None: - result = runner.invoke(app, ["convert", str(PLAN_FILE), "--vehicle-profile", "quadplane_v1"]) + result = runner.invoke( + app, ["convert", str(PLAN_FILE), "--vehicle-profile", "quadplane_v1"] + ) assert result.exit_code == int(CliExitCode.SUCCESS) assert "- id:" in result.output assert " action:" in result.output @@ -708,7 +728,17 @@ def test_convert_command_route_items_are_block_style() -> None: def test_convert_command_writes_to_output_file(tmp_path: Path) -> None: out_file = tmp_path / "mission.yaml" - result = runner.invoke(app, ["convert", str(PLAN_FILE), "--vehicle-profile", "quadplane_v1", "--output", str(out_file)]) + result = runner.invoke( + app, + [ + "convert", + str(PLAN_FILE), + "--vehicle-profile", + "quadplane_v1", + "--output", + str(out_file), + ], + ) assert result.exit_code == int(CliExitCode.SUCCESS) assert out_file.exists() payload = yaml.safe_load(out_file.read_text(encoding="utf-8")) @@ -723,7 +753,16 @@ def test_convert_command_invalid_json_exits_invalid_input(tmp_path: Path) -> Non def test_convert_validate_only_exits_success() -> None: - result = runner.invoke(app, ["convert", str(PLAN_FILE), "--vehicle-profile", "quadplane_v1", "--validate-only"]) + result = runner.invoke( + app, + [ + "convert", + str(PLAN_FILE), + "--vehicle-profile", + "quadplane_v1", + "--validate-only", + ], + ) assert result.exit_code == int(CliExitCode.SUCCESS) assert "OK" in result.output assert "route:" not in result.output # YAML must not be written