polish(sprint-0): clean code per ruff + mypy strict

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 07:26:37 -04:00
parent 700756c16f
commit 8d4a698144
10 changed files with 54 additions and 41 deletions
+5
View File
@@ -137,3 +137,8 @@ examples/output/
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
*.log *.log
logs/ logs/
# ----------------------------------------------------------------------------
# Claude Code local settings (personal overrides — not committed)
# ----------------------------------------------------------------------------
.claude/settings.local.json
+2 -2
View File
@@ -7,12 +7,12 @@ intentionally **not** enabled in Phase 1 configurations.
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
class ActuatorType(str, Enum): class ActuatorType(StrEnum):
"""Actuator family driving the rudder. """Actuator family driving the rudder.
Phase-1 supported families have a regular value. Reserved (Phase 3) Phase-1 supported families have a regular value. Reserved (Phase 3)
+6 -6
View File
@@ -8,13 +8,13 @@ the autopilot (return to STANDBY).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class AlarmSeverity(str, Enum): class AlarmSeverity(StrEnum):
"""IEC-style four-level severity scheme.""" """IEC-style four-level severity scheme."""
EMERGENCY = "emergency" EMERGENCY = "emergency"
@@ -30,7 +30,7 @@ class AlarmSeverity(str, Enum):
"""Informational only; ack with one tap.""" """Informational only; ack with one tap."""
class AlarmType(str, Enum): class AlarmType(StrEnum):
"""The fixed catalogue of alarms emitted by the firmware. """The fixed catalogue of alarms emitted by the firmware.
Adding an entry here is a deliberate, reviewed change — the firmware Adding an entry here is a deliberate, reviewed change — the firmware
@@ -89,7 +89,7 @@ class Alarm(BaseModel):
type: AlarmType type: AlarmType
severity: AlarmSeverity severity: AlarmSeverity
message: str = Field(min_length=1, max_length=240) 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( source: str = Field(
default="firmware", default="firmware",
description="Subsystem that raised the alarm (e.g. 'firmware', 'display', 'vms').", description="Subsystem that raised the alarm (e.g. 'firmware', 'display', 'vms').",
@@ -105,7 +105,7 @@ class Alarm(BaseModel):
*, *,
message: str | None = None, message: str | None = None,
source: str = "firmware", source: str = "firmware",
) -> "Alarm": ) -> Alarm:
"""Convenience constructor using catalogue defaults.""" """Convenience constructor using catalogue defaults."""
return cls( return cls(
type=alarm_type, type=alarm_type,
+11 -11
View File
@@ -11,13 +11,13 @@ happens in the ESP32 firmware (Sprint 7).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
class KnobMode(str, Enum): class KnobMode(StrEnum):
"""High-level state of the bridge knob.""" """High-level state of the bridge knob."""
LIBRE = "libre" LIBRE = "libre"
@@ -30,7 +30,7 @@ class KnobMode(str, Enum):
"""Pending value being shown for operator confirmation.""" """Pending value being shown for operator confirmation."""
class KnobFunction(str, Enum): class KnobFunction(StrEnum):
"""The set of values the knob may target once armed.""" """The set of values the knob may target once armed."""
NONE = "none" NONE = "none"
@@ -84,7 +84,7 @@ class KnobState(BaseModel):
) )
@model_validator(mode="after") @model_validator(mode="after")
def _consistency(self) -> "KnobState": def _consistency(self) -> KnobState:
if self.mode == KnobMode.LIBRE: if self.mode == KnobMode.LIBRE:
if self.function != KnobFunction.NONE: if self.function != KnobFunction.NONE:
raise ValueError("LIBRE mode requires function == NONE") raise ValueError("LIBRE mode requires function == NONE")
@@ -105,7 +105,7 @@ class KnobState(BaseModel):
# --- Pure transition helpers (return new immutable states) -------------- # --- Pure transition helpers (return new immutable states) --------------
@classmethod @classmethod
def idle(cls) -> "KnobState": def idle(cls) -> KnobState:
"""Construct the canonical idle (LIBRE) state.""" """Construct the canonical idle (LIBRE) state."""
return cls() return cls()
@@ -116,7 +116,7 @@ class KnobState(BaseModel):
current_value: float, current_value: float,
timeout_s: float = DEFAULT_ARMED_TIMEOUT_S, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S,
now: datetime | None = None, now: datetime | None = None,
) -> "KnobState": ) -> KnobState:
"""Transition LIBRE → ARMADO for the given function.""" """Transition LIBRE → ARMADO for the given function."""
if self.mode != KnobMode.LIBRE: if self.mode != KnobMode.LIBRE:
raise ValueError(f"Cannot arm from mode {self.mode.value}") raise ValueError(f"Cannot arm from mode {self.mode.value}")
@@ -127,11 +127,11 @@ class KnobState(BaseModel):
function=function, function=function,
current_value=current_value, current_value=current_value,
pending_value=None, pending_value=None,
armed_at=now or datetime.now(timezone.utc), armed_at=now or datetime.now(UTC),
timeout_remaining_s=timeout_s, 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).""" """Operator turned the knob: stage a pending value (ARMADO → CONFIRMANDO)."""
if self.mode not in (KnobMode.ARMADO, KnobMode.CONFIRMANDO): if self.mode not in (KnobMode.ARMADO, KnobMode.CONFIRMANDO):
raise ValueError(f"Cannot propose from mode {self.mode.value}") raise ValueError(f"Cannot propose from mode {self.mode.value}")
@@ -144,7 +144,7 @@ class KnobState(BaseModel):
timeout_remaining_s=timeout_s, 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.""" """Operator pressed to confirm; commits ``pending_value`` and stays armed."""
if self.mode != KnobMode.CONFIRMANDO: if self.mode != KnobMode.CONFIRMANDO:
raise ValueError(f"Cannot confirm from mode {self.mode.value}") raise ValueError(f"Cannot confirm from mode {self.mode.value}")
@@ -157,6 +157,6 @@ class KnobState(BaseModel):
timeout_remaining_s=timeout_s, 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).""" """Force the knob back to LIBRE (timeout, alarm, mode change, long-press)."""
return KnobState.idle() return KnobState.idle()
+2 -2
View File
@@ -7,10 +7,10 @@ compatibility.
from __future__ import annotations 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. """The complete set of autopilot operating modes, across both phases.
Use :func:`is_available_in_phase` to query phase gating instead of Use :func:`is_available_in_phase` to query phase gating instead of
+3 -3
View File
@@ -11,12 +11,12 @@ Mirrors section 6 of the brief:
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator 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).""" """Three-level RBAC for PID tuning (brief section 6)."""
OPERATOR = "operator" OPERATOR = "operator"
@@ -133,7 +133,7 @@ class PidConfig(BaseModel):
return v return v
@model_validator(mode="after") @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: if self.inner_loop_freq_hz <= self.outer_loop_freq_hz:
raise ValueError( raise ValueError(
"inner_loop_freq_hz must be strictly greater than outer_loop_freq_hz " "inner_loop_freq_hz must be strictly greater than outer_loop_freq_hz "
+9 -9
View File
@@ -8,7 +8,7 @@ transport into the firmware build pipeline.
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -38,8 +38,8 @@ class ProjectConfig(BaseModel):
vessel: VesselConfig vessel: VesselConfig
created_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(timezone.utc)) modified_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- Serialisation ------------------------------------------------------- # --- Serialisation -------------------------------------------------------
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
@@ -75,19 +75,19 @@ class ProjectConfig(BaseModel):
# --- Deserialisation ----------------------------------------------------- # --- Deserialisation -----------------------------------------------------
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> "ProjectConfig": def from_dict(cls, data: dict[str, Any]) -> ProjectConfig:
return cls.model_validate(data) return cls.model_validate(data)
@classmethod @classmethod
def from_json(cls, text: str) -> "ProjectConfig": def from_json(cls, text: str) -> ProjectConfig:
return cls.model_validate(json.loads(text)) return cls.model_validate(json.loads(text))
@classmethod @classmethod
def from_yaml(cls, text: str) -> "ProjectConfig": def from_yaml(cls, text: str) -> ProjectConfig:
return cls.model_validate(yaml.safe_load(text)) return cls.model_validate(yaml.safe_load(text))
@classmethod @classmethod
def load(cls, path: Path | str) -> "ProjectConfig": def load(cls, path: Path | str) -> ProjectConfig:
"""Load from disk; format inferred from the file extension.""" """Load from disk; format inferred from the file extension."""
p = Path(path) p = Path(path)
text = p.read_text(encoding="utf-8") text = p.read_text(encoding="utf-8")
@@ -98,6 +98,6 @@ class ProjectConfig(BaseModel):
return cls.from_json(text) return cls.from_json(text)
raise ValueError(f"Unsupported project file extension: {suffix!r}") 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 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)})
+2 -2
View File
@@ -6,7 +6,7 @@ of every ``ProjectConfig``.
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field 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 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).""" """Vessel classes targeted by Phase 1 of the product (brief section 3)."""
YACHT_MOTOR_PLANEO = "yacht_motor_planeo" YACHT_MOTOR_PLANEO = "yacht_motor_planeo"
+10 -4
View File
@@ -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]: 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]: def _read_yaml_resource(package: str, filename: str) -> dict[str, Any]:
return yaml.safe_load( text = resources.files(package).joinpath(filename).read_text(encoding="utf-8")
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
+4 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC
import pytest import pytest
from arautopilot.core.knob_state import ( 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: def test_libre_with_armed_at_set_invalid() -> None:
from datetime import datetime, timezone from datetime import datetime
with pytest.raises(ValueError): with pytest.raises(ValueError):
KnobState( KnobState(
mode=KnobMode.LIBRE, mode=KnobMode.LIBRE,
function=KnobFunction.NONE, function=KnobFunction.NONE,
armed_at=datetime.now(timezone.utc), armed_at=datetime.now(UTC),
) )