refactor(paths): v3 design - explicit initialize_paths + frozen PathsConfig singleton

This commit is contained in:
ed
2026-06-19 09:40:01 -04:00
parent 3fb9f9ff8e
commit 327b388800
11 changed files with 338 additions and 251 deletions
+110 -37
View File
@@ -149,47 +149,64 @@ def test_sandbox_allows_pytest_cache_write() -> None:
def test_config_override_via_cli_flag(tmp_path) -> None:
"""paths.set_config_override(path) makes get_config_path() return that path."""
"""paths.initialize_paths(config_path) makes all path getters return paths
rooted at config_path's [paths] section (or default).
[C: src/paths.py:initialize_paths, sloppy.py:main]"""
from src import paths
config_path = tmp_path / "my_config.toml"
config_path.write_text("[ai]\nprovider='gemini'\n", encoding="utf-8")
original = paths._CONFIG_OVERRIDE
paths.initialize_paths(config_path)
try:
paths.set_config_override(config_path)
assert paths.get_config_path() == config_path
assert paths.get_global_presets_path().name == "presets.toml"
assert paths.get_logs_dir().name == "sessions"
finally:
paths.set_config_override(original)
paths.reset_paths()
def test_paths_get_config_path_no_env_fallback(monkeypatch) -> None:
"""Without an override AND without SLOP_CONFIG, get_config_path returns default."""
monkeypatch.delenv("SLOP_CONFIG", raising=False)
def test_paths_runtime_refresh_atomic_swap(tmp_path) -> None:
"""Calling initialize_paths() a second time atomically swaps the singleton.
Reader threads see the new config immediately. The PathsConfig is frozen.
[C: src/paths.py:initialize_paths, src/paths.py:PathsConfig]"""
from src import paths
original = paths._CONFIG_OVERRIDE
try:
paths.set_config_override(None)
expected = Path(__file__).resolve().parent.parent / "config.toml"
assert paths.get_config_path() == expected
finally:
paths.set_config_override(original)
cfg_a = tmp_path / "a.toml"
cfg_b = tmp_path / "b.toml"
cfg_a.write_text("[ai]\nprovider='gemini-a'\n", encoding="utf-8")
cfg_b.write_text("[ai]\nprovider='gemini-b'\n", encoding="utf-8")
paths.initialize_paths(cfg_a)
assert paths.get_config_path() == cfg_a
paths.initialize_paths(cfg_b)
assert paths.get_config_path() == cfg_b
paths.reset_paths()
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.
[C: src/paths.py:_cfg]"""
from src import paths
paths.reset_paths()
with pytest.raises(RuntimeError, match="not initialized"):
paths.get_logs_dir()
paths.reset_paths()
def test_sloppy_py_parses_config_flag() -> None:
"""sloppy.py has a --config argparse argument that calls set_config_override."""
"""sloppy.py has a --config argparse argument that calls initialize_paths."""
import ast
sloppy = Path(__file__).resolve().parent.parent / "sloppy.py"
tree = ast.parse(sloppy.read_text(encoding="utf-8"))
found_config_arg = False
found_set_override_call = False
found_init_call = False
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and node.value == "--config":
found_config_arg = True
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == "set_config_override":
found_set_override_call = True
if isinstance(func, ast.Name) and func.id == "initialize_paths":
found_init_call = True
assert found_config_arg, "sloppy.py must have a --config argparse argument"
assert found_set_override_call, "sloppy.py must call paths.set_config_override(args.config)"
assert found_init_call, "sloppy.py must call paths.initialize_paths(args.config)"
def test_pyproject_toml_basetemp_is_under_tests() -> None:
@@ -276,30 +293,86 @@ def test_config_overrides_toml_has_paths_section() -> None:
)
def test_path_getters_read_from_config_paths_section() -> None:
"""Every global path getter in src/paths.py reads from the [paths] section
of the active config when no env var is set. Verifies via AST that each
get_*_path() function calls _resolve_path (not raw os.environ.get)."""
def test_path_getters_are_trivial_field_access() -> None:
"""Every global path getter in src/paths.py is a trivial field access on the
PathsConfig singleton (return _cfg().<field>). They must NOT do file I/O
or call _resolve_path() (which is internal-only, called from initialize_paths).
This enforces the v3 design: explicit init at startup, trivial getters."""
import ast
paths_py = Path(__file__).resolve().parent.parent / "src" / "paths.py"
tree = ast.parse(paths_py.read_text(encoding="utf-8"))
getters = {
"get_global_presets_path": "presets",
"get_global_tool_presets_path": "tool_presets",
"get_global_personas_path": "personas",
"get_global_themes_path": "themes",
"get_global_workspace_profiles_path": "workspace_profiles",
"get_credentials_path": "credentials",
"get_logs_dir": "logs_dir",
"get_scripts_dir": "scripts_dir",
}
trivial_getters = [
"get_global_presets_path", "get_global_tool_presets_path",
"get_global_personas_path", "get_global_themes_path",
"get_global_workspace_profiles_path", "get_credentials_path",
"get_logs_dir", "get_scripts_dir",
]
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name in getters:
if isinstance(node, ast.FunctionDef) and node.name in trivial_getters:
src = ast.unparse(node)
assert "_resolve_path(" in src, (
f"{node.name} must call _resolve_path() to read from config.toml [paths]; "
f"got direct os.environ.get() instead."
assert "_cfg()" in src, (
f"{node.name} must call _cfg() to read from the PathsConfig singleton; "
f"got direct file I/O or env var lookup instead."
)
assert "_resolve_path(" not in src, (
f"{node.name} must NOT call _resolve_path(); that's internal-only "
f"(called once from initialize_paths)."
)
assert "os.environ" not in src, (
f"{node.name} must NOT use os.environ directly; env vars are read once "
f"during initialize_paths()."
)
def test_initialize_paths_thread_safe_atomic_swap(tmp_path) -> None:
"""initialize_paths() uses an RLock; concurrent swaps don't corrupt the
singleton. Reader threads always see a consistent PathsConfig snapshot
(frozen dataclass). Per the user's "bad programmers take shortcuts" rule:
no torn writes, no partial reads.
[C: src/paths.py:_PATHS_LOCK, src/paths.py:PathsConfig]"""
import threading
import tomllib
from src import paths
cfg_a = tmp_path / "a.toml"
cfg_b = tmp_path / "b.toml"
cfg_a.write_bytes(b"[paths]\nlogs_dir = 'C:/tmp/thread_a'\n")
cfg_b.write_bytes(b"[paths]\nlogs_dir = 'C:/tmp/thread_b'\n")
errors = []
def swap(cfg, n):
for _ in range(n):
try:
paths.initialize_paths(cfg)
except Exception as e:
errors.append(e)
t1 = threading.Thread(target=swap, args=(cfg_a, 100))
t2 = threading.Thread(target=swap, args=(cfg_b, 100))
t1.start(); t2.start()
t1.join(); t2.join()
assert not errors, f"thread-safety violation: {errors}"
paths.reset_paths()
def test_pathsconfig_is_frozen_dataclass() -> None:
"""PathsConfig uses @dataclass(frozen=True) so individual field reads are
atomic and cannot be mutated by readers. Per user directive: gated
transactions, no data race over config.
[C: src/paths.py:PathsConfig]"""
import dataclasses
import tempfile, tomli_w
from src import paths
params = getattr(paths.PathsConfig, "__dataclass_params__", None)
assert params is not None, "PathsConfig must be a dataclass"
assert params.frozen is True, "PathsConfig must be @dataclass(frozen=True)"
with tempfile.NamedTemporaryFile(suffix=".toml", delete=False, mode="wb") as f:
tomli_w.dump({"paths": {"logs_dir": "C:/tmp/frozen_test"}}, f)
cfg = Path(f.name)
try:
paths.initialize_paths(cfg)
with pytest.raises(dataclasses.FrozenInstanceError):
paths._PATHS_CONFIG.presets = Path("/tmp/should_fail")
finally:
paths.reset_paths()
cfg.unlink()
@pytest.mark.skipif(os.name != "nt", reason="Windows-only sandbox wrapper")