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 contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Generic, TypeVar from typing import Any, Generic, TypeVar
from ..log import die from ..log import die
from ..manifest import Manifest from ..manifest import Manifest
@@ -199,14 +199,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
from .docker import DockerBottleBackend # noqa: E402 from .docker import DockerBottleBackend # noqa: E402
# The dict carries heterogeneous BottleBackend specializations; callers # The dict is heterogeneous: each value is a BottleBackend specialized
# use it through the unparameterized BottleBackend interface. # over its own plan type. Concrete plan types are erased here because
_BACKENDS: dict[str, BottleBackend] = { # 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(), "docker": DockerBottleBackend(),
} }
def get_bottle_backend() -> BottleBackend: def get_bottle_backend() -> BottleBackend[Any, Any]:
"""Resolve the bottle backend for the active environment. Dies with """Resolve the bottle backend for the active environment. Dies with
a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an
unimplemented one.""" unimplemented one."""
+2 -2
View File
@@ -16,7 +16,7 @@ import subprocess
import sys import sys
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Iterator, Sequence from typing import Generator, Sequence
from ... import pipelock from ... import pipelock
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
@@ -165,7 +165,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
env_file.chmod(0o600) env_file.chmod(0o600)
@contextmanager @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.""" """Build, launch, and provision a Docker bottle. Teardown on exit."""
stack = ExitStack() stack = ExitStack()
+7 -1
View File
@@ -9,6 +9,7 @@ prompt was provisioned.
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
from typing import Callable
from .. import Bottle from .. import Bottle
@@ -16,7 +17,12 @@ from .. import Bottle
class DockerBottle(Bottle): class DockerBottle(Bottle):
"""Concrete Bottle for Docker.""" """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.name = container
self._teardown = teardown self._teardown = teardown
self._prompt_path = prompt_path_in_container self._prompt_path = prompt_path_in_container
+3 -1
View File
@@ -9,13 +9,15 @@ import sys
from ..log import Die, die from ..log import Die, die
from ._common import PROG from ._common import PROG
from . import list as _list_mod
from .cleanup import cmd_cleanup from .cleanup import cmd_cleanup
from .edit import cmd_edit from .edit import cmd_edit
from .info import cmd_info from .info import cmd_info
from .init import cmd_init from .init import cmd_init
from .list import cmd_list
from .start import cmd_start from .start import cmd_start
cmd_list = _list_mod.cmd_list
COMMANDS = { COMMANDS = {
"cleanup": cmd_cleanup, "cleanup": cmd_cleanup,
"edit": cmd_edit, "edit": cmd_edit,
+2 -2
View File
@@ -47,8 +47,8 @@ class ResolvedEnv:
resolve_env; the backend forwards by-name. `literals` carry their resolve_env; the backend forwards by-name. `literals` carry their
values verbatim and are serialized by the backend.""" values verbatim and are serialized by the backend."""
forwarded: list[str] = field(default_factory=list) forwarded: list[str] = field(default_factory=list[str])
literals: dict[str, str] = field(default_factory=dict) literals: dict[str, str] = field(default_factory=dict[str, str])
def env_entry_kind(raw: 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 json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
@@ -77,7 +77,7 @@ def fixture_with_ssh() -> Manifest:
return Manifest.from_json_obj(fixture_with_ssh_dict()) 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. """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; Accepts a function returning either a dict (JSON shape) or a Manifest;
only the dict form is supported here since we need to serialize.""" 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.""" silently ignoring."""
import unittest import unittest
from typing import Any
from claude_bottle.log import Die from claude_bottle.log import Die
from claude_bottle.manifest import Bottle, Manifest from claude_bottle.manifest import Bottle, Manifest
def _manifest_with_runtime(value: object) -> dict: def _manifest_with_runtime(value: object) -> dict[str, Any]:
return { return {
"bottles": {"dev": {"runtime": value}}, "bottles": {"dev": {"runtime": value}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+10 -6
View File
@@ -10,6 +10,7 @@ import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import Any, cast
from claude_bottle.backend.docker.pipelock import DockerPipelockProxy from claude_bottle.backend.docker.pipelock import DockerPipelockProxy
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
@@ -27,19 +28,22 @@ class TestBuildConfig(unittest.TestCase):
{"include_defaults": True, "scan_env": True}, cfg["dlp"] {"include_defaults": True, "scan_env": True}, cfg["dlp"]
) )
# Baked defaults always present. # Baked defaults always present.
self.assertIn("api.anthropic.com", cfg["api_allowlist"]) self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
self.assertIn("raw.githubusercontent.com", cfg["api_allowlist"]) self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
# No SSH entries → no trusted_domains, no ssrf. # No SSH entries → no trusted_domains, no ssrf.
self.assertNotIn("trusted_domains", cfg) self.assertNotIn("trusted_domains", cfg)
self.assertNotIn("ssrf", cfg) self.assertNotIn("ssrf", cfg)
def test_ssh_shape(self): def test_ssh_shape(self):
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"]) cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
self.assertIn("github.com", cfg["trusted_domains"]) self.assertIn("github.com", cast(list[str], cfg["trusted_domains"]))
self.assertNotIn("100.78.141.42", cfg["trusted_domains"]) self.assertNotIn("100.78.141.42", cast(list[str], cfg["trusted_domains"]))
self.assertIn("100.78.141.42/32", cfg["ssrf"]["ip_allowlist"]) 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. # 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): class TestRenderAndWrite(unittest.TestCase):