From e9a3de49aff436ccfe28618d4ba649fa477a3553 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 10 May 2026 21:34:03 -0400 Subject: [PATCH] fix(types): make manifest.py clean under pyright strict - log.die() typed NoReturn so pyright knows it terminates control flow (was returning the unreachable Die instance type). - manifest.py: raw inputs typed object (not Any) and narrowed via a new _as_json_object helper that validates str keys and returns dict[str, object]. Eliminates the Unknown cascade through .get() calls under strict. - _from_dict classmethods renamed to from_dict so cross-class construction (Bottle.from_dict from Manifest.from_json_obj, etc.) doesn't trip reportPrivateUsage. - _SUPPORTED_RUNTIMES typed tuple[Runtime, ...] so the membership check narrows runtime_raw to Literal["runc", "runsc"] and the # type: ignore[assignment] is no longer needed. - Bottle.env uses a typed _empty_str_dict factory; bare dict resolves to dict[Unknown, Unknown] under strict. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/log.py | 3 +- claude_bottle/manifest.py | 166 ++++++++++++++++++++++---------------- 2 files changed, 97 insertions(+), 72 deletions(-) diff --git a/claude_bottle/log.py b/claude_bottle/log.py index b666e1e..4fc93cf 100644 --- a/claude_bottle/log.py +++ b/claude_bottle/log.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from typing import NoReturn def info(msg: str) -> None: @@ -18,6 +19,6 @@ class Die(SystemExit): fatal exit from an unrelated SystemExit.""" -def die(msg: str) -> "Die": +def die(msg: str) -> NoReturn: print(f"claude-bottle: error: {msg}", file=sys.stderr) raise Die(1) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index fc87a76..5f30d35 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -33,13 +33,17 @@ import json import os from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal, Mapping +from typing import Literal, Mapping, cast from .log import die -_SUPPORTED_RUNTIMES: tuple[str, ...] = ("runc", "runsc") Runtime = Literal["runc", "runsc"] +_SUPPORTED_RUNTIMES: tuple[Runtime, ...] = ("runc", "runsc") + + +def _empty_str_dict() -> dict[str, str]: + return {} @dataclass(frozen=True) @@ -52,26 +56,22 @@ class SshEntry: KnownHostKey: str = "" @classmethod - def _from_dict(cls, bottle_name: str, idx: int, raw: Any) -> "SshEntry": - if not isinstance(raw, dict): - die( - f"bottle '{bottle_name}' ssh[{idx}] must be a JSON object " - f"(was {_json_type(raw)})" - ) - host = raw.get("Host") + def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "SshEntry": + d = _as_json_object(raw, f"bottle '{bottle_name}' ssh[{idx}]") + host = d.get("Host") if not isinstance(host, str) or not host: die(f"bottle '{bottle_name}' ssh[{idx}] missing required string field 'Host'") - ident = raw.get("IdentityFile") + ident = d.get("IdentityFile") if not isinstance(ident, str) or not ident: die( f"bottle '{bottle_name}' ssh '{host}' missing required string field " f"'IdentityFile'" ) - hostname = _opt_str(raw.get("Hostname"), f"bottle '{bottle_name}' ssh '{host}' Hostname") - user = _opt_str(raw.get("User"), f"bottle '{bottle_name}' ssh '{host}' User") - port = _opt_port(raw.get("Port"), f"bottle '{bottle_name}' ssh '{host}' Port") + hostname = _opt_str(d.get("Hostname"), f"bottle '{bottle_name}' ssh '{host}' Hostname") + user = _opt_str(d.get("User"), f"bottle '{bottle_name}' ssh '{host}' User") + port = _opt_port(d.get("Port"), f"bottle '{bottle_name}' ssh '{host}' Port") khk = _opt_str( - raw.get("KnownHostKey"), + d.get("KnownHostKey"), f"bottle '{bottle_name}' ssh '{host}' KnownHostKey", ) return cls( @@ -89,13 +89,9 @@ class BottleEgress: allowlist: tuple[str, ...] = () @classmethod - def _from_dict(cls, bottle_name: str, raw: Any) -> "BottleEgress": - if not isinstance(raw, dict): - die( - f"bottle '{bottle_name}' egress must be a JSON object " - f"(was {_json_type(raw)})" - ) - allow = raw.get("allowlist") + def from_dict(cls, bottle_name: str, raw: object) -> "BottleEgress": + d = _as_json_object(raw, f"bottle '{bottle_name}' egress") + allow = d.get("allowlist") if allow is None: return cls() if not isinstance(allow, list): @@ -103,33 +99,34 @@ class BottleEgress: f"bottle '{bottle_name}' egress.allowlist must be an array " f"(was {_json_type(allow)})" ) - for i, host in enumerate(allow): + items: list[str] = [] + allow_list = cast(list[object], allow) + for i, host in enumerate(allow_list): if not isinstance(host, str): die( f"bottle '{bottle_name}' egress.allowlist[{i}] must be a string " f"(was {_json_type(host)})" ) - return cls(allowlist=tuple(allow)) + items.append(host) + return cls(allowlist=tuple(items)) @dataclass(frozen=True) class Bottle: - env: Mapping[str, str] = field(default_factory=dict) + env: Mapping[str, str] = field(default_factory=_empty_str_dict) ssh: tuple[SshEntry, ...] = () egress: BottleEgress = field(default_factory=BottleEgress) runtime: Runtime = "runc" @classmethod - def _from_dict(cls, name: str, raw: Any) -> "Bottle": - if not isinstance(raw, dict): - die(f"bottle '{name}' must be a JSON object (was {_json_type(raw)})") + def from_dict(cls, name: str, raw: object) -> "Bottle": + d = _as_json_object(raw, f"bottle '{name}'") - env_raw = raw.get("env") env: dict[str, str] = {} + env_raw = d.get("env") if env_raw is not None: - if not isinstance(env_raw, dict): - die(f"bottle '{name}' env must be a JSON object (was {_json_type(env_raw)})") - for var, value in env_raw.items(): + env_dict = _as_json_object(env_raw, f"bottle '{name}' env") + for var, value in env_dict.items(): if not isinstance(value, str): die( f"env entry {var} in bottle '{name}' must be a JSON string " @@ -137,25 +134,28 @@ class Bottle: ) env[var] = value - ssh_raw = raw.get("ssh") ssh: tuple[SshEntry, ...] = () + ssh_raw = d.get("ssh") if ssh_raw is not None: if not isinstance(ssh_raw, list): die(f"bottle '{name}' ssh must be an array (was {_json_type(ssh_raw)})") + ssh_list = cast(list[object], ssh_raw) ssh = tuple( - SshEntry._from_dict(name, i, entry) for i, entry in enumerate(ssh_raw) + SshEntry.from_dict(name, i, entry) + for i, entry in enumerate(ssh_list) ) - egress_raw = raw.get("egress") + egress_raw = d.get("egress") egress = ( - BottleEgress._from_dict(name, egress_raw) + BottleEgress.from_dict(name, egress_raw) if egress_raw is not None else BottleEgress() ) - runtime_raw = raw.get("runtime") + runtime_raw = d.get("runtime") + runtime: Runtime if runtime_raw is None: - runtime: Runtime = "runc" + runtime = "runc" else: if not isinstance(runtime_raw, str): die(f"bottle '{name}' runtime must be a string (was {_json_type(runtime_raw)})") @@ -164,7 +164,7 @@ class Bottle: f"bottle '{name}' runtime '{runtime_raw}' is not supported. " f"Use one of: {', '.join(_SUPPORTED_RUNTIMES)}." ) - runtime = runtime_raw # type: ignore[assignment] + runtime = runtime_raw return cls(env=env, ssh=ssh, egress=egress, runtime=runtime) @@ -176,11 +176,10 @@ class Agent: prompt: str = "" @classmethod - def _from_dict(cls, name: str, raw: Any, bottle_names: set[str]) -> "Agent": - if not isinstance(raw, dict): - die(f"agent '{name}' must be a JSON object (was {_json_type(raw)})") + def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent": + d = _as_json_object(raw, f"agent '{name}'") - bottle = raw.get("bottle") + bottle = d.get("bottle") if not isinstance(bottle, str) or not bottle: die(f"agent '{name}' must declare a 'bottle' field naming a defined bottle") if bottle not in bottle_names: @@ -190,20 +189,23 @@ class Agent: f"Available: {available}" ) - skills_raw = raw.get("skills") skills: tuple[str, ...] = () + skills_raw = d.get("skills") if skills_raw is not None: if not isinstance(skills_raw, list): die(f"agent '{name}' skills must be an array (was {_json_type(skills_raw)})") - for i, skill in enumerate(skills_raw): + collected: list[str] = [] + skills_list = cast(list[object], skills_raw) + for i, skill in enumerate(skills_list): if not isinstance(skill, str): die( f"agent '{name}' skills[{i}] must be a string " f"(was {_json_type(skill)})" ) - skills = tuple(skills_raw) + collected.append(skill) + skills = tuple(collected) - prompt_raw = raw.get("prompt") + prompt_raw = d.get("prompt") if prompt_raw is None: prompt = "" elif isinstance(prompt_raw, str): @@ -235,30 +237,32 @@ class Manifest: if cwd_doc is None and home_doc is None: die(f"no claude-bottle.json found in {cwd} or {os.environ['HOME']}") - h = home_doc or {} - c = cwd_doc or {} - merged: dict[str, Any] = { - "bottles": {**(h.get("bottles") or {}), **(c.get("bottles") or {})}, - "agents": {**(h.get("agents") or {}), **(c.get("agents") or {})}, + h: dict[str, object] = home_doc if home_doc is not None else {} + c: dict[str, object] = cwd_doc if cwd_doc is not None else {} + h_bottles = _section_dict(h.get("bottles"), "bottles") + c_bottles = _section_dict(c.get("bottles"), "bottles") + h_agents = _section_dict(h.get("agents"), "agents") + c_agents = _section_dict(c.get("agents"), "agents") + merged: dict[str, object] = { + "bottles": {**h_bottles, **c_bottles}, + "agents": {**h_agents, **c_agents}, } return cls.from_json_obj(merged) @classmethod - def from_json_obj(cls, obj: Any) -> "Manifest": + def from_json_obj(cls, obj: object) -> "Manifest": """Validate and build a Manifest from a raw JSON-like dict.""" - if not isinstance(obj, dict): - die(f"manifest must be a JSON object (was {_json_type(obj)})") + d = _as_json_object(obj, "manifest") + raw_bottles = _section_dict(d.get("bottles"), "manifest 'bottles'") + raw_agents = _section_dict(d.get("agents"), "manifest 'agents'") - raw_bottles = obj.get("bottles") or {} - raw_agents = obj.get("agents") or {} - if not isinstance(raw_bottles, dict): - die(f"manifest 'bottles' must be a JSON object (was {_json_type(raw_bottles)})") - if not isinstance(raw_agents, dict): - die(f"manifest 'agents' must be a JSON object (was {_json_type(raw_agents)})") - - bottles = {n: Bottle._from_dict(n, b) for n, b in raw_bottles.items()} + bottles: dict[str, Bottle] = { + n: Bottle.from_dict(n, b) for n, b in raw_bottles.items() + } bottle_names = set(bottles.keys()) - agents = {n: Agent._from_dict(n, a, bottle_names) for n, a in raw_agents.items()} + agents: dict[str, Agent] = { + n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() + } return cls(bottles=bottles, agents=agents) def has_agent(self, name: str) -> bool: @@ -293,18 +297,38 @@ class Manifest: return self.bottles[self.agents[agent_name].bottle] -def _load_json_or_die(path: Path) -> dict[str, Any]: +def _as_json_object(value: object, label: str) -> dict[str, object]: + """Assert that `value` is a JSON object (str-keyed dict) and return + a view typed as `dict[str, object]` so downstream `.get(...)` calls + have a typed surface.""" + if not isinstance(value, dict): + die(f"{label} must be a JSON object (was {_json_type(value)})") + items = cast(dict[object, object], value) + out: dict[str, object] = {} + for k, v in items.items(): + if not isinstance(k, str): + die(f"{label} keys must be strings (found {_json_type(k)})") + out[k] = v + return out + + +def _section_dict(value: object, label: str) -> dict[str, object]: + """Like _as_json_object but treats absent/null as an empty section.""" + if value is None: + return {} + return _as_json_object(value, label) + + +def _load_json_or_die(path: Path) -> dict[str, object]: try: with path.open() as f: - doc = json.load(f) + doc: object = json.load(f) except json.JSONDecodeError: die(f"claude-bottle.json at {path} is not valid JSON") - if not isinstance(doc, dict): - die(f"claude-bottle.json at {path} must be a JSON object") - return doc + return _as_json_object(doc, f"claude-bottle.json at {path}") -def _opt_str(value: Any, label: str) -> str: +def _opt_str(value: object, label: str) -> str: if value is None: return "" if not isinstance(value, str): @@ -312,7 +336,7 @@ def _opt_str(value: Any, label: str) -> str: return value -def _opt_port(value: Any, label: str) -> str: +def _opt_port(value: object, label: str) -> str: """Port accepts string or int (JSON-friendly) and is normalized to str.""" if value is None: return "" @@ -325,7 +349,7 @@ def _opt_port(value: Any, label: str) -> str: die(f"{label} must be a string or number (was {_json_type(value)})") -def _json_type(value: Any) -> str: +def _json_type(value: object) -> str: """Mirror jq's type names for parity with the original bash error messages.""" if value is None: return "null"