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''
+ )
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# 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