🎉 Initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user