From 8d4a698144be6820620846f0c1dbdc9a7f562aaa Mon Sep 17 00:00:00 2001 From: alro1965 Date: Mon, 18 May 2026 07:26:37 -0400 Subject: [PATCH] polish(sprint-0): clean code per ruff + mypy strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the dev linters over Sprint 0's core/library/shared modules and address every finding. Behaviour unchanged; tests still 80/80 green. Changes: - Replace `class Foo(str, Enum)` with `class Foo(StrEnum)` (PEP 663 / Python 3.11+) in 7 enum classes: ActuatorType, AlarmSeverity, AlarmType, KnobMode, KnobFunction, AutopilotMode, AccessLevel, VesselType. Pydantic v2 serialises StrEnum the same way, so YAML/JSON round-trips are byte-identical. - Use `datetime.UTC` alias in place of `datetime.timezone.utc` (UP017) across alarms.py, knob_state.py, project_config.py, and test_knob_state.py. - Remove now-unnecessary forward-reference quotes from method return type annotations (UP037) — `from __future__ import annotations` is already in scope everywhere. - Tighten `_read_json_resource` / `_read_yaml_resource` in the library loader: validate that the deserialised payload is actually a dict before returning, instead of leaking `Any` from json.loads / yaml.safe_load. Fixes the only two `mypy --strict` findings. - Add `.claude/settings.local.json` to .gitignore (personal Claude Code overrides are not committed). Verification: ruff check arautopilot/ -> All checks passed mypy arautopilot/core library shared -> Success, 0 issues, 12 files pytest -> 80 passed in 0.25s Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 +++++ arautopilot/core/actuator_config.py | 4 ++-- arautopilot/core/alarms.py | 12 ++++++------ arautopilot/core/knob_state.py | 22 +++++++++++----------- arautopilot/core/modes.py | 4 ++-- arautopilot/core/pid_config.py | 6 +++--- arautopilot/core/project_config.py | 18 +++++++++--------- arautopilot/core/vessel_config.py | 4 ++-- arautopilot/library/loader.py | 14 ++++++++++---- arautopilot/tests/test_knob_state.py | 6 ++++-- 10 files changed, 54 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 21499b8..1f9931d 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,8 @@ examples/output/ # ---------------------------------------------------------------------------- *.log logs/ + +# ---------------------------------------------------------------------------- +# Claude Code local settings (personal overrides — not committed) +# ---------------------------------------------------------------------------- +.claude/settings.local.json diff --git a/arautopilot/core/actuator_config.py b/arautopilot/core/actuator_config.py index d2cda42..5054e1b 100644 --- a/arautopilot/core/actuator_config.py +++ b/arautopilot/core/actuator_config.py @@ -7,12 +7,12 @@ intentionally **not** enabled in Phase 1 configurations. from __future__ import annotations -from enum import Enum +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field, field_validator -class ActuatorType(str, Enum): +class ActuatorType(StrEnum): """Actuator family driving the rudder. Phase-1 supported families have a regular value. Reserved (Phase 3) diff --git a/arautopilot/core/alarms.py b/arautopilot/core/alarms.py index dc45380..4d92437 100644 --- a/arautopilot/core/alarms.py +++ b/arautopilot/core/alarms.py @@ -8,13 +8,13 @@ the autopilot (return to STANDBY). from __future__ import annotations -from datetime import datetime, timezone -from enum import Enum +from datetime import UTC, datetime +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field -class AlarmSeverity(str, Enum): +class AlarmSeverity(StrEnum): """IEC-style four-level severity scheme.""" EMERGENCY = "emergency" @@ -30,7 +30,7 @@ class AlarmSeverity(str, Enum): """Informational only; ack with one tap.""" -class AlarmType(str, Enum): +class AlarmType(StrEnum): """The fixed catalogue of alarms emitted by the firmware. Adding an entry here is a deliberate, reviewed change — the firmware @@ -89,7 +89,7 @@ class Alarm(BaseModel): type: AlarmType severity: AlarmSeverity message: str = Field(min_length=1, max_length=240) - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) source: str = Field( default="firmware", description="Subsystem that raised the alarm (e.g. 'firmware', 'display', 'vms').", @@ -105,7 +105,7 @@ class Alarm(BaseModel): *, message: str | None = None, source: str = "firmware", - ) -> "Alarm": + ) -> Alarm: """Convenience constructor using catalogue defaults.""" return cls( type=alarm_type, diff --git a/arautopilot/core/knob_state.py b/arautopilot/core/knob_state.py index 5d51701..9457341 100644 --- a/arautopilot/core/knob_state.py +++ b/arautopilot/core/knob_state.py @@ -11,13 +11,13 @@ happens in the ESP32 firmware (Sprint 7). from __future__ import annotations -from datetime import datetime, timezone -from enum import Enum +from datetime import UTC, datetime +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field, model_validator -class KnobMode(str, Enum): +class KnobMode(StrEnum): """High-level state of the bridge knob.""" LIBRE = "libre" @@ -30,7 +30,7 @@ class KnobMode(str, Enum): """Pending value being shown for operator confirmation.""" -class KnobFunction(str, Enum): +class KnobFunction(StrEnum): """The set of values the knob may target once armed.""" NONE = "none" @@ -84,7 +84,7 @@ class KnobState(BaseModel): ) @model_validator(mode="after") - def _consistency(self) -> "KnobState": + def _consistency(self) -> KnobState: if self.mode == KnobMode.LIBRE: if self.function != KnobFunction.NONE: raise ValueError("LIBRE mode requires function == NONE") @@ -105,7 +105,7 @@ class KnobState(BaseModel): # --- Pure transition helpers (return new immutable states) -------------- @classmethod - def idle(cls) -> "KnobState": + def idle(cls) -> KnobState: """Construct the canonical idle (LIBRE) state.""" return cls() @@ -116,7 +116,7 @@ class KnobState(BaseModel): current_value: float, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S, now: datetime | None = None, - ) -> "KnobState": + ) -> KnobState: """Transition LIBRE → ARMADO for the given function.""" if self.mode != KnobMode.LIBRE: raise ValueError(f"Cannot arm from mode {self.mode.value}") @@ -127,11 +127,11 @@ class KnobState(BaseModel): function=function, current_value=current_value, pending_value=None, - armed_at=now or datetime.now(timezone.utc), + armed_at=now or datetime.now(UTC), timeout_remaining_s=timeout_s, ) - def propose(self, value: float, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> "KnobState": + def propose(self, value: float, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> KnobState: """Operator turned the knob: stage a pending value (ARMADO → CONFIRMANDO).""" if self.mode not in (KnobMode.ARMADO, KnobMode.CONFIRMANDO): raise ValueError(f"Cannot propose from mode {self.mode.value}") @@ -144,7 +144,7 @@ class KnobState(BaseModel): timeout_remaining_s=timeout_s, ) - def confirm(self, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> "KnobState": + def confirm(self, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> KnobState: """Operator pressed to confirm; commits ``pending_value`` and stays armed.""" if self.mode != KnobMode.CONFIRMANDO: raise ValueError(f"Cannot confirm from mode {self.mode.value}") @@ -157,6 +157,6 @@ class KnobState(BaseModel): timeout_remaining_s=timeout_s, ) - def disarm(self) -> "KnobState": + def disarm(self) -> KnobState: """Force the knob back to LIBRE (timeout, alarm, mode change, long-press).""" return KnobState.idle() diff --git a/arautopilot/core/modes.py b/arautopilot/core/modes.py index c8158b0..8d5833b 100644 --- a/arautopilot/core/modes.py +++ b/arautopilot/core/modes.py @@ -7,10 +7,10 @@ compatibility. from __future__ import annotations -from enum import Enum +from enum import StrEnum -class AutopilotMode(str, Enum): +class AutopilotMode(StrEnum): """The complete set of autopilot operating modes, across both phases. Use :func:`is_available_in_phase` to query phase gating instead of diff --git a/arautopilot/core/pid_config.py b/arautopilot/core/pid_config.py index ee8c9a3..770da37 100644 --- a/arautopilot/core/pid_config.py +++ b/arautopilot/core/pid_config.py @@ -11,12 +11,12 @@ Mirrors section 6 of the brief: from __future__ import annotations -from enum import Enum +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -class AccessLevel(str, Enum): +class AccessLevel(StrEnum): """Three-level RBAC for PID tuning (brief section 6).""" OPERATOR = "operator" @@ -133,7 +133,7 @@ class PidConfig(BaseModel): return v @model_validator(mode="after") - def _check_loop_frequencies(self) -> "PidConfig": + def _check_loop_frequencies(self) -> PidConfig: if self.inner_loop_freq_hz <= self.outer_loop_freq_hz: raise ValueError( "inner_loop_freq_hz must be strictly greater than outer_loop_freq_hz " diff --git a/arautopilot/core/project_config.py b/arautopilot/core/project_config.py index f96663d..ae0aa49 100644 --- a/arautopilot/core/project_config.py +++ b/arautopilot/core/project_config.py @@ -8,7 +8,7 @@ transport into the firmware build pipeline. from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -38,8 +38,8 @@ class ProjectConfig(BaseModel): vessel: VesselConfig - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - modified_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + modified_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) # --- Serialisation ------------------------------------------------------- def to_dict(self) -> dict[str, Any]: @@ -75,19 +75,19 @@ class ProjectConfig(BaseModel): # --- Deserialisation ----------------------------------------------------- @classmethod - def from_dict(cls, data: dict[str, Any]) -> "ProjectConfig": + def from_dict(cls, data: dict[str, Any]) -> ProjectConfig: return cls.model_validate(data) @classmethod - def from_json(cls, text: str) -> "ProjectConfig": + def from_json(cls, text: str) -> ProjectConfig: return cls.model_validate(json.loads(text)) @classmethod - def from_yaml(cls, text: str) -> "ProjectConfig": + def from_yaml(cls, text: str) -> ProjectConfig: return cls.model_validate(yaml.safe_load(text)) @classmethod - def load(cls, path: Path | str) -> "ProjectConfig": + def load(cls, path: Path | str) -> ProjectConfig: """Load from disk; format inferred from the file extension.""" p = Path(path) text = p.read_text(encoding="utf-8") @@ -98,6 +98,6 @@ class ProjectConfig(BaseModel): return cls.from_json(text) raise ValueError(f"Unsupported project file extension: {suffix!r}") - def touch(self) -> "ProjectConfig": + def touch(self) -> ProjectConfig: """Return a copy with ``modified_at`` refreshed to now (UTC).""" - return self.model_copy(update={"modified_at": datetime.now(timezone.utc)}) + return self.model_copy(update={"modified_at": datetime.now(UTC)}) diff --git a/arautopilot/core/vessel_config.py b/arautopilot/core/vessel_config.py index e19ac00..6b3a38d 100644 --- a/arautopilot/core/vessel_config.py +++ b/arautopilot/core/vessel_config.py @@ -6,7 +6,7 @@ of every ``ProjectConfig``. from __future__ import annotations -from enum import Enum +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field @@ -15,7 +15,7 @@ from arautopilot.core.ids import VesselId, new_vessel_id from arautopilot.core.pid_config import PidConfig -class VesselType(str, Enum): +class VesselType(StrEnum): """Vessel classes targeted by Phase 1 of the product (brief section 3).""" YACHT_MOTOR_PLANEO = "yacht_motor_planeo" diff --git a/arautopilot/library/loader.py b/arautopilot/library/loader.py index 9cf0f8d..88e2dc2 100644 --- a/arautopilot/library/loader.py +++ b/arautopilot/library/loader.py @@ -65,10 +65,16 @@ def load_default_tuning_from_path(path: Path | str) -> PidConfig: def _read_json_resource(package: str, filename: str) -> dict[str, Any]: - return json.loads(resources.files(package).joinpath(filename).read_text(encoding="utf-8")) + text = resources.files(package).joinpath(filename).read_text(encoding="utf-8") + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError(f"{package}/{filename}: expected a JSON object at the top level") + return data def _read_yaml_resource(package: str, filename: str) -> dict[str, Any]: - return yaml.safe_load( - resources.files(package).joinpath(filename).read_text(encoding="utf-8") - ) + text = resources.files(package).joinpath(filename).read_text(encoding="utf-8") + data = yaml.safe_load(text) + if not isinstance(data, dict): + raise ValueError(f"{package}/{filename}: expected a YAML mapping at the top level") + return data diff --git a/arautopilot/tests/test_knob_state.py b/arautopilot/tests/test_knob_state.py index eebda47..08a5749 100644 --- a/arautopilot/tests/test_knob_state.py +++ b/arautopilot/tests/test_knob_state.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import UTC + import pytest from arautopilot.core.knob_state import ( @@ -81,13 +83,13 @@ def test_disarm_returns_to_libre() -> None: def test_libre_with_armed_at_set_invalid() -> None: - from datetime import datetime, timezone + from datetime import datetime with pytest.raises(ValueError): KnobState( mode=KnobMode.LIBRE, function=KnobFunction.NONE, - armed_at=datetime.now(timezone.utc), + armed_at=datetime.now(UTC), )