diff --git a/src/paths.py b/src/paths.py index 005becbb..7d494e5f 100644 --- a/src/paths.py +++ b/src/paths.py @@ -68,6 +68,39 @@ _PATHS_CONFIG: Optional[PathsConfig] = None _PATHS_LOCK = threading.RLock() +def _default_paths_config() -> PathsConfig: + """Build the default PathsConfig (no [paths] overrides, just defaults). + Called once at module load to ensure _PATHS_CONFIG is never None for + callers that don't explicitly initialize (e.g., subprocess imports). + [C: src/paths.py:initialize_paths, src/paths.py:_module_init_default]""" + root_dir = Path(__file__).resolve().parent.parent + config_path = root_dir / "config.toml" + cfg = PathsConfig( + config_path = config_path, + presets = root_dir / "presets.toml", + tool_presets = root_dir / "tool_presets.toml", + personas = root_dir / "personas.toml", + themes = root_dir / "themes", + workspace_profiles = root_dir / "workspace_profiles.toml", + credentials = root_dir / "credentials.toml", + logs_dir = root_dir / "logs" / "sessions", + scripts_dir = root_dir / "scripts" / "generated", + ) + return cfg + + +def _module_init_default() -> None: + """Initialize _PATHS_CONFIG with defaults at module load. + Idempotent. Subsequent calls to initialize_paths() override this. + [C: src/paths.py:initialize_paths, src/paths.py:reset_paths]""" + global _PATHS_CONFIG + if _PATHS_CONFIG is None: + _PATHS_CONFIG = _default_paths_config() + + +_module_init_default() + + def _resolve_path(env_var: str, config_key: str, default: Path, config_path: Path) -> Path: """Internal: resolve one path from env var -> config [paths] -> default. Called only from initialize_paths(). Not thread-safe; caller holds lock.""" diff --git a/tests/test_test_sandbox.py b/tests/test_test_sandbox.py index bc6cca6c..85b4784d 100644 --- a/tests/test_test_sandbox.py +++ b/tests/test_test_sandbox.py @@ -181,14 +181,36 @@ def test_paths_runtime_refresh_atomic_swap(tmp_path) -> None: def test_paths_uninitialized_raises(tmp_path) -> None: - """Calling a path getter before initialize_paths() raises RuntimeError - (not silent fallback). This is the contract that enforces explicit init. + """After explicit paths.reset_paths() (i.e., user CLEARED the singleton + after a previous init), a getter raises RuntimeError. This is the + "bad programmer" detection — once cleared, you must re-init. [C: src/paths.py:_cfg]""" from src import paths + paths.initialize_paths(tmp_path / "dummy.toml") paths.reset_paths() with pytest.raises(RuntimeError, match="not initialized"): paths.get_logs_dir() - paths.reset_paths() + + +def test_paths_module_load_initializes_defaults(tmp_path) -> None: + """src/paths.py initializes _PATHS_CONFIG with defaults at module load. + This means subprocess imports that don't go through conftest.py (e.g., + _run_in_subprocess tests) still have valid paths for any src/* module + that triggers a paths getter at import time (e.g., theme_2.load_themes). + [C: src/paths.py:_module_init_default]""" + import importlib + import src.paths as paths_module + # Reload to simulate fresh module load in subprocess + importlib.reload(paths_module) + # After module reload, defaults should be set + assert paths_module._PATHS_CONFIG is not None, ( + "src.paths must initialize _PATHS_CONFIG at module load " + "so subprocess imports don't trigger 'paths not initialized' errors." + ) + default_logs = paths_module._PATHS_CONFIG.logs_dir + assert default_logs.name == "sessions", ( + f"default logs_dir should end in 'sessions'; got {default_logs}" + ) def test_sloppy_py_parses_config_flag() -> None: