From 4f1a55d6fb60e2a0f0f1500fe08ee825b5454c51 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:12:28 +0200 Subject: [PATCH 1/2] feat(docs): add unified documentation synchronization sentinel and tests --- .github/workflows/ci.yml | 2 + README.md | 14 +- scripts/sync_documentation.py | 525 ++++++++++++++++++++++++++++++ scripts/update_readme_coverage.py | 2 +- tests/test_sync_documentation.py | 305 +++++++++++++++++ 5 files changed, 841 insertions(+), 7 deletions(-) create mode 100644 scripts/sync_documentation.py create mode 100644 tests/test_sync_documentation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1e4f51..f183641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,8 @@ jobs: run: mypy OWNd tests/typing - name: Enforce PyPI library standards & clean decoupling run: python scripts/verify_library_standards.py + - name: Check documentation synchronization + run: python scripts/sync_documentation.py --check package: name: Build package diff --git a/README.md b/README.md index ed3a1aa..3facaf1 100755 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ It powers the [Home Assistant MyHOME integration](https://github.com/OpenWebNet-HA/MyHOME) and serves as a standalone Python client for discovering, monitoring, and controlling OpenWebNet bus devices over TCP/IP gateways and serial USB interfaces. > [!TIP] -> **🚀 V2 Phase 2 Architecture Now Live**: Phase 2 architecture is active across **OWNd** and **MyHOME**! Featuring strongly typed CEN / CEN+ scenario command builders and device triggers (**P2**), Thermoregulation Central Unit (3550 / 4695) master mode and zone coordination (**P4**), Multi-Gateway routing and plant isolation (**P6**), DALI Tunable White support, and 100.0% test coverage verified against the OpenWebNet Golden Corpus. +> **🚀 Phase 2 Architecture**: Phase 2 architecture powers **OWNd** and **MyHOME**! Featuring strongly typed CEN / CEN+ scenario command builders and device triggers (**P2**), Thermoregulation Central Unit (3550 / 4695) master mode and zone coordination (**P4**), Multi-Gateway routing and plant isolation (**P6**), DALI Tunable White support, and 100.0% test coverage verified against the OpenWebNet Golden Corpus. > [!NOTE] -> **🔒 Full `mypy --strict` compliance (2.0.0-b7)**: every module now passes strict static typing with zero suppressions, and the package ships a [PEP 561](https://peps.python.org/pep-0561/) `py.typed` marker so type checkers pick up OWNd's types in consuming projects (this is what unblocks the Home Assistant Platinum `strict-typing` quality-scale rule for MyHOME). Also in this release: a public `OWNSession.is_open` property, one canonical line ending across the repository, and a CI gate that replays [MyHOME](https://github.com/OpenWebNet-HA/MyHOME)'s golden corpus and test suite against every OWNd change before it ships. +> **🔒 Full `mypy --strict` compliance**: Every module passes strict static typing with zero suppressions, and the package ships a [PEP 561](https://peps.python.org/pep-0561/) `py.typed` marker so type checkers pick up OWNd's types in consuming projects (unblocking the Home Assistant Platinum `strict-typing` quality-scale rule for MyHOME). OWNd maintains a public `OWNSession.is_open` property, canonical line endings, and a CI gate that replays [MyHOME](https://github.com/OpenWebNet-HA/MyHOME)'s golden corpus and test suite against every OWNd change before it ships. --- @@ -36,7 +36,7 @@ It powers the [Home Assistant MyHOME integration](https://github.com/OpenWebNet- - Periodic application-level keepalives and passive watchdogs. - Non-blocking bounded timeouts on handshakes and commands to prevent event loop stalls. - Multi-frame response collection for large bus status sweeps (up to 256 frames). -- **Declarative Hardware Profiles**: Tailored queue pacing, session concurrency, and subsystem limits for known Legrand/BTicino hardware (F454, F455, MH200, MH200N, MH201, MH202, MyHomeServer1, and conservative generic fallbacks). +- **Declarative Hardware Profiles**: Tailored queue pacing, session concurrency, and subsystem limits for known Legrand/BTicino hardware (MyHomeServer1, F452, F452V, F453, F453AV, F454, F455, F461, H4890, MH202, MH201, MH200, MH200N, and conservative generic fallbacks). - **Modern Python & Strict 100% Test Coverage**: Designed for Python **3.11+**, tested continuously against Python 3.11, 3.12, 3.13, and 3.14 with strict **100.0% line coverage** unconditionally enforced across all core modules. --- @@ -72,6 +72,7 @@ pip install --pre OWNd OWNd parses OpenWebNet frames and dispatches typed commands and events across the full MyHOME spectrum: + | WHO | Subsystem | Description & Capabilities | Event / Command Classes | |:---:|:---|:---|:---| | **1** | Lighting | On/off switching, dimming level (0–100%), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries | `OWNLightingCommand`, `OWNLightingEvent` | @@ -82,9 +83,10 @@ OWNd parses OpenWebNet frames and dispatches typed commands and events across th | **13** | Gateway Diagnostics & Clock | Gateway date/time synchronization, timezone offsets, firmware metadata | `OWNGatewayCommand`, `OWNGatewayEvent` | | **15** | CEN Scenarios | Scenario control, pushbutton push/release/extended press events, strongly typed command builders | `OWNCenCommand`, `OWNCENEvent`, `OWNScenarioEvent` | | **16** / **22** | Sound Diffusion | Multi-source selection, zone activation, volume adjustment, F441 matrix | `OWNSoundCommand`, `OWNSoundEvent`, `OWNAVCommand` | -| **17** | Scenario Programmer | MH200N / MH202 scenario activation and state monitoring | `OWNSceneEvent` | +| **17** | Scenario Programmer | MH200N / MH202 scenario activation and state monitoring | `OWNSceneCommand`, `OWNSceneEvent` | | **18** | Energy Management | Active power (W), hourly/daily/monthly consumption (kWh), Stop & Go breaker diagnostics | `OWNEnergyCommand`, `OWNEnergyEvent` | | **25** | CEN+ & Dry Contacts | 32-button keypads, rotary knob encoders (CW/CCW), dry contacts, PIR sensors, strongly typed command builders | `OWNCenPlusCommand`, `OWNCENPlusEvent`, `OWNDryContactCommand`, `OWNDryContactEvent` | + --- @@ -312,7 +314,7 @@ This project is licensed under the **Apache License 2.0**, the same license as H ## 📊 Code Coverage & Quality Assurance -OWNd maintains an automated test suite with strict **100.0% line coverage** (3,087 / 3,087 statements covered with 0 missing lines across all 9 core modules) verified continuously in CI across Python 3.11, 3.12, 3.13, and 3.14: +OWNd maintains an automated test suite with strict **100.0% line coverage** verified continuously in CI across Python 3.11, 3.12, 3.13, and 3.14: @@ -337,7 +339,7 @@ OWNd maintains an automated test suite with strict **100.0% line coverage** (3,0 | [`OWNd/message/lighting.py`](OWNd/message/lighting.py) | **100%** | WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors | | [`OWNd/message/scenario.py`](OWNd/message/scenario.py) | **100%** | WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes | | [`OWNd/message/sound.py`](OWNd/message/sound.py) | **100%** | WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented) | -| [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** | Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1) | +| [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** | Declarative hardware gateway models across 14 canonical hardware profiles | | [`OWNd/transport/__init__.py`](OWNd/transport/__init__.py) | **100%** | Transport subpackage exports | | [`OWNd/transport/base.py`](OWNd/transport/base.py) | **100%** | Abstract transport layer and event listener notification contracts | | [`OWNd/transport/serial.py`](OWNd/transport/serial.py) | **100%** | Async Serial/USB transport for Legrand 3578 interface with in-band demux | diff --git a/scripts/sync_documentation.py b/scripts/sync_documentation.py new file mode 100644 index 0000000..ca90cf1 --- /dev/null +++ b/scripts/sync_documentation.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +"""Unified Documentation Synchronization & Anti-Drift Sentinel for OWNd. + +Maintains live, automated synchronization between the OWNd codebase, profiles, +coverage reports, and README.md: +1. Gateway Profiles table () + Cross-referenced against OWNd.profiles.canonical_profiles(). +2. Supported Subsystems (WHO Catalog) table () + Dynamically inspected from OWNd.message exports and WHO registries. +3. Code Coverage table () and coverage.svg badge + Derived from coverage.xml test execution reports. + +Modes: +- python scripts/sync_documentation.py: Updates README.md and coverage.svg in place. +- python scripts/sync_documentation.py --check: Verifies README.md is in sync, + exits 0 if in sync, or 1 with unified diff if drifted. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import difflib +import os +from pathlib import Path +import re +import sys +from typing import Any +import xml.etree.ElementTree as ET + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from OWNd.profiles import canonical_profiles # noqa: E402 + +README_MD = REPO_ROOT / "README.md" +COVERAGE_XML = REPO_ROOT / "coverage.xml" +COVERAGE_SVG = REPO_ROOT / "coverage.svg" + +# Sentinel block markers +GATEWAY_START_MARKER = "" +GATEWAY_END_MARKER = "" + +WHO_START_MARKER = "" +WHO_END_MARKER = "" + +COVERAGE_START_MARKER = "" +COVERAGE_END_MARKER = "" + +COMPONENT_NOTES: dict[str, str] = { + "OWNd/connection/__init__.py": "Hardened dual-session TCP engine & connection package exports", + "OWNd/connection/auth.py": "Pure cryptographic routines: Open password, HMAC-SHA1/SHA256, hex/dec conversions", + "OWNd/connection/command_session.py": "Command queue pacing, synchronized send, and request/reply tracking", + "OWNd/connection/event_session.py": "Long-lived event bus session, keepalives, and automatic reconnection", + "OWNd/connection/gateway.py": "Gateway configuration container, endpoint discovery, and profile mapping", + "OWNd/connection/session.py": "Base TCP transport, negotiation state machine, and frame buffering", + "OWNd/discovery.py": "SSDP multicast and UPnP XML gateway discovery and descriptor parsing", + "OWNd/message/__init__.py": "OpenWebNet message package exports & WHO subsystem registries", + "OWNd/message/alarm.py": "WHO 5: Intrusion and technical alarm subsystem events and commands", + "OWNd/message/automation.py": "WHO 2: Automation and motorized shutter events and commands", + "OWNd/message/base.py": "Base message, signaling, and protocol dispatcher registries", + "OWNd/message/cen.py": "WHO 15 & WHO 25: CEN, CEN+, and dry contact control events/commands", + "OWNd/message/energy.py": "WHO 18: Energy management, power, and diagnostic pulse events/commands", + "OWNd/message/gateway.py": "WHO 13: Gateway management, clock/date configuration, and model telemetry", + "OWNd/message/heating.py": "WHO 4: Thermoregulation, heating/cooling zone control, and temperature decoders", + "OWNd/message/lighting.py": "WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors", + "OWNd/message/scenario.py": "WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes", + "OWNd/message/sound.py": "WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented)", + "OWNd/profiles.py": "Declarative hardware gateway models across 14 canonical hardware profiles", + "OWNd/transport/base.py": "Abstract transport layer and event listener notification contracts", + "OWNd/transport/serial.py": "Async Serial/USB transport for Legrand 3578 interface with in-band demux", + "OWNd/transport/tcp.py": "Dual-session TCP transport linking event and command channels", + "OWNd/transport/__init__.py": "Transport subpackage exports", + "OWNd/__init__.py": "Package initialization and version metadata", +} + + +@dataclass(frozen=True) +class WhoCatalogDefinition: + who: str + name: str + description: str + dispatch_who_keys: tuple[int, ...] + explicit_classes: tuple[str, ...] = () + + +WHO_DEFINITIONS: tuple[WhoCatalogDefinition, ...] = ( + WhoCatalogDefinition( + who="**1**", + name="Lighting", + description="On/off switching, dimming level (0–100%), DALI Tunable White (Dimension 14, 2000K–6535K / mireds), status queries", + dispatch_who_keys=(1,), + ), + WhoCatalogDefinition( + who="**2**", + name="Automation", + description="Shutters, blinds, motorized curtains, tilt angles, short & full replies", + dispatch_who_keys=(2,), + ), + WhoCatalogDefinition( + who="**3**", + name="Load Control", + description="Load shedding status, circuit priority management", + dispatch_who_keys=(), + explicit_classes=("OWNCommand", "OWNEvent"), + ), + WhoCatalogDefinition( + who="**4**", + name="Thermoregulation / Climate", + description="Multi-zone temperature readouts, target adjustments, HVAC modes (Heat/Cool/Auto/Off), local offsets, fan coil speeds, valve states, Central Unit 3550/4695 master coordination", + dispatch_who_keys=(4,), + ), + WhoCatalogDefinition( + who="**5**", + name="Burglar Alarm", + description="Zone status, system arming / disarming states", + dispatch_who_keys=(5,), + ), + WhoCatalogDefinition( + who="**13**", + name="Gateway Diagnostics & Clock", + description="Gateway date/time synchronization, timezone offsets, firmware metadata", + dispatch_who_keys=(13,), + ), + WhoCatalogDefinition( + who="**15**", + name="CEN Scenarios", + description="Scenario control, pushbutton push/release/extended press events, strongly typed command builders", + dispatch_who_keys=(15,), + explicit_classes=("OWNScenarioEvent",), + ), + WhoCatalogDefinition( + who="**16** / **22**", + name="Sound Diffusion", + description="Multi-source selection, zone activation, volume adjustment, F441 matrix", + dispatch_who_keys=(16,), + explicit_classes=("OWNAVCommand",), + ), + WhoCatalogDefinition( + who="**17**", + name="Scenario Programmer", + description="MH200N / MH202 scenario activation and state monitoring", + dispatch_who_keys=(17,), + ), + WhoCatalogDefinition( + who="**18**", + name="Energy Management", + description="Active power (W), hourly/daily/monthly consumption (kWh), Stop & Go breaker diagnostics", + dispatch_who_keys=(18,), + ), + WhoCatalogDefinition( + who="**25**", + name="CEN+ & Dry Contacts", + description="32-button keypads, rotary knob encoders (CW/CCW), dry contacts, PIR sensors, strongly typed command builders", + dispatch_who_keys=(), + explicit_classes=( + "OWNCenPlusCommand", + "OWNCENPlusEvent", + "OWNDryContactCommand", + "OWNDryContactEvent", + ), + ), +) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Gateway Profiles Table Builder +# ───────────────────────────────────────────────────────────────────────────── + +def build_gateway_profiles_table() -> str: + """Generate Markdown table representing canonical gateway profiles.""" + lines = [ + "| Gateway Model | Concurrency | Queue Delay | Event keepalive | Features |", + "|:---|:---:|:---:|:---:|:---|", + ] + for profile in canonical_profiles(): + model_name = ( + "Generic Gateway" + if profile.model_name in ("Generic", "Generic Gateway") + else profile.model_name + ) + lines.append( + f"| **{model_name}** | {profile.concurrency_summary} | " + f"{profile.queue_delay_summary} | {profile.keepalive_summary} | " + f"{profile.features_summary} |" + ) + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. WHO Subsystems Catalog Table Builder +# ───────────────────────────────────────────────────────────────────────────── + +def _resolve_subsystem_classes(defn: WhoCatalogDefinition) -> list[str]: + """Dynamically resolve and verify command/event classes for a subsystem.""" + import OWNd.message as message_pkg + from OWNd.message.base import ( + _COMMAND_DISPATCH, + _EVENT_DISPATCH, + _ensure_all_subsystems_registered, + ) + + _ensure_all_subsystems_registered() + + discovered_commands: list[str] = [] + discovered_events: list[str] = [] + + for who_key in defn.dispatch_who_keys: + cmd_parser = _COMMAND_DISPATCH.get(who_key) + if cmd_parser is not None and hasattr(cmd_parser, "__name__"): + name = cmd_parser.__name__ + if not name.startswith("_") and name not in discovered_commands: + discovered_commands.append(name) + + evt_parser = _EVENT_DISPATCH.get(who_key) + if evt_parser is not None and hasattr(evt_parser, "__name__"): + name = evt_parser.__name__ + if not name.startswith("_") and name not in discovered_events: + discovered_events.append(name) + + # Combine discovered with any explicit classes (sorted Commands first, then Events) + combined = list(discovered_commands) + list(discovered_events) + for extra in defn.explicit_classes: + if extra not in combined: + combined.append(extra) + + # Validate that every class exists and is exported in OWNd.message + for cls_name in combined: + if not hasattr(message_pkg, cls_name): + raise AttributeError(f"Class '{cls_name}' is not found in OWNd.message.") + if cls_name not in message_pkg.__all__: + raise ValueError(f"Class '{cls_name}' is not exported in OWNd.message.__all__.") + + return combined + + +def build_who_catalog_table() -> str: + """Dynamically generate Markdown table of supported WHO subsystems.""" + lines = [ + "| WHO | Subsystem | Description & Capabilities | Event / Command Classes |", + "|:---:|:---|:---|:---|", + ] + for defn in WHO_DEFINITIONS: + classes = _resolve_subsystem_classes(defn) + formatted_classes = ", ".join(f"`{cls_name}`" for cls_name in classes) + lines.append( + f"| {defn.who} | {defn.name} | {defn.description} | {formatted_classes} |" + ) + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Coverage Table Builder & SVG Generator +# ───────────────────────────────────────────────────────────────────────────── + +def normalize_coverage_filename(fn: str, repo_root: Path = REPO_ROOT) -> str: + """Normalize filenames from coverage.xml to OWNd/... paths.""" + fn = fn.replace("\\", "/") + if os.path.isabs(fn): + try: + fn = str(Path(fn).relative_to(repo_root)).replace("\\", "/") + except ValueError: + pass + if not fn.startswith("OWNd/"): + fn = f"OWNd/{fn}" + return fn + + +def build_coverage_table(coverage_xml_path: Path, repo_root: Path = REPO_ROOT) -> tuple[str, int, str]: + """Parse coverage.xml and return (markdown_table, rounded_total_rate, hex_color).""" + if not coverage_xml_path.is_file(): + raise FileNotFoundError(f"{coverage_xml_path} does not exist.") + + tree = ET.parse(coverage_xml_path) + root = tree.getroot() + + file_rates: dict[str, float] = {} + for p in root.findall(".//package"): + for c in p.findall(".//class"): + raw_fn = c.attrib.get("filename", "") + fn = normalize_coverage_filename(raw_fn, repo_root) + if fn in ("OWNd/__main__.py",): + continue + cr = float(c.attrib.get("line-rate", 0)) * 100 + file_rates[fn] = cr + + total_rate = float(root.attrib.get("line-rate", 0)) * 100 + rate_round = round(total_rate) + color = "#4c1" if rate_round >= 80 else ("#dfb317" if rate_round >= 60 else "#e05d44") + + sorted_files = sorted(file_rates.items(), key=lambda item: (-round(item[1], 1), item[0])) + + table_lines = [ + "| Component / Module | Coverage | Notes |", + "|---|:---:|---|", + ] + for fn, rate in sorted_files: + notes = COMPONENT_NOTES.get(fn, "Core OWNd library component") + rate_str = f"**{round(rate)}%**" if round(rate) >= 90 else f"{round(rate)}%" + table_lines.append(f"| [`{fn}`]({fn}) | {rate_str} | {notes} |") + + return "\n".join(table_lines), rate_round, color + + +def generate_coverage_svg(rate_round: int, color: str | None = None) -> str: + """Generate SVG badge string for test coverage percentage.""" + if color is None: + color = "#4c1" if rate_round >= 80 else ("#dfb317" if rate_round >= 60 else "#e05d44") + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'coverage' + f'coverage' + f'{rate_round}%' + f'{rate_round}%' + f'' + f'' + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Marker Block Replacement & Synchronization Routine +# ───────────────────────────────────────────────────────────────────────────── + +def _replace_marker_block( + content: str, + start_marker: str, + end_marker: str, + new_inner_content: str, + pad_newlines: bool = False, +) -> tuple[str, bool]: + """Replace content between start_marker and end_marker.""" + if start_marker not in content or end_marker not in content: + raise ValueError(f"Marker pair '{start_marker}' ... '{end_marker}' not found in content.") + + pattern = re.compile( + rf"{re.escape(start_marker)}.*?{re.escape(end_marker)}", + re.DOTALL, + ) + match = pattern.search(content) + if not match: + raise ValueError(f"Could not parse content between '{start_marker}' and '{end_marker}'.") + + sep = "\n\n" if pad_newlines else "\n" + replacement = f"{start_marker}{sep}{new_inner_content}{sep}{end_marker}" + if match.group(0) == replacement: + return content, False + + updated_content = pattern.sub(replacement, content) + return updated_content, True + + +def sync_documentation( + readme_path: Path = README_MD, + coverage_xml_path: Path | None = COVERAGE_XML, + coverage_svg_path: Path | None = COVERAGE_SVG, + check_only: bool = False, +) -> tuple[bool, str, list[str]]: + """Verify or synchronize documentation tables in README.md. + + Returns (is_in_sync, unified_diff_str, list_of_messages). + """ + messages: list[str] = [] + if not readme_path.is_file(): + raise FileNotFoundError(f"{readme_path} not found.") + + original_content = readme_path.read_text(encoding="utf-8").replace("\r\n", "\n") + working_content = original_content + + # 1. Gateway Profiles Table + gw_table = build_gateway_profiles_table() + working_content, gw_changed = _replace_marker_block( + working_content, + GATEWAY_START_MARKER, + GATEWAY_END_MARKER, + gw_table, + pad_newlines=False, + ) + if gw_changed: + messages.append("Gateway Profiles table drifted from canonical profiles.") + else: + messages.append("Gateway Profiles table is in sync.") + + # 2. WHO Subsystems Catalog Table + who_table = build_who_catalog_table() + working_content, who_changed = _replace_marker_block( + working_content, + WHO_START_MARKER, + WHO_END_MARKER, + who_table, + pad_newlines=False, + ) + if who_changed: + messages.append("WHO Subsystem Catalog table drifted from OWNd.message exports/registries.") + else: + messages.append("WHO Subsystem Catalog table is in sync.") + + # 3. Coverage Table & SVG (when coverage.xml is present) + if coverage_xml_path and coverage_xml_path.is_file(): + cov_table, rate_round, color = build_coverage_table( + coverage_xml_path, repo_root=readme_path.parent + ) + working_content, cov_changed = _replace_marker_block( + working_content, + COVERAGE_START_MARKER, + COVERAGE_END_MARKER, + cov_table, + pad_newlines=True, + ) + if cov_changed: + messages.append(f"Coverage table drifted from {coverage_xml_path.name} ({rate_round}%).") + else: + messages.append(f"Coverage table is in sync ({rate_round}%).") + + if not check_only and coverage_svg_path: + svg_content = generate_coverage_svg(rate_round, color) + coverage_svg_path.write_text(svg_content, encoding="utf-8", newline="\n") + messages.append(f"Updated {coverage_svg_path.name} to {rate_round}%.") + else: + # Check marker presence without altering existing coverage block + if COVERAGE_START_MARKER not in working_content or COVERAGE_END_MARKER not in working_content: + raise ValueError(f"Coverage markers '{COVERAGE_START_MARKER}' not found in {readme_path.name}.") + messages.append("Coverage table check skipped (coverage.xml not present).") + + is_in_sync = working_content == original_content + + diff_str = "" + if not is_in_sync: + diff_lines = list( + difflib.unified_diff( + original_content.splitlines(keepends=True), + working_content.splitlines(keepends=True), + fromfile=f"a/{readme_path.name}", + tofile=f"b/{readme_path.name}", + ) + ) + diff_str = "".join(diff_lines) + + if not check_only and not is_in_sync: + readme_path.write_text(working_content, encoding="utf-8", newline="\n") + messages.append(f"Updated {readme_path.name} in place.") + + return is_in_sync, diff_str, messages + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. CLI Entrypoint +# ───────────────────────────────────────────────────────────────────────────── + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Unified sentinel & synchronizer for OWNd documentation." + ) + parser.add_argument( + "--check", + action="store_true", + help="Check documentation synchronization without modifying files (exits 1 with diff on drift).", + ) + parser.add_argument( + "--readme", + type=Path, + default=README_MD, + help=f"Path to README.md (default: {README_MD})", + ) + parser.add_argument( + "--coverage-xml", + type=Path, + default=COVERAGE_XML, + help=f"Path to coverage.xml (default: {COVERAGE_XML})", + ) + parser.add_argument( + "--coverage-svg", + type=Path, + default=COVERAGE_SVG, + help=f"Path to coverage.svg (default: {COVERAGE_SVG})", + ) + + args = parser.parse_args(argv) + + try: + in_sync, diff, messages = sync_documentation( + readme_path=args.readme, + coverage_xml_path=args.coverage_xml if args.coverage_xml.is_file() else None, + coverage_svg_path=args.coverage_svg, + check_only=args.check, + ) + except Exception as exc: + print(f"Error during documentation synchronization: {exc}", file=sys.stderr) + return 1 + + for msg in messages: + print(f"- {msg}") + + if args.check: + if not in_sync: + print("\nError: Documentation is out of date!", file=sys.stderr) + if diff: + print(diff, file=sys.stderr) + print( + f"\nRun 'python scripts/sync_documentation.py' to synchronize {args.readme.name}.", + file=sys.stderr, + ) + return 1 + print(f"\nDocumentation in {args.readme.name} is fully synchronized.") + return 0 + + if in_sync: + print(f"\nDocumentation in {args.readme.name} is already up to date.") + else: + print(f"\nSuccessfully synchronized documentation in {args.readme.name}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_readme_coverage.py b/scripts/update_readme_coverage.py index a7a4eea..81f907b 100644 --- a/scripts/update_readme_coverage.py +++ b/scripts/update_readme_coverage.py @@ -39,7 +39,7 @@ "OWNd/message/lighting.py": "WHO 1: Lighting controls, dimming, RGB/HSV color, and motion/PIR sensors", "OWNd/message/scenario.py": "WHO 0, WHO 9, WHO 17: Scenario activation, auxiliary commands, and MH200/MH202 scenes", "OWNd/message/sound.py": "WHO 16: Sound system and audio/video matrix routing (WHO 22 is not implemented)", - "OWNd/profiles.py": "Declarative hardware gateway models (F454, MH200, MH200N, MH201, MH202, MyHomeServer1)", + "OWNd/profiles.py": "Declarative hardware gateway models across 14 canonical hardware profiles", "OWNd/transport/base.py": "Abstract transport layer and event listener notification contracts", "OWNd/transport/serial.py": "Async Serial/USB transport for Legrand 3578 interface with in-band demux", "OWNd/transport/tcp.py": "Dual-session TCP transport linking event and command channels", diff --git a/tests/test_sync_documentation.py b/tests/test_sync_documentation.py new file mode 100644 index 0000000..1d9da7b --- /dev/null +++ b/tests/test_sync_documentation.py @@ -0,0 +1,305 @@ +"""Unit tests for the documentation anti-drift sentinel and synchronizer.""" +from __future__ import annotations + +from pathlib import Path +import re + +import pytest + +from OWNd.profiles import canonical_profiles +from scripts.sync_documentation import ( + COVERAGE_END_MARKER, + COVERAGE_START_MARKER, + GATEWAY_END_MARKER, + GATEWAY_START_MARKER, + WHO_DEFINITIONS, + WHO_END_MARKER, + WHO_START_MARKER, + WhoCatalogDefinition, + _replace_marker_block, + _resolve_subsystem_classes, + build_coverage_table, + build_gateway_profiles_table, + build_who_catalog_table, + generate_coverage_svg, + main, + normalize_coverage_filename, + sync_documentation, +) + + +def test_build_gateway_profiles_table() -> None: + """Verify generated gateway profiles table contains all canonical profiles.""" + table = build_gateway_profiles_table() + assert "| Gateway Model | Concurrency | Queue Delay | Event keepalive | Features |" in table + assert "|:---|:---:|:---:|:---:|:---|" in table + + profiles = canonical_profiles() + for profile in profiles: + model_name = ( + "Generic Gateway" + if profile.model_name in ("Generic", "Generic Gateway") + else profile.model_name + ) + assert f"**{model_name}**" in table + assert profile.concurrency_summary in table + assert profile.queue_delay_summary in table + + +def test_build_who_catalog_table() -> None: + """Verify generated WHO catalog table contains all defined subsystems and classes.""" + table = build_who_catalog_table() + assert "| WHO | Subsystem | Description & Capabilities | Event / Command Classes |" in table + assert "|:---:|:---|:---|:---|" in table + + # Verify key subsystem names and WHO identifiers + for defn in WHO_DEFINITIONS: + assert f"| {defn.who} | {defn.name} |" in table + + # Verify WHO 17 includes both OWNSceneCommand and OWNSceneEvent + assert "`OWNSceneCommand`, `OWNSceneEvent`" in table + + # Verify WHO 1 includes OWNLightingCommand and OWNLightingEvent + assert "`OWNLightingCommand`, `OWNLightingEvent`" in table + + # Verify WHO 4 includes OWNHeatingCommand and OWNHeatingEvent + assert "`OWNHeatingCommand`, `OWNHeatingEvent`" in table + + # Verify WHO 25 includes CEN+ and DryContact classes + assert "`OWNCenPlusCommand`, `OWNCENPlusEvent`, `OWNDryContactCommand`, `OWNDryContactEvent`" in table + + +def test_resolve_subsystem_classes_validation() -> None: + """Verify _resolve_subsystem_classes validates class existence in OWNd.message.""" + # Invalid class name that does not exist in OWNd.message + invalid_def = WhoCatalogDefinition( + who="**99**", + name="Invalid Subsystem", + description="Fake", + dispatch_who_keys=(), + explicit_classes=("NonExistentCommandClass",), + ) + with pytest.raises(AttributeError, match="not found in OWNd.message"): + _resolve_subsystem_classes(invalid_def) + + # Object that exists in OWNd.message module but is not in __all__ + invalid_all_def = WhoCatalogDefinition( + who="**99**", + name="Private Class", + description="Fake", + dispatch_who_keys=(), + explicit_classes=("re",), + ) + with pytest.raises(ValueError, match="not exported in OWNd.message.__all__"): + _resolve_subsystem_classes(invalid_all_def) + + +def test_normalize_coverage_filename(tmp_path: Path) -> None: + """Verify filename normalization from coverage.xml paths.""" + assert normalize_coverage_filename("OWNd/message/lighting.py", tmp_path) == "OWNd/message/lighting.py" + assert normalize_coverage_filename("message/lighting.py", tmp_path) == "OWNd/message/lighting.py" + + abs_path = str(tmp_path / "OWNd" / "profiles.py") + assert normalize_coverage_filename(abs_path, tmp_path) == "OWNd/profiles.py" + + +def test_coverage_table_and_svg(tmp_path: Path) -> None: + """Verify coverage XML parsing and SVG badge generation.""" + sample_xml = """ + + + + + + + + + + + +""" + xml_file = tmp_path / "coverage.xml" + xml_file.write_text(sample_xml, encoding="utf-8") + + table, rate, color = build_coverage_table(xml_file, repo_root=tmp_path) + assert rate == 95 + assert color == "#4c1" + assert "| Component / Module | Coverage | Notes |" in table + assert "| [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** |" in table + assert "| [`OWNd/discovery.py`](OWNd/discovery.py) | **95%** |" in table + # __main__.py is ignored + assert "__main__.py" not in table + + # SVG badge tests across rate thresholds + svg_green = generate_coverage_svg(95) + assert 'fill="#4c1"' in svg_green + assert ">95%<" in svg_green + + svg_yellow = generate_coverage_svg(75) + assert 'fill="#dfb317"' in svg_yellow + assert ">75%<" in svg_yellow + + svg_red = generate_coverage_svg(50) + assert 'fill="#e05d44"' in svg_red + assert ">50%<" in svg_red + + # Non-existent coverage XML raises FileNotFoundError + with pytest.raises(FileNotFoundError): + build_coverage_table(tmp_path / "nonexistent.xml") + + +def test_replace_marker_block_edge_cases() -> None: + """Test marker replacement error handling.""" + content = "Header\nOld\nFooter" + + # Missing markers + with pytest.raises(ValueError, match="Marker pair"): + _replace_marker_block(content, "", "", "New") + + # Clean replacement + updated, changed = _replace_marker_block(content, "", "", "New") + assert changed is True + assert updated == "Header\n\nNew\n\nFooter" + + # No change if content matches + _, changed_again = _replace_marker_block(updated, "", "", "New") + assert changed_again is False + + +def test_sync_documentation_live_readme() -> None: + """Verify that the repository's live README.md is in sync.""" + repo_root = Path(__file__).resolve().parent.parent + readme_path = repo_root / "README.md" + coverage_xml = repo_root / "coverage.xml" + + is_in_sync, diff, messages = sync_documentation( + readme_path=readme_path, + coverage_xml_path=coverage_xml if coverage_xml.is_file() else None, + check_only=True, + ) + + assert is_in_sync is True, f"Live README.md has drifted:\n{diff}" + assert diff == "" + + +def test_sync_documentation_drift_detection_and_inplace_update(tmp_path: Path) -> None: + """Verify drift detection, unified diff generation, and in-place updating.""" + # Build a simulated README with outdated WHO catalog and Gateway profiles + readme_content = f"""# Test Readme + +{WHO_START_MARKER} +| WHO | Subsystem | Description & Capabilities | Event / Command Classes | +|:---:|:---|:---|:---| +| **17** | Scenario Programmer | Old description | `OWNSceneEvent` | +{WHO_END_MARKER} + +{GATEWAY_START_MARKER} +| Gateway Model | Concurrency | Queue Delay | Event keepalive | Features | +|:---|:---:|:---:|:---:|:---| +| **OldGateway** | 1 session | 100 ms | OS TCP only | Old | +{GATEWAY_END_MARKER} + +{COVERAGE_START_MARKER} +| Component / Module | Coverage | Notes | +|---|:---:|---| +| [`OWNd/profiles.py`](OWNd/profiles.py) | **90%** | Old note | +{COVERAGE_END_MARKER} +""" + readme_file = tmp_path / "README.md" + readme_file.write_text(readme_content, encoding="utf-8") + + sample_xml = """ + + + + + + + + + +""" + xml_file = tmp_path / "coverage.xml" + xml_file.write_text(sample_xml, encoding="utf-8") + svg_file = tmp_path / "coverage.svg" + + # 1. Run in check_only mode -> should report drift + in_sync, diff, messages = sync_documentation( + readme_path=readme_file, + coverage_xml_path=xml_file, + coverage_svg_path=svg_file, + check_only=True, + ) + assert in_sync is False + assert "-| **OldGateway**" in diff + assert "+| **MyHomeServer1**" in diff + assert "+| **17** | Scenario Programmer" in diff + assert "`OWNSceneCommand`, `OWNSceneEvent`" in diff + + # 2. Run in update mode -> should update in-place + in_sync_update, _, update_msgs = sync_documentation( + readme_path=readme_file, + coverage_xml_path=xml_file, + coverage_svg_path=svg_file, + check_only=False, + ) + assert in_sync_update is False # Was modified during update + assert svg_file.is_file() + + # 3. Re-run in check_only mode -> now in sync + in_sync_after, diff_after, _ = sync_documentation( + readme_path=readme_file, + coverage_xml_path=xml_file, + coverage_svg_path=svg_file, + check_only=True, + ) + assert in_sync_after is True + assert diff_after == "" + + +def test_sync_documentation_missing_coverage_graceful(tmp_path: Path) -> None: + """Verify that absent coverage.xml does not break check mode if markers are present.""" + gw_table = build_gateway_profiles_table() + who_table = build_who_catalog_table() + + readme_content = f"""# Test Readme + +{WHO_START_MARKER} +{who_table} +{WHO_END_MARKER} + +{GATEWAY_START_MARKER} +{gw_table} +{GATEWAY_END_MARKER} + +{COVERAGE_START_MARKER} +| Component / Module | Coverage | Notes | +|---|:---:|---| +| Existing table | 100% | Note | +{COVERAGE_END_MARKER} +""" + readme_file = tmp_path / "README.md" + readme_file.write_text(readme_content, encoding="utf-8") + + in_sync, diff, msgs = sync_documentation( + readme_path=readme_file, + coverage_xml_path=None, + check_only=True, + ) + assert in_sync is True + assert diff == "" + assert any("Coverage table check skipped" in m for m in msgs) + + +def test_main_cli_modes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test CLI main() function for both check and update modes.""" + # 1. Main check on live repository exits 0 + assert main(["--check"]) == 0 + + # 2. Main check on drifted file exits 1 + drifted_readme = tmp_path / "README.md" + drifted_readme.write_text("Missing all markers", encoding="utf-8") + assert main(["--readme", str(drifted_readme), "--check"]) == 1 + + # 3. Main on invalid file exits 1 + assert main(["--readme", str(tmp_path / "does_not_exist.md")]) == 1 From 5863a61a78e74322c5ae200c44cefc604292953c Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:20:19 +0200 Subject: [PATCH 2/2] fix(docs): harden sync documentation sentinel with SVG drift and CRLF checks --- scripts/sync_documentation.py | 121 +++++++++++++++++++---- scripts/update_readme_profiles.py | 27 +----- tests/test_sync_documentation.py | 153 +++++++++++++++++++++++++++++- 3 files changed, 257 insertions(+), 44 deletions(-) diff --git a/scripts/sync_documentation.py b/scripts/sync_documentation.py index ca90cf1..67ccd10 100644 --- a/scripts/sync_documentation.py +++ b/scripts/sync_documentation.py @@ -249,20 +249,59 @@ def build_who_catalog_table() -> str: return "\n".join(lines) +def verify_who_catalog_coverage() -> list[str]: + """Verify that all WHO subsystems registered in _COMMAND_DISPATCH are accounted for.""" + from OWNd.message.base import ( + _COMMAND_DISPATCH, + _ensure_all_subsystems_registered, + ) + + _ensure_all_subsystems_registered() + covered_who: set[int] = set() + for defn in WHO_DEFINITIONS: + covered_who.update(defn.dispatch_who_keys) + + # WHO 25 is handled with explicit classes because its dispatcher is a private helper function + missing_who = [ + who for who in sorted(_COMMAND_DISPATCH.keys()) + if who not in covered_who and who != 25 + ] + if missing_who: + return [ + f"WHO {who} registered in _COMMAND_DISPATCH but not mapped in WHO_DEFINITIONS." + for who in missing_who + ] + return [] + + # ───────────────────────────────────────────────────────────────────────────── # 3. Coverage Table Builder & SVG Generator # ───────────────────────────────────────────────────────────────────────────── -def normalize_coverage_filename(fn: str, repo_root: Path = REPO_ROOT) -> str: - """Normalize filenames from coverage.xml to OWNd/... paths.""" - fn = fn.replace("\\", "/") +def _safe_float(val: Any, default: float = 0.0) -> float: + """Safely convert value to float, returning default on ValueError/TypeError.""" + try: + return float(val) + except (ValueError, TypeError): + return default + + +def normalize_coverage_filename(fn: str, repo_root: Path = REPO_ROOT) -> str | None: + """Normalize filenames from coverage.xml to OWNd/... paths, or None if outside OWNd.""" + if not fn or not fn.strip(): + return None + fn = fn.strip().replace("\\", "/") if os.path.isabs(fn): try: fn = str(Path(fn).relative_to(repo_root)).replace("\\", "/") except ValueError: pass + if fn.startswith(("tests/", "scripts/", "setup.py", ".venv/")): + return None if not fn.startswith("OWNd/"): fn = f"OWNd/{fn}" + if not fn.endswith(".py"): + return None return fn @@ -271,7 +310,11 @@ def build_coverage_table(coverage_xml_path: Path, repo_root: Path = REPO_ROOT) - if not coverage_xml_path.is_file(): raise FileNotFoundError(f"{coverage_xml_path} does not exist.") - tree = ET.parse(coverage_xml_path) + try: + tree = ET.parse(coverage_xml_path) + except ET.ParseError as exc: + raise ValueError(f"Failed to parse coverage XML from {coverage_xml_path}: {exc}") from exc + root = tree.getroot() file_rates: dict[str, float] = {} @@ -279,12 +322,12 @@ def build_coverage_table(coverage_xml_path: Path, repo_root: Path = REPO_ROOT) - for c in p.findall(".//class"): raw_fn = c.attrib.get("filename", "") fn = normalize_coverage_filename(raw_fn, repo_root) - if fn in ("OWNd/__main__.py",): + if fn is None or fn in ("OWNd/__main__.py",): continue - cr = float(c.attrib.get("line-rate", 0)) * 100 + cr = _safe_float(c.attrib.get("line-rate", 0)) * 100 file_rates[fn] = cr - total_rate = float(root.attrib.get("line-rate", 0)) * 100 + total_rate = _safe_float(root.attrib.get("line-rate", 0)) * 100 rate_round = round(total_rate) color = "#4c1" if rate_round >= 80 else ("#dfb317" if rate_round >= 60 else "#e05d44") @@ -356,7 +399,7 @@ def _replace_marker_block( if match.group(0) == replacement: return content, False - updated_content = pattern.sub(replacement, content) + updated_content = pattern.sub(replacement, content, count=1) return updated_content, True @@ -374,7 +417,9 @@ def sync_documentation( if not readme_path.is_file(): raise FileNotFoundError(f"{readme_path} not found.") - original_content = readme_path.read_text(encoding="utf-8").replace("\r\n", "\n") + raw_bytes = readme_path.read_bytes() + has_crlf = b"\r" in raw_bytes + original_content = raw_bytes.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") working_content = original_content # 1. Gateway Profiles Table @@ -406,6 +451,7 @@ def sync_documentation( messages.append("WHO Subsystem Catalog table is in sync.") # 3. Coverage Table & SVG (when coverage.xml is present) + svg_drifted = False if coverage_xml_path and coverage_xml_path.is_file(): cov_table, rate_round, color = build_coverage_table( coverage_xml_path, repo_root=readme_path.parent @@ -422,20 +468,39 @@ def sync_documentation( else: messages.append(f"Coverage table is in sync ({rate_round}%).") - if not check_only and coverage_svg_path: - svg_content = generate_coverage_svg(rate_round, color) - coverage_svg_path.write_text(svg_content, encoding="utf-8", newline="\n") - messages.append(f"Updated {coverage_svg_path.name} to {rate_round}%.") + if coverage_svg_path: + expected_svg = generate_coverage_svg(rate_round, color) + if coverage_svg_path.is_file(): + current_svg = coverage_svg_path.read_text(encoding="utf-8") + if current_svg.strip() != expected_svg.strip(): + svg_drifted = True + else: + svg_drifted = True + + if svg_drifted: + messages.append(f"{coverage_svg_path.name} drifted from {coverage_xml_path.name} ({rate_round}%).") + if not check_only: + coverage_svg_path.write_text(expected_svg, encoding="utf-8", newline="\n") + messages.append(f"Updated {coverage_svg_path.name} to {rate_round}%.") + else: + messages.append(f"{coverage_svg_path.name} is in sync ({rate_round}%).") else: # Check marker presence without altering existing coverage block if COVERAGE_START_MARKER not in working_content or COVERAGE_END_MARKER not in working_content: raise ValueError(f"Coverage markers '{COVERAGE_START_MARKER}' not found in {readme_path.name}.") messages.append("Coverage table check skipped (coverage.xml not present).") - is_in_sync = working_content == original_content + if has_crlf: + if check_only: + messages.append(f"{readme_path.name} contains CRLF line endings (repository requires LF only).") + else: + messages.append(f"Normalized line endings to LF in {readme_path.name}.") + + content_changed = working_content != original_content + is_in_sync = (not content_changed) and (not has_crlf) and (not svg_drifted) diff_str = "" - if not is_in_sync: + if content_changed: diff_lines = list( difflib.unified_diff( original_content.splitlines(keepends=True), @@ -446,7 +511,7 @@ def sync_documentation( ) diff_str = "".join(diff_lines) - if not check_only and not is_in_sync: + if not check_only and (content_changed or has_crlf): readme_path.write_text(working_content, encoding="utf-8", newline="\n") messages.append(f"Updated {readme_path.name} in place.") @@ -461,11 +526,17 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Unified sentinel & synchronizer for OWNd documentation." ) - parser.add_argument( + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument( "--check", action="store_true", help="Check documentation synchronization without modifying files (exits 1 with diff on drift).", ) + mode_group.add_argument( + "--update", + action="store_true", + help="Synchronize documentation tables in place (default behavior when --check is omitted).", + ) parser.add_argument( "--readme", type=Path, @@ -487,10 +558,24 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) + # Determine coverage_xml_path: + # If default COVERAGE_XML is used, skip gracefully when absent. + # If the user explicitly provided a path, it must exist. + coverage_xml_path: Path | None = None + if args.coverage_xml: + if args.coverage_xml == COVERAGE_XML: + if COVERAGE_XML.is_file(): + coverage_xml_path = COVERAGE_XML + else: + if not args.coverage_xml.is_file(): + print(f"Error: Coverage XML file not found: {args.coverage_xml}", file=sys.stderr) + return 1 + coverage_xml_path = args.coverage_xml + try: in_sync, diff, messages = sync_documentation( readme_path=args.readme, - coverage_xml_path=args.coverage_xml if args.coverage_xml.is_file() else None, + coverage_xml_path=coverage_xml_path, coverage_svg_path=args.coverage_svg, check_only=args.check, ) diff --git a/scripts/update_readme_profiles.py b/scripts/update_readme_profiles.py index fa06b8d..c63c9f8 100644 --- a/scripts/update_readme_profiles.py +++ b/scripts/update_readme_profiles.py @@ -18,30 +18,13 @@ sys.path.insert(0, str(REPO_ROOT)) from OWNd.profiles import canonical_profiles # noqa: E402 +from scripts.sync_documentation import ( # noqa: E402 + GATEWAY_END_MARKER as END_MARKER, + GATEWAY_START_MARKER as START_MARKER, + build_gateway_profiles_table as build_profiles_table, +) README_MD = REPO_ROOT / "README.md" -START_MARKER = "" -END_MARKER = "" - - -def build_profiles_table() -> str: - """Generate Markdown table representing canonical gateway profiles.""" - lines = [ - "| Gateway Model | Concurrency | Queue Delay | Event keepalive | Features |", - "|:---|:---:|:---:|:---:|:---|", - ] - for profile in canonical_profiles(): - model_name = ( - "Generic Gateway" - if profile.model_name in ("Generic", "Generic Gateway") - else profile.model_name - ) - lines.append( - f"| **{model_name}** | {profile.concurrency_summary} | " - f"{profile.queue_delay_summary} | {profile.keepalive_summary} | " - f"{profile.features_summary} |" - ) - return "\n".join(lines) def sync_readme_profiles(check_only: bool = False) -> bool: diff --git a/tests/test_sync_documentation.py b/tests/test_sync_documentation.py index 1d9da7b..734af01 100644 --- a/tests/test_sync_documentation.py +++ b/tests/test_sync_documentation.py @@ -25,6 +25,7 @@ main, normalize_coverage_filename, sync_documentation, + verify_who_catalog_coverage, ) @@ -102,6 +103,14 @@ def test_normalize_coverage_filename(tmp_path: Path) -> None: abs_path = str(tmp_path / "OWNd" / "profiles.py") assert normalize_coverage_filename(abs_path, tmp_path) == "OWNd/profiles.py" + # Edge cases: non-module files, external paths, empty strings + assert normalize_coverage_filename("") is None + assert normalize_coverage_filename(" ") is None + assert normalize_coverage_filename("tests/test_sync_documentation.py") is None + assert normalize_coverage_filename("scripts/sync_documentation.py") is None + assert normalize_coverage_filename("setup.py") is None + assert normalize_coverage_filename("OWNd/py.typed") is None + def test_coverage_table_and_svg(tmp_path: Path) -> None: """Verify coverage XML parsing and SVG badge generation.""" @@ -113,6 +122,9 @@ def test_coverage_table_and_svg(tmp_path: Path) -> None: + + + @@ -127,8 +139,10 @@ def test_coverage_table_and_svg(tmp_path: Path) -> None: assert "| Component / Module | Coverage | Notes |" in table assert "| [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** |" in table assert "| [`OWNd/discovery.py`](OWNd/discovery.py) | **95%** |" in table - # __main__.py is ignored + assert "| [`OWNd/energy.py`](OWNd/energy.py) | 0% |" in table + # __main__.py and tests are ignored assert "__main__.py" not in table + assert "tests/" not in table # SVG badge tests across rate thresholds svg_green = generate_coverage_svg(95) @@ -147,6 +161,12 @@ def test_coverage_table_and_svg(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): build_coverage_table(tmp_path / "nonexistent.xml") + # Malformed XML raises ValueError + malformed_xml = tmp_path / "malformed.xml" + malformed_xml.write_text("", encoding="utf-8") + with pytest.raises(ValueError, match="Failed to parse coverage XML"): + build_coverage_table(malformed_xml) + def test_replace_marker_block_edge_cases() -> None: """Test marker replacement error handling.""" @@ -257,6 +277,122 @@ def test_sync_documentation_drift_detection_and_inplace_update(tmp_path: Path) - assert diff_after == "" +def test_coverage_svg_drift_detection_in_check_mode(tmp_path: Path) -> None: + """Verify that drifted coverage.svg is detected in check_only mode.""" + gw_table = build_gateway_profiles_table() + who_table = build_who_catalog_table() + + sample_xml = """ + + + + + + + + + +""" + xml_file = tmp_path / "coverage.xml" + xml_file.write_text(sample_xml, encoding="utf-8") + + cov_table, _, _ = build_coverage_table(xml_file, repo_root=tmp_path) + readme_content = f"""# Test Readme +{WHO_START_MARKER} +{who_table} +{WHO_END_MARKER} +{GATEWAY_START_MARKER} +{gw_table} +{GATEWAY_END_MARKER} +{COVERAGE_START_MARKER} +{cov_table} +{COVERAGE_END_MARKER} +""" + readme_file = tmp_path / "README.md" + readme_file.write_text(readme_content, encoding="utf-8") + + # SVG badge exists on disk but has outdated 75% rate + svg_file = tmp_path / "coverage.svg" + svg_file.write_text(generate_coverage_svg(75), encoding="utf-8") + + # In check_only mode, drift must be caught + in_sync, _, msgs = sync_documentation( + readme_path=readme_file, + coverage_xml_path=xml_file, + coverage_svg_path=svg_file, + check_only=True, + ) + assert in_sync is False + assert any("coverage.svg drifted" in m for m in msgs) + + # 2. In update mode, SVG is corrected + in_sync_update, _, _ = sync_documentation( + readme_path=readme_file, + coverage_xml_path=xml_file, + coverage_svg_path=svg_file, + check_only=False, + ) + assert in_sync_update is False + assert ">100%<" in svg_file.read_text(encoding="utf-8") + + # 3. Check mode now passes cleanly + in_sync_after, _, _ = sync_documentation( + readme_path=readme_file, + coverage_xml_path=xml_file, + coverage_svg_path=svg_file, + check_only=True, + ) + assert in_sync_after is True + + +def test_sync_documentation_crlf_detection(tmp_path: Path) -> None: + """Verify CRLF line ending drift detection and LF normalization.""" + gw_table = build_gateway_profiles_table() + who_table = build_who_catalog_table() + + # Create README with Windows CRLF line endings + readme_content = ( + f"# Test Readme\r\n\r\n" + f"{WHO_START_MARKER}\r\n{who_table}\r\n{WHO_END_MARKER}\r\n\r\n" + f"{GATEWAY_START_MARKER}\r\n{gw_table}\r\n{GATEWAY_END_MARKER}\r\n\r\n" + f"{COVERAGE_START_MARKER}\r\n| Component / Module | Coverage | Notes |\r\n|---|:---:|---|\r\n| [`OWNd/profiles.py`](OWNd/profiles.py) | **100%** | notes |\r\n{COVERAGE_END_MARKER}\r\n" + ) + readme_file = tmp_path / "README.md" + readme_file.write_bytes(readme_content.encode("utf-8")) + + # 1. Check mode flags CRLF drift + in_sync, _, msgs = sync_documentation( + readme_path=readme_file, + coverage_xml_path=None, + check_only=True, + ) + assert in_sync is False + assert any("contains CRLF line endings" in m for m in msgs) + + # 2. Update mode normalizes to LF only + in_sync_up, _, _ = sync_documentation( + readme_path=readme_file, + coverage_xml_path=None, + check_only=False, + ) + assert in_sync_up is False + assert b"\r" not in readme_file.read_bytes() + + # 3. Check mode now passes cleanly + in_sync_after, _, _ = sync_documentation( + readme_path=readme_file, + coverage_xml_path=None, + check_only=True, + ) + assert in_sync_after is True + + +def test_who_catalog_dispatch_coverage() -> None: + """Verify all registered WHO command parsers are covered in WHO_DEFINITIONS.""" + violations = verify_who_catalog_coverage() + assert not violations, f"WHO catalog definition gaps found: {violations}" + + def test_sync_documentation_missing_coverage_graceful(tmp_path: Path) -> None: """Verify that absent coverage.xml does not break check mode if markers are present.""" gw_table = build_gateway_profiles_table() @@ -292,14 +428,23 @@ def test_sync_documentation_missing_coverage_graceful(tmp_path: Path) -> None: def test_main_cli_modes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Test CLI main() function for both check and update modes.""" + """Test CLI main() function across check, update, and error modes.""" # 1. Main check on live repository exits 0 assert main(["--check"]) == 0 - # 2. Main check on drifted file exits 1 + # 2. Main update flag on live repository exits 0 + assert main(["--update"]) == 0 + + # 3. Main default (no flags) on live repository exits 0 + assert main([]) == 0 + + # 4. Main check on drifted file exits 1 drifted_readme = tmp_path / "README.md" drifted_readme.write_text("Missing all markers", encoding="utf-8") assert main(["--readme", str(drifted_readme), "--check"]) == 1 - # 3. Main on invalid file exits 1 + # 5. Main on invalid file exits 1 assert main(["--readme", str(tmp_path / "does_not_exist.md")]) == 1 + + # 6. Explicit non-existent coverage XML exits 1 + assert main(["--coverage-xml", str(tmp_path / "nonexistent.xml")]) == 1