5. Configuration¶
What¶
src/zsnoop_mcp/config.py — the
dataclasses and TOML loader for hosts.toml.
Why frozen dataclasses¶
Two design decisions that stand out:
@dataclass(frozen=True, slots=True)— config is immutable once loaded. Mutating aHostConfigraisesAttributeErrorat runtime, which prevents accidental drift if something held onto a stale reference.- Strict validation in
__post_init__— every field is type-checked and constraint-checked at construction time, not at use time. A bad config produces a single error at startup, not a mysterious crash six tool-calls in.
How — guided tour¶
The HostConfig shape¶
@dataclass(frozen=True, slots=True)
class HostConfig:
name: str
ssh_target: str = ""
transport: Transport = "ssh" # "ssh" | "local"
agent_mode: AgentMode = "bootstrap" # "bootstrap" | "preinstalled"
agent_path: str | None = None
sudo: bool = False
remote_python: str = "python3"
ssh_options: tuple[str, ...] = ()
pools: tuple[str, ...] = ()
Notes:
- Sequence fields are
tuple, notlist. Frozen dataclasses can't hold mutable defaults anyway, and tuple is hashable so aHostConfigcan go in a set if we ever need it. Literal[...]types fortransportandagent_modegive the type checker something concrete; the runtime check in__post_init__keeps TOML inputs honest.
Cross-field validation¶
The constraints we enforce post-init:
def __post_init__(self) -> None:
if self.transport not in _VALID_TRANSPORTS:
raise ConfigError(f"host {self.name!r}: transport must be one of …")
if self.transport == "ssh" and not self.ssh_target:
raise ConfigError(f"host {self.name!r}: ssh_target is required when transport='ssh'")
if self.agent_mode not in _VALID_MODES:
raise ConfigError(...)
if self.agent_mode == "preinstalled" and not self.agent_path:
raise ConfigError(...)
Why this matters: ssh_target becomes conditionally required based on
transport. The whole module of validation logic exists because we want
"this stanza is malformed" to fail loudly with the exact reason, and
fail at config-load time rather than at first-use time.
The parser — explicit allow-list of keys¶
_KNOWN_HOST_KEYS = frozenset({
"ssh_target", "transport", "agent_mode", "agent_path",
"sudo", "remote_python", "ssh_options", "pools",
})
def _parse_host(name: str, stanza: dict[str, Any]) -> HostConfig:
extra = stanza.keys() - _KNOWN_HOST_KEYS
if extra:
raise ConfigError(f"host {name!r}: unknown keys: {sorted(extra)}")
return HostConfig(
name=name,
ssh_target=_optional_str(name, stanza, "ssh_target", ""),
transport=_optional_str(name, stanza, "transport", "ssh"),
...
)
Rejecting unknown keys turns typos into errors instead of silent no-ops.
A user who writes agnt_mode = "bootstrap" gets
unknown keys: ['agnt_mode'] not a confused agent.
load_config(str | Path)¶
Small but worth knowing: load_config accepts either string or Path.
This was a bug-fix during phase 5 — the type signature was Path only,
and external callers (a diagnostic script we wrote) passed a str, which
exploded with AttributeError: 'str' object has no attribute 'read_text'.
Fixed by normalising at the entry:
Test:
test_load_config_round_trip.
What to read next¶
→ Time parsing — the smallest module in the project, worth understanding because every time-range parameter passes through it.