chore(types): add pyright strict config and fix resulting errors
test / unit (push) Successful in 11s
test / integration (push) Successful in 12s

Adds pyrightconfig.json (strict, Python 3.11) covering cli.py,
claude_bottle/, and tests/. Fixes the 49 strict-mode errors:

- Type DockerBottle.teardown as Callable[[], None].
- ResolvedEnv default_factory uses parameterized list[str] / dict[str, str].
- Erase BottleBackend generics at the registry boundary
  (BottleBackend[Any, Any]) since selection is runtime-driven and
  callers use the unparameterized interface.
- DockerBottleBackend.launch returns Generator[DockerBottle, None, None];
  @contextmanager now flags Iterator returns as deprecated.
- Sidestep cli.list submodule shadowing builtins.list in main()'s argv
  annotation via an aliased re-import in cli/__init__.py.
- Cast cfg[...] results in test_pipelock_yaml at the dict[str, object]
  boundary.
- Annotate write_fixture's fn parameter and _manifest_with_runtime's
  return type.
This commit is contained in:
2026-05-12 10:03:48 -04:00
parent ac634edcb6
commit 64a31a382b
9 changed files with 50 additions and 20 deletions
+7 -5
View File
@@ -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."""
+2 -2
View File
@@ -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()
+7 -1
View File
@@ -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
+3 -1
View File
@@ -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,
+2 -2
View File
@@ -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:
+15
View File
@@ -0,0 +1,15 @@
{
"include": [
"cli.py",
"claude_bottle",
"tests"
],
"exclude": [
"**/__pycache__",
"**/.venv",
"**/venv"
],
"pythonVersion": "3.11",
"typeCheckingMode": "strict",
"reportMissingTypeStubs": "none"
}
+2 -2
View File
@@ -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."""
+2 -1
View File
@@ -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"}},
+10 -6
View File
@@ -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):