Files
nickol-knx-mcp/tests/test_appprog_parser.py
T
Nikolay Miroshnichenko 264767f91f 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.
2026-09-12 21:36:23 +02:00

85 lines
4.2 KiB
Python

"""appprog_parser: extract exact device object models from a (synthetic) .knxprod.
Self-contained: builds a minimal ETS-shaped archive in memory — no real project needed.
"""
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
import nickol_knx_mcp.device_library as dl
HARDWARE = """<?xml version="1.0" encoding="utf-8"?>
<KNX><ManufacturerData><Manufacturer RefId="M-0071"><Hardware>
<Hardware Id="M-0071_H-TST-1" Name="Test Dimmer 2CH" SerialNumber="TST-DIM2">
<Products><Product Id="M-0071_H-TST-1_P-1" OrderNumber="TST-DIM2" /></Products>
<Hardware2Programs><Hardware2Program>
<ApplicationProgramRef RefId="M-0071_A-TEST-1-0" />
</Hardware2Program></Hardware2Programs>
</Hardware>
</Hardware></Manufacturer></ManufacturerData></KNX>"""
APPPROG = """<?xml version="1.0" encoding="utf-8"?>
<KNX><ManufacturerData><Manufacturer><ApplicationPrograms>
<ApplicationProgram Id="M-0071_A-TEST-1-0" ApplicationVersion="7"><Static><ComObjectTable>
<ComObject Id="O-1" Name="oX.ch[0].onOff" Text="[C1] On/Off" Number="1" FunctionText="on/off" ObjectSize="1 Bit" ReadFlag="Disabled" WriteFlag="Enabled" CommunicationFlag="Enabled" TransmitFlag="Disabled" UpdateFlag="Disabled" DatapointType="DPST-1-1" />
<ComObject Id="O-2" Name="oX.ch[0].dim" Text="[C1] Relative Dimming" Number="2" ObjectSize="4 Bit" WriteFlag="Enabled" CommunicationFlag="Enabled" DatapointType="DPST-3-7" />
<ComObject Id="O-3" Name="oX.ch[1].onOff" Text="[C2] On/Off" Number="3" ObjectSize="1 Bit" WriteFlag="Enabled" CommunicationFlag="Enabled" DatapointType="DPST-1-1" />
<ComObject Id="O-4" Name="oX.ch[1].dim" Text="[C2] Relative Dimming" Number="4" ObjectSize="4 Bit" WriteFlag="Enabled" CommunicationFlag="Enabled" DatapointType="DPST-3-7" />
<ComObject Id="O-5" Name="oX.pattern" Text="[LF] Pattern" Number="5" ObjectSize="1 Byte" WriteFlag="Enabled" CommunicationFlag="Enabled" />
</ComObjectTable></Static></ApplicationProgram>
</ApplicationPrograms></Manufacturer></ManufacturerData></KNX>"""
def _make_archive(path):
with zipfile.ZipFile(path, "w") as z:
z.writestr("P-1/0.xml", "<KNX/>") # a 'client project' the parser must NOT read
z.writestr("M-0071/Hardware.xml", HARDWARE)
z.writestr("M-0071/M-0071_A-TEST-1-0.xml", APPPROG)
with tempfile.TemporaryDirectory() as d:
arc = os.path.join(d, "sample.knxprod")
_make_archive(arc)
res = parse_project(arc)
cov = res["coverage"]
assert cov["devices_parsed"] == 1, cov
assert cov["objects_total"] == 5, cov
assert cov["objects_without_dpt"] == 1, cov # the [LF] Pattern, no DPT
dev = res["devices"][0]
assert dev["order_number"] == "TST-DIM2", dev
assert dev["application_program"]["version"] == "v7", dev
assert dev["object_counts"]["master_catalog_total"] == 5
# DPT conversion DPST-x-y -> x.00y
o2 = [o for o in dev["comm_objects"] if o["number"] == 2][0]
assert o2["dpt"] == "3.007", o2
o1 = [o for o in dev["comm_objects"] if o["number"] == 1][0]
assert o1["dpt"] == "1.001" and o1["role"] == "cmd", o1
o5 = [o for o in dev["comm_objects"] if o["number"] == 5][0]
assert o5["dpt"] is None, "missing DatapointType must stay None, never guessed"
# per-channel block detection: [C1]/[C2] -> 2 instances of 2 objects, stride 2
blk = [b for b in dev["blocks"] if b["unit"] == "C"][0]
assert blk["instances"] == 2 and blk["objects_per_instance"] == 2, blk
assert blk["stride"] == 2, blk
# summary is compact (no full object lists)
sm = summary(res)
assert "comm_objects" not in sm["devices"][0]
# round-trip: emit YAML -> load as catalog -> decompose_device is catalog-exact
cat = os.path.join(d, "cat.yaml")
with open(cat, "w", encoding="utf-8") as fh:
fh.write(to_catalog_yaml(res))
os.environ["NICKOL_KNX_CATALOG"] = d
dl._CATALOG_INDEX = None
r = dl.decompose_device("TST-DIM2")
assert r["source"] == "catalog-exact", r
assert r["total_master_objects"] == 5, r
os.environ.pop("NICKOL_KNX_CATALOG", None)
dl._CATALOG_INDEX = None
print("OK — appprog_parser: order->app map, DPST->DPT, honest missing-DPT, block/stride, YAML round-trip")