mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
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:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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"))}" .',
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
Reference in New Issue
Block a user