🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-10-07 10:38:21 +02:00
commit 1c64aeb071
38 changed files with 3391 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
"""Typed configuration: dataclasses, YAML loading and strict validation.
Pydantic-free by design (IMPLEMENTATION_PLAN §4.1). Every validation error is
actionable: it names the offending key and explains what is wrong, so
``opcda-diagnozer check-config`` output lets an ops engineer fix the file
without reading the code.
"""
from __future__ import annotations
from dataclasses import MISSING, dataclass, field, fields, replace
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
_yaml = YAML(typ="safe") # pure-Python safe loader; config files are small
class ConfigError(Exception):
"""Invalid configuration; the message is user-facing (check-config output)."""
@dataclass(frozen=True)
class OpcConfig:
# server: OPC DA server ProgID, e.g. "Matrikon.OPC.Simulation.1"
# host: DCOM host; None means a local (same-machine) server
server: str
host: str | None = None
connect_timeout_s: float = 10.0
@dataclass(frozen=True)
class TagEntry:
# expect_change: True -> frozen-value detection (D13) applies to this tag
name: str
expect_change: bool = False
@dataclass(frozen=True)
class PollConfig:
interval_s: float = 1.0
timeout_ms: int = 5000 # D7: beyond this the call is flagged as a hang
hard_hang_s: float = 120.0 # SPECIFICATION §10.4: abandon + rebuild the COM thread
@dataclass(frozen=True)
class LatencyConfig:
warn_ms: float = 1000.0 # D4 spike threshold
critical_ms: float = 5000.0 # D5 sustained-slow severity
slow_poll_streak: int = 5 # D5 consecutive slow polls
window_polls: int = 60 # D6 rolling window for the p95 trend
@dataclass(frozen=True)
class QualityConfig:
persistent_bad_s: float = 30.0 # D10
flapping_transitions_per_window: int = 5 # D11
flapping_window_s: float = 600.0 # D11 window (10 min)
@dataclass(frozen=True)
class StaleConfig:
enabled: bool = True
max_age_s: float = 120.0 # D12 / D13
@dataclass(frozen=True)
class JsonlConfig:
dir: str = "C:/ProgramData/OPCDA-Diagnozer/logs"
max_mb: int = 50
backups: int = 10
@dataclass(frozen=True)
class EventLogConfig:
source: str = "OPCDA-Diagnozer"
min_severity: str = "WARN" # INFO / WARN / CRITICAL
@dataclass(frozen=True)
class ReportConfig:
heartbeat_s: float = 300.0 # D15
jsonl: JsonlConfig = field(default_factory=JsonlConfig)
eventlog: EventLogConfig = field(default_factory=EventLogConfig)
@dataclass(frozen=True)
class Config:
"""Fully validated tool configuration (see config.example.yaml)."""
opc: OpcConfig
poll: PollConfig = field(default_factory=PollConfig)
tags: tuple[TagEntry, ...] = ()
latency: LatencyConfig = field(default_factory=LatencyConfig)
quality: QualityConfig = field(default_factory=QualityConfig)
stale: StaleConfig = field(default_factory=StaleConfig)
report: ReportConfig = field(default_factory=ReportConfig)
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "latency", "quality", "stale", "report"}
_TAG_KEYS = {"name", "expect_change"}
def _build_section(cls: type[Any], data: Any, where: str) -> Any:
"""Instantiate a frozen dataclass section, rejecting unknown/missing keys."""
data = {} if data is None else data
if not isinstance(data, dict):
raise ConfigError(f"{where}: must be a mapping, got {type(data).__name__}")
known = {f.name for f in fields(cls)}
unknown = sorted(set(data) - known)
if unknown:
raise ConfigError(f"{where}: unknown key(s) {unknown}; accepted keys: {sorted(known)}")
for f in fields(cls):
if f.name not in data and f.default is MISSING and f.default_factory is MISSING:
raise ConfigError(f"{where}: missing required key '{f.name}'")
try:
return cls(**data)
except TypeError as exc:
raise ConfigError(f"{where}: {exc}") from None
def _build_report(data: Any) -> ReportConfig:
data = {} if data is None else dict(data)
jsonl = _build_section(JsonlConfig, data.pop("jsonl", None), "report.jsonl")
eventlog = _build_section(EventLogConfig, data.pop("eventlog", None), "report.eventlog")
rest = _build_section(ReportConfig, data, "report")
return replace(rest, jsonl=jsonl, eventlog=eventlog)
def _build_tags(data: Any) -> tuple[TagEntry, ...]:
if data is None:
return ()
if not isinstance(data, list):
raise ConfigError(f"tags: must be a list, got {type(data).__name__}")
entries: list[TagEntry] = []
for i, item in enumerate(data):
if isinstance(item, str):
if not item.strip():
raise ConfigError(f"tags[{i}]: name must be a non-empty string")
entries.append(TagEntry(name=item))
continue
if not isinstance(item, dict):
raise ConfigError(
f"tags[{i}]: must be a tag name string or a mapping with 'name'"
" (and optional 'expect_change')"
)
name = item.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError(f"tags[{i}]: 'name' is required and must be a non-empty string")
unknown = sorted(set(item) - _TAG_KEYS)
if unknown:
raise ConfigError(f"tags[{i}]: unknown key(s) {unknown}; accepted keys: {sorted(_TAG_KEYS)}")
expect = item.get("expect_change", False)
if not isinstance(expect, bool):
raise ConfigError(
f"tags[{i}]: 'expect_change' must be a boolean, got {type(expect).__name__}"
)
entries.append(TagEntry(name=name, expect_change=expect))
return tuple(entries)
def _validate(config: Config) -> None:
"""Cross-field validation; each message names the offending key."""
if not config.opc.server or not config.opc.server.strip():
raise ConfigError(
"opc.server: required — the OPC DA server ProgID, "
"e.g. 'Matrikon.OPC.Simulation.1'"
)
if config.opc.connect_timeout_s <= 0:
raise ConfigError(f"opc.connect_timeout_s: must be > 0, got {config.opc.connect_timeout_s}")
if not config.tags:
raise ConfigError(
"tags: at least one tag is required — without tags there is nothing to monitor"
)
names = [t.name for t in config.tags]
duplicates = sorted({n for n in names if names.count(n) > 1})
if duplicates:
raise ConfigError(f"tags: duplicate tag name(s) {duplicates}")
if config.poll.interval_s <= 0:
raise ConfigError(f"poll.interval_s: must be > 0, got {config.poll.interval_s}")
if config.poll.timeout_ms <= 0:
raise ConfigError(f"poll.timeout_ms: must be > 0, got {config.poll.timeout_ms}")
if config.poll.hard_hang_s * 1000 <= config.poll.timeout_ms:
raise ConfigError(
f"poll.hard_hang_s: must exceed poll.timeout_ms "
f"({config.poll.timeout_ms} ms); got {config.poll.hard_hang_s} s"
)
if not 0 < config.latency.warn_ms <= config.latency.critical_ms:
raise ConfigError(
"latency.warn_ms: must satisfy 0 < warn_ms <= critical_ms "
f"(got warn_ms={config.latency.warn_ms}, critical_ms={config.latency.critical_ms})"
)
if config.latency.slow_poll_streak < 1:
raise ConfigError(f"latency.slow_poll_streak: must be >= 1, got {config.latency.slow_poll_streak}")
if config.latency.window_polls < 1:
raise ConfigError(f"latency.window_polls: must be >= 1, got {config.latency.window_polls}")
if config.quality.persistent_bad_s <= 0:
raise ConfigError(f"quality.persistent_bad_s: must be > 0, got {config.quality.persistent_bad_s}")
if config.quality.flapping_transitions_per_window < 1:
raise ConfigError(
f"quality.flapping_transitions_per_window: must be >= 1, "
f"got {config.quality.flapping_transitions_per_window}"
)
if config.quality.flapping_window_s <= 0:
raise ConfigError(f"quality.flapping_window_s: must be > 0, got {config.quality.flapping_window_s}")
if config.stale.enabled and config.stale.max_age_s <= 0:
raise ConfigError(f"stale.max_age_s: must be > 0, got {config.stale.max_age_s}")
if config.report.heartbeat_s <= 0:
raise ConfigError(f"report.heartbeat_s: must be > 0, got {config.report.heartbeat_s}")
if config.report.eventlog.min_severity not in ("INFO", "WARN", "CRITICAL"):
raise ConfigError(
"report.eventlog.min_severity: must be one of INFO/WARN/CRITICAL, "
f"got {config.report.eventlog.min_severity!r}"
)
if config.report.jsonl.max_mb < 1:
raise ConfigError(f"report.jsonl.max_mb: must be >= 1, got {config.report.jsonl.max_mb}")
if config.report.jsonl.backups < 0:
raise ConfigError(f"report.jsonl.backups: must be >= 0, got {config.report.jsonl.backups}")
def load_config(path: str | Path) -> Config:
"""Load and strictly validate a YAML configuration file."""
p = Path(path)
if not p.is_file():
raise ConfigError(f"config file not found: {p}")
try:
data = _yaml.load(p.read_text(encoding="utf-8"))
except YAMLError as exc:
raise ConfigError(f"{p}: invalid YAML: {exc}") from None
if data is None:
data = {}
if not isinstance(data, dict):
raise ConfigError(f"{p}: top level must be a mapping")
unknown_top = sorted(set(data) - _ALLOWED_TOP_LEVEL)
if unknown_top:
raise ConfigError(
f"{p}: unknown section(s) {unknown_top}; "
f"accepted sections: {sorted(_ALLOWED_TOP_LEVEL)}"
)
config = Config(
opc=_build_section(OpcConfig, data.get("opc"), "opc"),
poll=_build_section(PollConfig, data.get("poll"), "poll"),
tags=_build_tags(data.get("tags")),
latency=_build_section(LatencyConfig, data.get("latency"), "latency"),
quality=_build_section(QualityConfig, data.get("quality"), "quality"),
stale=_build_section(StaleConfig, data.get("stale"), "stale"),
report=_build_report(data.get("report")),
)
try:
_validate(config)
except TypeError as exc:
raise ConfigError(f"invalid value type in config: {exc}") from None
return config