diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index fe4cd4b..dc70223 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -34,7 +34,7 @@ from abc import ABC, abstractmethod from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from ..log import die from ..manifest import Manifest @@ -199,14 +199,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): from .docker import DockerBottleBackend # noqa: E402 -# The dict carries heterogeneous BottleBackend specializations; callers -# use it through the unparameterized BottleBackend interface. -_BACKENDS: dict[str, BottleBackend] = { +# The dict is heterogeneous: each value is a BottleBackend specialized +# over its own plan type. Concrete plan types are erased here because +# the registry is selected at runtime and the CLI only needs the +# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.). +_BACKENDS: dict[str, BottleBackend[Any, Any]] = { "docker": DockerBottleBackend(), } -def get_bottle_backend() -> BottleBackend: +def get_bottle_backend() -> BottleBackend[Any, Any]: """Resolve the bottle backend for the active environment. Dies with a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an unimplemented one.""" diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 93adcb9..f7fb3f8 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -16,7 +16,7 @@ import subprocess import sys from contextlib import ExitStack, contextmanager from pathlib import Path -from typing import Iterator, Sequence +from typing import Generator, Sequence from ... import pipelock from ...env import ResolvedEnv, resolve_env @@ -165,7 +165,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup env_file.chmod(0o600) @contextmanager - def launch(self, plan: DockerBottlePlan) -> Iterator[DockerBottle]: + def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: """Build, launch, and provision a Docker bottle. Teardown on exit.""" stack = ExitStack() diff --git a/claude_bottle/backend/docker/bottle.py b/claude_bottle/backend/docker/bottle.py index a93e64d..2508a5e 100644 --- a/claude_bottle/backend/docker/bottle.py +++ b/claude_bottle/backend/docker/bottle.py @@ -9,6 +9,7 @@ prompt was provisioned. from __future__ import annotations import subprocess +from typing import Callable from .. import Bottle @@ -16,7 +17,12 @@ from .. import Bottle class DockerBottle(Bottle): """Concrete Bottle for Docker.""" - def __init__(self, container: str, teardown, prompt_path_in_container: str | None): + def __init__( + self, + container: str, + teardown: Callable[[], None], + prompt_path_in_container: str | None, + ): self.name = container self._teardown = teardown self._prompt_path = prompt_path_in_container diff --git a/claude_bottle/cli/__init__.py b/claude_bottle/cli/__init__.py index 71711fe..a6ca39e 100644 --- a/claude_bottle/cli/__init__.py +++ b/claude_bottle/cli/__init__.py @@ -9,13 +9,15 @@ import sys from ..log import Die, die from ._common import PROG +from . import list as _list_mod from .cleanup import cmd_cleanup from .edit import cmd_edit from .info import cmd_info from .init import cmd_init -from .list import cmd_list from .start import cmd_start +cmd_list = _list_mod.cmd_list + COMMANDS = { "cleanup": cmd_cleanup, "edit": cmd_edit, diff --git a/claude_bottle/env.py b/claude_bottle/env.py index e8a5739..0337214 100644 --- a/claude_bottle/env.py +++ b/claude_bottle/env.py @@ -47,8 +47,8 @@ class ResolvedEnv: resolve_env; the backend forwards by-name. `literals` carry their values verbatim and are serialized by the backend.""" - forwarded: list[str] = field(default_factory=list) - literals: dict[str, str] = field(default_factory=dict) + forwarded: list[str] = field(default_factory=list[str]) + literals: dict[str, str] = field(default_factory=dict[str, str]) def env_entry_kind(raw: str) -> str: diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..d2cbe87 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,15 @@ +{ + "include": [ + "cli.py", + "claude_bottle", + "tests" + ], + "exclude": [ + "**/__pycache__", + "**/.venv", + "**/venv" + ], + "pythonVersion": "3.11", + "typeCheckingMode": "strict", + "reportMissingTypeStubs": "none" +} diff --git a/tests/fixtures.py b/tests/fixtures.py index fae2f66..b5fd316 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -8,7 +8,7 @@ from __future__ import annotations import json import tempfile from pathlib import Path -from typing import Any +from typing import Any, Callable from claude_bottle.manifest import Manifest @@ -77,7 +77,7 @@ def fixture_with_ssh() -> Manifest: return Manifest.from_json_obj(fixture_with_ssh_dict()) -def write_fixture(fn) -> Path: +def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path: """Write fixture JSON to a temp file; return the path. Caller must rm. Accepts a function returning either a dict (JSON shape) or a Manifest; only the dict form is supported here since we need to serialize.""" diff --git a/tests/unit/test_manifest_runtime.py b/tests/unit/test_manifest_runtime.py index b365862..c41abf3 100644 --- a/tests/unit/test_manifest_runtime.py +++ b/tests/unit/test_manifest_runtime.py @@ -5,12 +5,13 @@ the legacy 'runtime' field must fail, regardless of value, rather than silently ignoring.""" import unittest +from typing import Any from claude_bottle.log import Die from claude_bottle.manifest import Bottle, Manifest -def _manifest_with_runtime(value: object) -> dict: +def _manifest_with_runtime(value: object) -> dict[str, Any]: return { "bottles": {"dev": {"runtime": value}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py index 4618e62..1818e05 100644 --- a/tests/unit/test_pipelock_yaml.py +++ b/tests/unit/test_pipelock_yaml.py @@ -10,6 +10,7 @@ import os import tempfile import unittest from pathlib import Path +from typing import Any, cast from claude_bottle.backend.docker.pipelock import DockerPipelockProxy from claude_bottle.manifest import Manifest @@ -27,19 +28,22 @@ class TestBuildConfig(unittest.TestCase): {"include_defaults": True, "scan_env": True}, cfg["dlp"] ) # Baked defaults always present. - self.assertIn("api.anthropic.com", cfg["api_allowlist"]) - self.assertIn("raw.githubusercontent.com", cfg["api_allowlist"]) + self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"])) + self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"])) # No SSH entries → no trusted_domains, no ssrf. self.assertNotIn("trusted_domains", cfg) self.assertNotIn("ssrf", cfg) def test_ssh_shape(self): cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"]) - self.assertIn("github.com", cfg["trusted_domains"]) - self.assertNotIn("100.78.141.42", cfg["trusted_domains"]) - self.assertIn("100.78.141.42/32", cfg["ssrf"]["ip_allowlist"]) + self.assertIn("github.com", cast(list[str], cfg["trusted_domains"])) + self.assertNotIn("100.78.141.42", cast(list[str], cfg["trusted_domains"])) + self.assertIn( + "100.78.141.42/32", + cast(dict[str, Any], cfg["ssrf"])["ip_allowlist"], + ) # Strict mode: IPv4 host is also in the api_allowlist union. - self.assertIn("100.78.141.42", cfg["api_allowlist"]) + self.assertIn("100.78.141.42", cast(list[str], cfg["api_allowlist"])) class TestRenderAndWrite(unittest.TestCase):