ci: ruff + mypy gates, Python 3.13/3.14 in the matrix, Dependabot

The CI ran tests only. Added a lint job with two narrow gates:

ruff with select = E9,F — syntax errors and pyflakes (undefined names, unused
imports, broken f-strings). Style rules are deliberately off: the full default
set flagged 202 things, almost all cosmetic, and a gate nobody reads is not a
gate. Fixed the 9 findings it did have (8 unused imports, 1 empty f-string).

mypy, clean at 13 errors fixed. None of the 13 was a live bug, so every fix is
an annotation or a local rename: the two accumulators in advanced, the block map
in appprog_parser, a reused loop name in project and in repair, a TypedDict lost
through dict(), a mixed-type summary dict in handover. Two are worth naming.
_CH_TOKEN_RE now captures the letter prefix directly instead of re-matching its
own capture, which removes an unguarded .group() on a possibly-None match; the
regex accepts exactly the same strings. repair's status-DPT lookup now spells out
the None case, which is what dict.get already did since None is not a key there.

Verified the fixes moved nothing: the full suite passes, and the corpus guard
reports no drift across all six real projects, the 3646-GA one included.

Matrix extended to 3.13 and 3.14; both were smoke-tested locally first.
Dependabot config for pip and github-actions, weekly and grouped so a quiet week
is one PR. Dependabot security updates and CodeQL default setup enabled in the
repository settings.
This commit is contained in:
Nikolay Miroshnichenko
2026-09-12 21:36:23 +02:00
parent 4eb54da53c
commit 264767f91f
18 changed files with 88 additions and 27 deletions
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
# Runtime dependencies. Grouped so a quiet week produces one PR, not five.
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
python-deps:
patterns: ["*"]
commit-message:
prefix: deps
# Action versions. These are pinned by tag and go stale silently.
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
groups:
actions:
patterns: ["*"]
commit-message:
prefix: ci
+18 -1
View File
@@ -7,12 +7,29 @@ on:
branches: [main] branches: [main]
jobs: jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install package and tools
run: |
python -m pip install --upgrade pip
pip install -e . ruff mypy
- name: Lint (ruff — errors only, no style rules)
run: ruff check nickol_knx_mcp tests tools
- name: Types (mypy)
run: mypy
smoke-test: smoke-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+3 -2
View File
@@ -16,7 +16,7 @@ from collections import Counter
from typing import Any from typing import Any
from .project import LoadedProject from .project import LoadedProject
from .pairing import find_status, base_tokens from .pairing import find_status
from .intent import INTENT_FUNCTIONAL from .intent import INTENT_FUNCTIONAL
@@ -71,7 +71,8 @@ _MATTER = {
def matter_readiness(project: LoadedProject) -> dict[str, Any]: def matter_readiness(project: LoadedProject) -> dict[str, Any]:
"""Which controllable functions round-trip to a Matter cluster, and what's missing.""" """Which controllable functions round-trip to a Matter cluster, and what's missing."""
stats = _status_gas(project) stats = _status_gas(project)
ready, not_ready = [], [] ready: list[dict[str, Any]] = []
not_ready: list[dict[str, Any]] = []
no_cluster: Counter = Counter() no_cluster: Counter = Counter()
for ga in _functional_commands(project): for ga in _functional_commands(project):
cluster = _MATTER.get(ga.category) cluster = _MATTER.get(ga.category)
+7 -5
View File
@@ -31,7 +31,7 @@ _HW_SPLIT_RE = re.compile(r"<Hardware\b")
_APPREF_RE = re.compile(r'<ApplicationProgramRef\s+RefId="([^"]+)"') _APPREF_RE = re.compile(r'<ApplicationProgramRef\s+RefId="([^"]+)"')
_APPVER_RE = re.compile(r'ApplicationVersion="(\d+)"') _APPVER_RE = re.compile(r'ApplicationVersion="(\d+)"')
_SIZE_RE = re.compile(r"(\d+)\s*(Bit|Byte)", re.I) _SIZE_RE = re.compile(r"(\d+)\s*(Bit|Byte)", re.I)
_CH_TOKEN_RE = re.compile(r"\[([A-Za-z]{1,3}\d+)\]") # display token: [C1] [O12] [T3] _CH_TOKEN_RE = re.compile(r"\[([A-Za-z]{1,3})\d+\]") # display token: [C1] [O12] [T3]
_LF_RE = re.compile(r"^\s*\[LF\]") _LF_RE = re.compile(r"^\s*\[LF\]")
# KNX manufacturer id (hex in the M-code) -> vendor name (common ones; extend freely) # KNX manufacturer id (hex in the M-code) -> vendor name (common ones; extend freely)
@@ -87,7 +87,7 @@ def _channel_token(text: str, name: str) -> Optional[str]:
"""Return the repeating-block key for an object, e.g. 'C', 'O', 'T', or None (general).""" """Return the repeating-block key for an object, e.g. 'C', 'O', 'T', or None (general)."""
m = _CH_TOKEN_RE.search(text or "") m = _CH_TOKEN_RE.search(text or "")
if m: if m:
return re.match(r"([A-Za-z]{1,3})", m.group(1)).group(1) return m.group(1)
m = re.search(r"\bch\[(\d+)\]", name or "") # internal 'oX.ch[0].y' form m = re.search(r"\bch\[(\d+)\]", name or "") # internal 'oX.ch[0].y' form
return "ch" if m else None return "ch" if m else None
@@ -169,7 +169,9 @@ def _parse_comobjects(xml_text: str) -> list[dict[str, Any]]:
def _detect_blocks(objs: list[dict[str, Any]]) -> dict[str, Any]: def _detect_blocks(objs: list[dict[str, Any]]) -> dict[str, Any]:
"""Best-effort per-channel block/stride + general/[LF] split from object names.""" """Best-effort per-channel block/stride + general/[LF] split from object names."""
general, lf, chan = [], [], {} general: list = []
lf: list = []
chan: dict[str, Any] = {}
for o in objs: for o in objs:
text = o["name"] or "" text = o["name"] or ""
if _LF_RE.match(text): if _LF_RE.match(text):
@@ -301,9 +303,9 @@ def parse_project(path: str, password: Optional[str] = None) -> dict[str, Any]:
continue continue
if app_path not in app_cache: if app_path not in app_cache:
xml_text = safe_read(z, app_path, pwd).decode("utf-8", "replace") xml_text = safe_read(z, app_path, pwd).decode("utf-8", "replace")
ver = _APPVER_RE.search(xml_text[:8000]) verm = _APPVER_RE.search(xml_text[:8000])
app_cache[app_path] = (_parse_comobjects(xml_text), app_cache[app_path] = (_parse_comobjects(xml_text),
ver.group(1) if ver else None) verm.group(1) if verm else None)
objs, ver = app_cache[app_path] objs, ver = app_cache[app_path]
bd = _detect_blocks(objs) bd = _detect_blocks(objs)
no_dpt = sum(1 for o in objs if o["dpt"] is None) no_dpt = sum(1 for o in objs if o["dpt"] is None)
+2 -2
View File
@@ -134,8 +134,8 @@ def classify_dpt(main: Optional[int], sub: Optional[int]) -> DptInfo:
return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN, return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN,
"ha_platform": "unknown", "value_type": None, "label": "No DPT assigned"} "ha_platform": "unknown", "value_type": None, "label": "No DPT assigned"}
if (main, sub) in _EXACT: if (main, sub) in _EXACT:
return dict(_EXACT[(main, sub)]) # copy return _EXACT[(main, sub)].copy() # copy
if main in _MAIN_FALLBACK: if main in _MAIN_FALLBACK:
return dict(_MAIN_FALLBACK[main]) return _MAIN_FALLBACK[main].copy()
return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN, return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN,
"ha_platform": "unknown", "value_type": None, "label": f"DPT {main}"} "ha_platform": "unknown", "value_type": None, "label": f"DPT {main}"}
+1 -1
View File
@@ -17,7 +17,7 @@ misclassification hides.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any
from .project import (LoadedProject, _override_kind_by_name, _domain_from_text, from .project import (LoadedProject, _override_kind_by_name, _domain_from_text,
_SOFT_DPT) _SOFT_DPT)
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import csv import csv
import io import io
from typing import Any, Optional from typing import Any
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
from .project import LoadedProject from .project import LoadedProject
+2 -1
View File
@@ -88,12 +88,13 @@ def _feedback_coverage(project: LoadedProject,
total = len(commands) total = len(commands)
with_status = max(total - gaps, 0) with_status = max(total - gaps, 0)
pct = round(100 * with_status / total) if total else 0 pct = round(100 * with_status / total) if total else 0
return { summary: dict[str, Any] = {
"commands": total, "with_status": with_status, "missing": gaps, "commands": total, "with_status": with_status, "missing": gaps,
"pct": pct, "pct": pct,
"formula": f"{with_status} / {total} functional command GAs have a status = {pct}%" "formula": f"{with_status} / {total} functional command GAs have a status = {pct}%"
if total else "no functional command GAs — coverage undefined", if total else "no functional command GAs — coverage undefined",
} }
return summary
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+1 -1
View File
@@ -34,7 +34,7 @@ def generate_knx_iot_turtle(project: LoadedProject) -> str:
"# Pragmatic skeleton (datapoint per functional group address), NOT the full", "# Pragmatic skeleton (datapoint per functional group address), NOT the full",
"# certified KNX IoT ontology. Review before use.", "# certified KNX IoT ontology. Review before use.",
"", "",
f'proj:project a knx:Installation ;', 'proj:project a knx:Installation ;',
f' rdfs:label "{_esc(project.info.get("name","KNX project"))}" ;', f' rdfs:label "{_esc(project.info.get("name","KNX project"))}" ;',
f' knx:groupAddressStyle "{_esc(project.info.get("group_address_style","ThreeLevel"))}" .', f' knx:groupAddressStyle "{_esc(project.info.get("group_address_style","ThreeLevel"))}" .',
"", "",
+2 -2
View File
@@ -434,8 +434,8 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject:
# 15/29 real position feedbacks (issue #12 re-test by Kris1166). # 15/29 real position feedbacks (issue #12 re-test by Kris1166).
if not (has_shutter_role or has_move): if not (has_shutter_role or has_move):
continue continue
for rec in recs: for member in recs:
_promote_to_shutter(rec, has_move) _promote_to_shutter(member, has_move)
return LoadedProject( return LoadedProject(
path=path, path=path,
+4 -5
View File
@@ -14,7 +14,6 @@ from typing import Any, Optional
from .project import LoadedProject from .project import LoadedProject
from .analyze import detect_missing_status, detect_dpt_issues, _expected_subdpt from .analyze import detect_missing_status, detect_dpt_issues, _expected_subdpt
from .intent import INTENT_FUNCTIONAL
# command DPT main -> the status/feedback DPT to synthesise for it # command DPT main -> the status/feedback DPT to synthesise for it
@@ -125,11 +124,11 @@ def suggest_repairs(project: LoadedProject) -> dict[str, Any]:
if f["code"] != "missing_status_address": if f["code"] != "missing_status_address":
continue continue
addr = f["address"] addr = f["address"]
ga = project.gas.get(addr) sga = project.gas.get(addr)
if ga is None: if sga is None:
continue continue
sdpt = _STATUS_DPT.get(ga.dpt_main, "1.011") sdpt = _STATUS_DPT.get(sga.dpt_main, "1.011") if sga.dpt_main is not None else "1.011"
new = _next_free(used, ga.main if ga.main is not None else 1, prefer_middle=4) new = _next_free(used, sga.main if sga.main is not None else 1, prefer_middle=4)
proposals.append({ proposals.append({
"code": "missing_status", "action": "add_ga", "address": new, "for": addr, "code": "missing_status", "action": "add_ga", "address": new, "for": addr,
"name": f"{ga.name}{_suffix(ga.name, ' (статус)', ' (status)')}", "dpt": sdpt, "name": f"{ga.name}{_suffix(ga.name, ' (статус)', ' (status)')}", "dpt": sdpt,
+1 -1
View File
@@ -426,7 +426,7 @@ def validate_template(tmpl: dict[str, Any]) -> dict[str, Any]:
findings.append(_finding("error", "slot_id_duplicate", findings.append(_finding("error", "slot_id_duplicate",
f"Slot id '{sid}' is used more than once.", slot=sid)) f"Slot id '{sid}' is used more than once.", slot=sid))
else: else:
seen_slots.add(sid) seen_slots.add(str(sid))
slabels = slot.get("labels") or {} slabels = slot.get("labels") or {}
for lang in SUPPORTED_LANGUAGES: for lang in SUPPORTED_LANGUAGES:
if not (slabels.get(lang) or "").strip(): if not (slabels.get(lang) or "").strip():
+1 -1
View File
@@ -240,7 +240,7 @@ def _classify_channel(project: dict[str, Any], links: list[_Link], chan_name: st
return None return None
evidence.append("object_text") evidence.append("object_text")
cur = t_cur_src[0] cur = t_cur_src[0]
knx: dict[str, Any] = {"ga_temperature_current": _ga_conf(cur, "state")}; used.add(cur.ga) knx = {"ga_temperature_current": _ga_conf(cur, "state")}; used.add(cur.ga)
if cur.role == "sink": if cur.role == "sink":
review.append("measured temperature taken from the thermostat's external-sensor INPUT object") review.append("measured temperature taken from the thermostat's external-sensor INPUT object")
if sp_cmd: if sp_cmd:
+15
View File
@@ -43,3 +43,18 @@ packages = ["nickol_knx_mcp"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
nickol_knx_mcp = ["room_templates/*.yaml", "room_templates/*.md"] nickol_knx_mcp = ["room_templates/*.yaml", "room_templates/*.md"]
[tool.ruff]
target-version = "py310"
line-length = 110
[tool.ruff.lint]
# Deliberately narrow: real errors, not style. E9 = syntax/IO errors, F = pyflakes
# (undefined names, unused imports, broken f-strings). Style rules are left off so the
# gate stays meaningful instead of noisy.
select = ["E9", "F"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
files = ["nickol_knx_mcp"]
+1 -1
View File
@@ -2,7 +2,7 @@
Self-contained: builds a minimal ETS-shaped archive in memory — no real project needed. Self-contained: builds a minimal ETS-shaped archive in memory — no real project needed.
""" """
import sys, os, io, zipfile, tempfile import sys, os, zipfile, tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from nickol_knx_mcp.appprog_parser import parse_project, to_catalog_yaml, summary from nickol_knx_mcp.appprog_parser import parse_project, to_catalog_yaml, summary
+1 -1
View File
@@ -443,7 +443,7 @@ print("OK: full zone -> valid climate; mode-only zone -> review")
# Regression (Track B): handover pack assembles a document + valid SVG diagram. # Regression (Track B): handover pack assembles a document + valid SVG diagram.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
print("\n=== REGRESSION: handover pack (Track B) ===") print("\n=== REGRESSION: handover pack (Track B) ===")
from nickol_knx_mcp.handover import build_handover, build_topology_svg from nickol_knx_mcp.handover import build_handover
_hp = build_handover(proj) _hp = build_handover(proj)
_hmd = _hp["markdown"] _hmd = _hp["markdown"]
-1
View File
@@ -3,7 +3,6 @@ zip-bomb (tiny archive -> gigabytes) and an XML billion-laughs/XXE payload are
in scope. These tests build hostile fixtures in a temp dir and assert each is in scope. These tests build hostile fixtures in a temp dir and assert each is
refused, while an honest small archive/XML still parses. refused, while an honest small archive/XML still parses.
""" """
import io
import os import os
import tempfile import tempfile
import zipfile import zipfile
+1 -1
View File
@@ -5,7 +5,7 @@ group addresses are named "GA x/y/z" — NO semantics in names — and there are
functional blocks. A structure-first provider must still produce the same configs functional blocks. A structure-first provider must still produce the same configs
the FB provider produces from DPAs, purely from device channels + object flags + DPTs. the FB provider produces from DPAs, purely from device channels + object flags + DPTs.
""" """
from nickol_knx_mcp.suggest import suggest_entities, FB_COVERED from nickol_knx_mcp.suggest import suggest_entities
SW, PCT, CT, RGB, UPD, STEP, STOP, TEMP, MODE = ({"main": 1, "sub": 1}, {"main": 5, "sub": 1}, SW, PCT, CT, RGB, UPD, STEP, STOP, TEMP, MODE = ({"main": 1, "sub": 1}, {"main": 5, "sub": 1},
{"main": 7, "sub": 600}, {"main": 232, "sub": 600}, {"main": 1, "sub": 8}, {"main": 7, "sub": 600}, {"main": 232, "sub": 600}, {"main": 1, "sub": 8},