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
+2 -2
View File
@@ -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)
+6 -6
View File
@@ -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,
+11 -11
View File
@@ -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()
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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 "
+9 -9
View File
@@ -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)})
+2 -2
View File
@@ -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"
+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]:
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
+4 -2
View File
@@ -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),
)