ci: run mypy against the installed package, fix what that exposed

The lint job failed on its first run with three errors my local run had not
shown. Cause: locally mypy had no xknxproject installed, so ignore_missing_imports
hid every mismatch against that library's own TypedDicts. CI installs the package
and therefore sees them.

The two range-walk helpers took dict[str, Any] but are handed a GroupRange
TypedDict, which mypy rejects because dict is invariant; they take Mapping now,
which is what they actually use. suggest passes the raw parsed dict into
build_loaded_from_raw, whose parameter is KNXProject, so the boundary is an
explicit cast rather than a widened public signature.

CONTRIBUTING now gives the command that matches CI, with the reason, so the next
person does not get a clean local run and a red CI.

Suite passes, corpus guard reports no drift.
This commit is contained in:
Nikolay Miroshnichenko
2026-09-12 21:38:54 +02:00
parent 264767f91f
commit af6d0e24c0
4 changed files with 18 additions and 4 deletions
+11
View File
@@ -59,6 +59,17 @@ pip install -e .
python tests/test_pipeline.py
```
### Lint and types
```bash
ruff check nickol_knx_mcp tests tools
uvx --with-editable . mypy # the package must be installed, or mypy misses xknxproject's types
```
Run mypy with the package installed. Without it, `ignore_missing_imports` silently hides every
mismatch against xknxproject's own TypedDicts, and CI, which does install it, will disagree.
Ruff is configured to `E9,F` only: real errors, no style rules.
CI runs the smoke test on Python 3.10–3.12 for every push and PR.
### Local corpus guard
+2 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import csv
import io
from collections.abc import Mapping
from typing import Any
from xml.sax.saxutils import escape
@@ -28,7 +29,7 @@ def _hierarchy(project: LoadedProject) -> dict[int, dict[str, Any]]:
main_names: dict[int, str] = {}
mid_names: dict[tuple[int, int], str] = {}
def walk(rng: dict[str, Any]) -> None:
def walk(rng: Mapping[str, Any]) -> None:
start = rng.get("address_start")
name = rng.get("name", "")
if isinstance(start, int):
+2 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
from collections.abc import Mapping
from typing import Any, Optional
from xknxproject import XKNXProj
@@ -293,7 +294,7 @@ def _build_range_name_map(raw: KNXProject) -> dict[str, str]:
"""
out: dict[str, str] = {}
def walk(rng: dict[str, Any], depth: int) -> None:
def walk(rng: Mapping[str, Any], depth: int) -> None:
start = rng.get("address_start")
name = rng.get("name", "")
if isinstance(start, int):
+3 -2
View File
@@ -27,7 +27,7 @@ from __future__ import annotations
import re
from collections import defaultdict
from typing import Any, Optional
from typing import Any, Optional, cast
import yaml
@@ -583,7 +583,8 @@ def _device_of(project: dict[str, Any], ga: str) -> tuple[str, str]:
def _fallback_suggestions(project: dict[str, Any], covered: set[str]) -> list[dict[str, Any]]:
loaded = build_loaded_from_raw(project, project.get("info", {}).get("name") or "project.knxproj")
loaded = build_loaded_from_raw(cast("Any", project),
project.get("info", {}).get("name") or "project.knxproj")
res = generate_ha_yaml(loaded)
doc = yaml.safe_load(res["yaml"]) or {}
knx = doc.get("knx") or {}