diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index e581856..dc7e911 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -66,6 +66,7 @@ COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py COPY bot_bottle/egress_addon.py /app/egress_addon.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py +COPY bot_bottle/paths.py /app/paths.py COPY bot_bottle/migrations.py /app/migrations.py COPY bot_bottle/db_store.py /app/db_store.py COPY bot_bottle/supervise_types.py /app/supervise_types.py diff --git a/bot_bottle/audit_store.py b/bot_bottle/audit_store.py index 7f25e9e..c01d2bb 100644 --- a/bot_bottle/audit_store.py +++ b/bot_bottle/audit_store.py @@ -6,11 +6,13 @@ import sqlite3 from pathlib import Path try: - from .supervise_types import AuditEntry, host_db_path + from .supervise_types import AuditEntry + from .paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import AuditEntry, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervise_types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 254c63f..298d5fe 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -26,7 +26,7 @@ from __future__ import annotations import shutil import subprocess -from ... import supervise as _supervise +from ...paths import bot_bottle_root from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan @@ -94,7 +94,7 @@ def _list_orphan_state_dirs( ANY backend — used so this docker-side check doesn't reap a running non-docker bottle's state dir (the layout is shared across backends).""" - state_root = _supervise.bot_bottle_root() / "state" + state_root = bot_bottle_root() / "state" if not state_root.is_dir(): return [] orphans: list[str] = [] diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index b97c89b..1ac5bb0 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -36,7 +36,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from . import supervise as _supervise +from .paths import bot_bottle_root # Directory layout: ~/.bot-bottle/state//... @@ -163,7 +163,7 @@ def read_metadata(identity: str) -> BottleMetadata | None: def bottle_state_dir(identity: str) -> Path: """Per-bottle state directory on the host. Created lazily by the write helpers; readers tolerate its absence.""" - return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity + return bot_bottle_root() / _STATE_SUBDIR / identity def per_bottle_dockerfile_path(identity: str) -> Path: diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index bf0766b..ff66ba6 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from .. import supervise as _supervise +from ..paths import bot_bottle_root from ..bottle_state import read_metadata from ..backend.docker.egress_apply import ( EgressApplyError, @@ -276,7 +276,7 @@ def _write_crash_log(exc: BaseException) -> Path: ) entry = f"=== supervise crash {stamp} ===\n{body}\n" try: - log_dir = _supervise.bot_bottle_root() / "logs" + log_dir = bot_bottle_root() / "logs" log_dir.mkdir(parents=True, exist_ok=True) path = log_dir / "supervise-crash.log" with path.open("a", encoding="utf-8") as fh: diff --git a/bot_bottle/db_store.py b/bot_bottle/db_store.py index 8950401..5d4bf37 100644 --- a/bot_bottle/db_store.py +++ b/bot_bottle/db_store.py @@ -11,30 +11,6 @@ except ImportError: from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module -# The single shared host state DB. All bot-bottle SQLite stores (supervise -# queue, audit, the orchestrator registry) co-tenant this one file — the -# TableMigrations schema_key namespaces each store's tables. -HOST_DB_FILENAME = "bot-bottle.db" - - -def host_db_path() -> Path: - """Path to the shared host state DB, `/db/bot-bottle.db`. - - Kept in its own `db/` subdirectory (not directly under the root) so a - backend that can only bind-mount *directories* can share this one file - with a sidecar without exposing the root's other contents (git-gate - keys, per-bottle state, ...). - - Resolves `bot_bottle_root` from `supervise_types` at call time (a lazy - import — avoids a load-time cycle, and lets a monkey-patch of - `supervise_types.bot_bottle_root` propagate here).""" - try: - from . import supervise_types as _st - except ImportError: # flat imports inside the sidecar bundle - import supervise_types as _st # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module - return _st.bot_bottle_root() / "db" / HOST_DB_FILENAME - - class DbVersionError(Exception): """Raised when the on-disk schema is behind the current migration list.""" @@ -80,4 +56,4 @@ class DbStore: pass -__all__ = ["DbStore", "DbVersionError", "HOST_DB_FILENAME", "host_db_path"] +__all__ = ["DbStore", "DbVersionError"] diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py index be835c0..dd962a5 100644 --- a/bot_bottle/orchestrator/registry.py +++ b/bot_bottle/orchestrator/registry.py @@ -35,8 +35,9 @@ import time from dataclasses import dataclass from pathlib import Path -from ..db_store import DbStore, host_db_path +from ..db_store import DbStore from ..migrations import TableMigrations +from ..paths import host_db_path # 256 bits of urandom, URL-safe — unguessable per-bottle identity token. IDENTITY_TOKEN_BYTES = 32 diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py new file mode 100644 index 0000000..8ac64ad --- /dev/null +++ b/bot_bottle/paths.py @@ -0,0 +1,43 @@ +"""Foundational filesystem paths for bot-bottle. + +`bot_bottle_root()` is the app data root — state, queue, audit logs, +git-gate keys, and the shared DB all live under it. It defaults to +`~/.bot-bottle` and is overridable with the **`BOT_BOTTLE_ROOT`** env var. + +The env override is the single knob for redirecting the root: the test +suite points it at a throwaway dir instead of monkey-patching the function +(every module and every flat/package copy reads the same env var, so one +override covers them all), and operators can relocate the root if needed. + +This module has no bot-bottle imports, so it is safe to import from any +layer (and to COPY flat into the sidecar bundle). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# The single shared host state DB. All bot-bottle SQLite stores (supervise +# queue, audit, the orchestrator registry) co-tenant this one file — the +# TableMigrations schema_key namespaces each store's tables. +HOST_DB_FILENAME = "bot-bottle.db" + + +def bot_bottle_root() -> Path: + """The app data root — `$BOT_BOTTLE_ROOT` if set, else `~/.bot-bottle`.""" + override = os.environ.get("BOT_BOTTLE_ROOT") + return Path(override) if override else Path.home() / ".bot-bottle" + + +def host_db_path() -> Path: + """Path to the shared host state DB, `/db/bot-bottle.db`. + + Kept in its own `db/` subdirectory (not directly under the root) so a + backend that can only bind-mount *directories* can share this one file + with a sidecar without exposing the root's other contents (git-gate + keys, per-bottle state, ...).""" + return bot_bottle_root() / "db" / HOST_DB_FILENAME + + +__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path"] diff --git a/bot_bottle/queue_store.py b/bot_bottle/queue_store.py index 0fe3295..cd6b188 100644 --- a/bot_bottle/queue_store.py +++ b/bot_bottle/queue_store.py @@ -7,11 +7,13 @@ import sqlite3 from pathlib import Path try: - from .supervise_types import Proposal, Response, host_db_path + from .supervise_types import Proposal, Response + from .paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import Proposal, Response, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/store_manager.py b/bot_bottle/store_manager.py index 70b1f46..bb1c494 100644 --- a/bot_bottle/store_manager.py +++ b/bot_bottle/store_manager.py @@ -23,13 +23,10 @@ class StoreManager: def __init__(self, db_path: Path | None = None) -> None: if db_path is None: - # Lazy import to avoid a circular dependency: supervise imports - # StoreManager at module level; StoreManager must not import - # supervise at module level in return. try: - from .supervise import host_db_path + from .paths import host_db_path except ImportError: - from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module db_path = host_db_path() self.db_path = db_path diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index cfa6a51..d1d9596 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -41,7 +41,6 @@ try: from .supervise_types import ( ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -54,13 +53,11 @@ try: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) except ImportError: from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -73,10 +70,15 @@ except ImportError: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) +try: + from .paths import bot_bottle_root, host_db_path +except ImportError: # flat imports inside the sidecar bundle + from paths import bot_bottle_root, host_db_path # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module + + SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 @@ -106,16 +108,6 @@ def audit_dir() -> Path: return bot_bottle_root() / "audit" -def host_db_path() -> Path: - # Calls bot_bottle_root() through this module's globals so that patches - # on supervise.bot_bottle_root propagate to callers going through - # supervise.host_db_path(). - # - # Kept in its own "db" subdirectory (see supervise_types.host_db_path - # for why) — must stay in sync with that copy. - return bot_bottle_root() / "db" / HOST_DB_FILENAME - - def audit_log_path(component: str, slug: str) -> Path: return audit_dir() / f"{component}-{slug}.log" diff --git a/bot_bottle/supervise_types.py b/bot_bottle/supervise_types.py index 1e579f0..d62343d 100644 --- a/bot_bottle/supervise_types.py +++ b/bot_bottle/supervise_types.py @@ -1,14 +1,11 @@ -"""Shared types and path helpers for supervise stores (PRD 0013). +"""Shared types for supervise stores (PRD 0013). Extracted from supervise.py so queue_store and audit_store can import -Proposal, Response, AuditEntry, and host_db_path without creating a -circular import (supervise imports from queue_store/audit_store and -vice-versa). +Proposal, Response, and AuditEntry without creating a circular import +(supervise imports from queue_store/audit_store and vice-versa). -host_db_path / HOST_DB_FILENAME now live in db_store (shared DB infra) and -are re-exported here for the historical import path. db_store.host_db_path -resolves bot_bottle_root from this module lazily, so patching -supervise_types.bot_bottle_root still propagates to the DB path. +The path helpers (`bot_bottle_root`, `host_db_path`, `HOST_DB_FILENAME`) +live in `paths` — import them from there. """ from __future__ import annotations @@ -17,16 +14,6 @@ import dataclasses import uuid from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path - -# host_db_path / HOST_DB_FILENAME now live in db_store (shared DB infra); -# re-exported here for the historical import path. db_store resolves -# bot_bottle_root from this module lazily, so patching -# supervise_types.bot_bottle_root still propagates. -try: - from .db_store import HOST_DB_FILENAME, host_db_path -except ImportError: # flat imports inside the sidecar bundle - from db_store import HOST_DB_FILENAME, host_db_path # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_ALLOW = "egress-allow" @@ -49,10 +36,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) ACTION_OPERATOR_EDIT = "operator-edit" -def bot_bottle_root() -> Path: - return Path.home() / ".bot-bottle" - - def _require_str(raw: dict[str, object], key: str) -> str: value = raw.get(key) if not isinstance(value, str): @@ -164,7 +147,6 @@ class AuditEntry: __all__ = [ "ACTION_OPERATOR_EDIT", "AuditEntry", - "HOST_DB_FILENAME", "Proposal", "Response", "STATUSES", @@ -177,6 +159,4 @@ __all__ = [ "TOOL_EGRESS_TOKEN_ALLOW", "TOOL_GITLEAKS_ALLOW", "TOOL_LIST_EGRESS_ROUTES", - "bot_bottle_root", - "host_db_path", ] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index da5784b..7e69d1d 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -10,8 +10,12 @@ and unisolated tests otherwise pollute the developer's home dir. Individual tests that need their own ``HOME`` still override ``os.environ['HOME']`` and restore it; they now restore to this isolated -dir rather than the real one, so isolation holds either way. Tests that -patch ``supervise.bot_bottle_root`` directly are unaffected. +dir rather than the real one, so isolation holds either way. + +Tests that need their own app-data root call ``use_bottle_root`` (below), +which sets ``BOT_BOTTLE_ROOT`` — the supported override read by +``paths.bot_bottle_root`` — instead of monkey-patching. One env setting +covers every module and every flat/package copy of the helper. """ from __future__ import annotations @@ -20,6 +24,9 @@ import atexit import os import shutil import tempfile +from collections.abc import Callable +from pathlib import Path +from unittest import mock _real_home = os.environ.get("HOME") _tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.") @@ -35,3 +42,15 @@ def _restore_home() -> None: atexit.register(_restore_home) + + +def use_bottle_root(root: Path) -> Callable[[], None]: + """Redirect ``paths.bot_bottle_root()`` to ``root`` by setting + ``BOT_BOTTLE_ROOT`` — the supported override. Returns a callable that + undoes it (store it as the test's restore hook, or pass to addCleanup). + + Replaces monkey-patching ``supervise.bot_bottle_root``; one env var + covers every module and the flat/package copies alike (they all read it).""" + patcher = mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(root)}) + patcher.start() + return patcher.stop diff --git a/tests/unit/test_backend_freezer.py b/tests/unit/test_backend_freezer.py index 40241b3..3cb7d54 100644 --- a/tests/unit/test_backend_freezer.py +++ b/tests/unit/test_backend_freezer.py @@ -7,7 +7,8 @@ import unittest from pathlib import Path from unittest.mock import patch -from bot_bottle import supervise, bottle_state +from bot_bottle import bottle_state +from tests.unit import use_bottle_root from bot_bottle.backend import ActiveAgent from bot_bottle.backend.freeze import get_freezer from bot_bottle.backend.docker.freezer import DockerFreezer @@ -18,13 +19,7 @@ from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py index 6423ccd..6013229 100644 --- a/tests/unit/test_backend_prepare.py +++ b/tests/unit/test_backend_prepare.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import patch from bot_bottle import bottle_state -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.resolve_common import mint_slug @@ -55,11 +55,10 @@ class _FakeStateMixin: def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.") self.root = Path(self.tmp.name) / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp.cleanup() diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py index f57fa02..a7463f3 100644 --- a/tests/unit/test_backend_workspace.py +++ b/tests/unit/test_backend_workspace.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import MagicMock, call, patch from bot_bottle import bottle_state -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.firecracker import FirecrackerBottleBackend @@ -55,11 +55,10 @@ class _FakeStateMixin: self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") self.tmp = Path(self.tmp_dir.name) self.root = self.tmp / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp_dir.cleanup() diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index c9abd92..df7a200 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -6,7 +6,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.bottle_state import ( BottleMetadata, @@ -18,13 +18,7 @@ from bot_bottle.bottle_state import ( class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_commit.py b/tests/unit/test_cli_commit.py index 2dd2708..6a7e7ff 100644 --- a/tests/unit/test_cli_commit.py +++ b/tests/unit/test_cli_commit.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch from bot_bottle.cli.commit import cmd_commit -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.freeze import CommitCancelled @@ -16,13 +16,7 @@ from bot_bottle.backend.freeze import CommitCancelled class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index 9a94ba5..4b1c405 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -8,7 +8,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.cli import start as start_mod @@ -16,15 +16,10 @@ from bot_bottle.cli import start as start_mod class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.") - self._original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): - supervise.bot_bottle_root = self._original # type: ignore[assignment] + self._restore() self._tmp.cleanup() diff --git a/tests/unit/test_docker_cleanup.py b/tests/unit/test_docker_cleanup.py index aeab400..730f20f 100644 --- a/tests/unit/test_docker_cleanup.py +++ b/tests/unit/test_docker_cleanup.py @@ -15,7 +15,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs @@ -23,13 +23,7 @@ from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore() diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index b6aca74..c774c28 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -23,7 +23,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker import enumerate as _enumerate @@ -75,13 +75,7 @@ class TestParseServicesByProject(unittest.TestCase): class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore_home() diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index de51c57..12d3b1a 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -8,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend.egress_apply import EgressApplyError from bot_bottle.backend.docker.egress_apply import applicator @@ -67,14 +67,8 @@ class TestValidateRoutesContent(unittest.TestCase): class TestApplyRoutesChange(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original)) self.addCleanup(self._tmp.cleanup) + self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle")) def test_writes_live_routes_and_signals_reload(self): calls: list[list[str]] = [] diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index bc69b89..01198a8 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from pathlib import Path from bot_bottle import supervise -from bot_bottle import supervise_types as sv_types +from tests.unit import use_bottle_root from bot_bottle.audit_store import AuditStore from bot_bottle.queue_store import QueueStore from bot_bottle.supervise import ( @@ -123,20 +123,7 @@ class TestQueueIO(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_write_and_read_proposal(self): p = _proposal() @@ -240,20 +227,7 @@ class TestAuditLog(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_write_then_read_single_entry(self): e = AuditEntry( @@ -400,20 +374,7 @@ class TestSupervisePrepare(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_prepare_creates_queue(self): plan = _StubSupervise().prepare("dev", self.stage_dir) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 9903da3..9b74ec5 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch from bot_bottle import supervise -from bot_bottle import supervise_types as sv_types +from tests.unit import use_bottle_root from bot_bottle.audit_store import AuditStore from bot_bottle.cli import supervise as supervise_cli from bot_bottle.queue_store import QueueStore @@ -55,20 +55,7 @@ class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - self._restore_home = restore + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") QueueStore("").migrate() AuditStore().migrate() diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index 56cb6f0..3fca465 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -11,12 +11,13 @@ from __future__ import annotations import contextlib import io +import os import tempfile import unittest from pathlib import Path from unittest import mock -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.cli import supervise as supervise_cli from bot_bottle.log import Die, die @@ -45,12 +46,11 @@ class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") - self._orig_root = supervise.bot_bottle_root self._root = Path(self._tmp.name) / ".bot-bottle" - supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment] + self._restore_root = use_bottle_root(self._root) def _teardown_fake_home(self): - supervise.bot_bottle_root = self._orig_root # type: ignore[assignment] + self._restore_root() self._tmp.cleanup() @@ -127,11 +127,11 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): # OSError and the helper must fall back to a tempfile. bad = Path(self._tmp.name) / "not-a-dir" bad.write_text("x") - supervise.bot_bottle_root = lambda: bad # type: ignore[assignment] - try: - raise RuntimeError("explode2") - except RuntimeError as e: - path = supervise_cli._write_crash_log(e) + with mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(bad)}): + try: + raise RuntimeError("explode2") + except RuntimeError as e: + path = supervise_cli._write_crash_log(e) self.assertTrue(path.exists()) self.assertIn("explode2", path.read_text()) diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 9ea47e5..a29f032 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -10,6 +10,8 @@ import unittest from pathlib import Path from unittest.mock import patch +from tests.unit import use_bottle_root + # The server module loads `supervise` via same-directory import inside # the container (Dockerfile.supervise WORKDIRs into /app). For tests @@ -19,10 +21,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bott import supervise as _sv # noqa: E402 # type: ignore import queue_store as _qs # noqa: E402 # type: ignore import audit_store as _as # noqa: E402 # type: ignore -import supervise_types as svt_flat # noqa: E402 # type: ignore from bot_bottle import supervise_server # noqa: E402 -from bot_bottle import supervise_types as svt_pkg # noqa: E402 from bot_bottle.supervise_server import ( ERR_INTERNAL, ERR_INVALID_PARAMS, @@ -279,23 +279,7 @@ class TestHandleToolsCall(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = _sv.bot_bottle_root - original_flat = svt_flat.bot_bottle_root - original_pkg = svt_pkg.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - _sv.bot_bottle_root = fake_root # type: ignore[assignment] - svt_flat.bot_bottle_root = fake_root # type: ignore[assignment] - svt_pkg.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - _sv.bot_bottle_root = original_sv # type: ignore[assignment] - svt_flat.bot_bottle_root = original_flat # type: ignore[assignment] - svt_pkg.bot_bottle_root = original_pkg # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: """Background thread: poll the queue for a fresh proposal, write a