From 264767f91f2f149d548aab5d3acd38c8fc8b4bc6 Mon Sep 17 00:00:00 2001 From: Nikolay Miroshnichenko Date: Sat, 12 Sep 2026 21:36:23 +0200 Subject: [PATCH] ci: ruff + mypy gates, Python 3.13/3.14 in the matrix, Dependabot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/dependabot.yml | 27 +++++++++++++++++++++++++++ .github/workflows/ci.yml | 19 ++++++++++++++++++- nickol_knx_mcp/advanced.py | 5 +++-- nickol_knx_mcp/appprog_parser.py | 12 +++++++----- nickol_knx_mcp/dpt_map.py | 4 ++-- nickol_knx_mcp/explain.py | 2 +- nickol_knx_mcp/generate_ets.py | 2 +- nickol_knx_mcp/handover.py | 3 ++- nickol_knx_mcp/iot.py | 2 +- nickol_knx_mcp/project.py | 4 ++-- nickol_knx_mcp/repair.py | 9 ++++----- nickol_knx_mcp/room_library.py | 2 +- nickol_knx_mcp/suggest.py | 2 +- pyproject.toml | 15 +++++++++++++++ tests/test_appprog_parser.py | 2 +- tests/test_pipeline.py | 2 +- tests/test_safexml.py | 1 - tests/test_suggest.py | 2 +- 18 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..90e5f68 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index badcf5f..532df58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,29 @@ on: branches: [main] 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: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/nickol_knx_mcp/advanced.py b/nickol_knx_mcp/advanced.py index af2af77..f9a26cf 100644 --- a/nickol_knx_mcp/advanced.py +++ b/nickol_knx_mcp/advanced.py @@ -16,7 +16,7 @@ from collections import Counter from typing import Any from .project import LoadedProject -from .pairing import find_status, base_tokens +from .pairing import find_status from .intent import INTENT_FUNCTIONAL @@ -71,7 +71,8 @@ _MATTER = { def matter_readiness(project: LoadedProject) -> dict[str, Any]: """Which controllable functions round-trip to a Matter cluster, and what's missing.""" stats = _status_gas(project) - ready, not_ready = [], [] + ready: list[dict[str, Any]] = [] + not_ready: list[dict[str, Any]] = [] no_cluster: Counter = Counter() for ga in _functional_commands(project): cluster = _MATTER.get(ga.category) diff --git a/nickol_knx_mcp/appprog_parser.py b/nickol_knx_mcp/appprog_parser.py index 279c37b..be8cebe 100644 --- a/nickol_knx_mcp/appprog_parser.py +++ b/nickol_knx_mcp/appprog_parser.py @@ -31,7 +31,7 @@ _HW_SPLIT_RE = re.compile(r" 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).""" m = _CH_TOKEN_RE.search(text or "") 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 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]: """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: text = o["name"] or "" if _LF_RE.match(text): @@ -301,9 +303,9 @@ def parse_project(path: str, password: Optional[str] = None) -> dict[str, Any]: continue if app_path not in app_cache: 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), - ver.group(1) if ver else None) + verm.group(1) if verm else None) objs, ver = app_cache[app_path] bd = _detect_blocks(objs) no_dpt = sum(1 for o in objs if o["dpt"] is None) diff --git a/nickol_knx_mcp/dpt_map.py b/nickol_knx_mcp/dpt_map.py index 4b97072..f5ad248 100644 --- a/nickol_knx_mcp/dpt_map.py +++ b/nickol_knx_mcp/dpt_map.py @@ -134,8 +134,8 @@ def classify_dpt(main: Optional[int], sub: Optional[int]) -> DptInfo: return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN, "ha_platform": "unknown", "value_type": None, "label": "No DPT assigned"} if (main, sub) in _EXACT: - return dict(_EXACT[(main, sub)]) # copy + return _EXACT[(main, sub)].copy() # copy if main in _MAIN_FALLBACK: - return dict(_MAIN_FALLBACK[main]) + return _MAIN_FALLBACK[main].copy() return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN, "ha_platform": "unknown", "value_type": None, "label": f"DPT {main}"} diff --git a/nickol_knx_mcp/explain.py b/nickol_knx_mcp/explain.py index 9b6d989..8f017ff 100644 --- a/nickol_knx_mcp/explain.py +++ b/nickol_knx_mcp/explain.py @@ -17,7 +17,7 @@ misclassification hides. """ from __future__ import annotations -from typing import Any, Optional +from typing import Any from .project import (LoadedProject, _override_kind_by_name, _domain_from_text, _SOFT_DPT) diff --git a/nickol_knx_mcp/generate_ets.py b/nickol_knx_mcp/generate_ets.py index f5256e0..e954306 100644 --- a/nickol_knx_mcp/generate_ets.py +++ b/nickol_knx_mcp/generate_ets.py @@ -12,7 +12,7 @@ from __future__ import annotations import csv import io -from typing import Any, Optional +from typing import Any from xml.sax.saxutils import escape from .project import LoadedProject diff --git a/nickol_knx_mcp/handover.py b/nickol_knx_mcp/handover.py index 5af61c1..b91b31f 100644 --- a/nickol_knx_mcp/handover.py +++ b/nickol_knx_mcp/handover.py @@ -88,12 +88,13 @@ def _feedback_coverage(project: LoadedProject, total = len(commands) with_status = max(total - gaps, 0) pct = round(100 * with_status / total) if total else 0 - return { + summary: dict[str, Any] = { "commands": total, "with_status": with_status, "missing": gaps, "pct": pct, "formula": f"{with_status} / {total} functional command GAs have a status = {pct}%" if total else "no functional command GAs — coverage undefined", } + return summary # --------------------------------------------------------------------------- # diff --git a/nickol_knx_mcp/iot.py b/nickol_knx_mcp/iot.py index e421080..d937ed6 100644 --- a/nickol_knx_mcp/iot.py +++ b/nickol_knx_mcp/iot.py @@ -34,7 +34,7 @@ def generate_knx_iot_turtle(project: LoadedProject) -> str: "# Pragmatic skeleton (datapoint per functional group address), NOT the full", "# 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' knx:groupAddressStyle "{_esc(project.info.get("group_address_style","ThreeLevel"))}" .', "", diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index de99494..3bd3c21 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -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). if not (has_shutter_role or has_move): continue - for rec in recs: - _promote_to_shutter(rec, has_move) + for member in recs: + _promote_to_shutter(member, has_move) return LoadedProject( path=path, diff --git a/nickol_knx_mcp/repair.py b/nickol_knx_mcp/repair.py index ad07fca..48f534a 100644 --- a/nickol_knx_mcp/repair.py +++ b/nickol_knx_mcp/repair.py @@ -14,7 +14,6 @@ from typing import Any, Optional from .project import LoadedProject 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 @@ -125,11 +124,11 @@ def suggest_repairs(project: LoadedProject) -> dict[str, Any]: if f["code"] != "missing_status_address": continue addr = f["address"] - ga = project.gas.get(addr) - if ga is None: + sga = project.gas.get(addr) + if sga is None: continue - sdpt = _STATUS_DPT.get(ga.dpt_main, "1.011") - new = _next_free(used, ga.main if ga.main is not None else 1, prefer_middle=4) + sdpt = _STATUS_DPT.get(sga.dpt_main, "1.011") if sga.dpt_main is not None else "1.011" + new = _next_free(used, sga.main if sga.main is not None else 1, prefer_middle=4) proposals.append({ "code": "missing_status", "action": "add_ga", "address": new, "for": addr, "name": f"{ga.name}{_suffix(ga.name, ' (статус)', ' (status)')}", "dpt": sdpt, diff --git a/nickol_knx_mcp/room_library.py b/nickol_knx_mcp/room_library.py index 50ec18d..7f8882a 100644 --- a/nickol_knx_mcp/room_library.py +++ b/nickol_knx_mcp/room_library.py @@ -426,7 +426,7 @@ def validate_template(tmpl: dict[str, Any]) -> dict[str, Any]: findings.append(_finding("error", "slot_id_duplicate", f"Slot id '{sid}' is used more than once.", slot=sid)) else: - seen_slots.add(sid) + seen_slots.add(str(sid)) slabels = slot.get("labels") or {} for lang in SUPPORTED_LANGUAGES: if not (slabels.get(lang) or "").strip(): diff --git a/nickol_knx_mcp/suggest.py b/nickol_knx_mcp/suggest.py index 2d3ecbe..4df41d0 100644 --- a/nickol_knx_mcp/suggest.py +++ b/nickol_knx_mcp/suggest.py @@ -240,7 +240,7 @@ def _classify_channel(project: dict[str, Any], links: list[_Link], chan_name: st return None evidence.append("object_text") 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": review.append("measured temperature taken from the thermostat's external-sensor INPUT object") if sp_cmd: diff --git a/pyproject.toml b/pyproject.toml index 61c19b0..a8285fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,18 @@ packages = ["nickol_knx_mcp"] [tool.setuptools.package-data] 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"] diff --git a/tests/test_appprog_parser.py b/tests/test_appprog_parser.py index 9bee625..6969aef 100644 --- a/tests/test_appprog_parser.py +++ b/tests/test_appprog_parser.py @@ -2,7 +2,7 @@ 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__)))) from nickol_knx_mcp.appprog_parser import parse_project, to_catalog_yaml, summary diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 77b2c95..4f125f8 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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. # --------------------------------------------------------------------------- # 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) _hmd = _hp["markdown"] diff --git a/tests/test_safexml.py b/tests/test_safexml.py index d7d8850..366046d 100644 --- a/tests/test_safexml.py +++ b/tests/test_safexml.py @@ -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 refused, while an honest small archive/XML still parses. """ -import io import os import tempfile import zipfile diff --git a/tests/test_suggest.py b/tests/test_suggest.py index 2190438..255220d 100644 --- a/tests/test_suggest.py +++ b/tests/test_suggest.py @@ -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 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}, {"main": 7, "sub": 600}, {"main": 232, "sub": 600}, {"main": 1, "sub": 8},