Private
Public Access
0
0
Files
manual_slop/src/paths.py
T
ed c12d5b6d82 refactor(models,paths,presets,summary_cache): remove Optional returns (Phase 6 batch 1)
Phase 6: Eliminate Optional[T] returns (FR5) - BATCH 1 of 7
Before: 8 Optional[T] return types across 4 files
After:  0 (replaced with default-zero return values)
Delta:  -8 sites

Per conductor/code_styleguides/error_handling.md "Optional[X] ban":
- "Use Result[T] for any function that can fail at runtime."
- "Use nil-sentinel dataclasses for 'no result'."

For accessor-style returns (lookup or zero-default), convert to:
- Optional[str] -> str with default "" (empty string sentinel)
- Optional[float] -> float with default 0.0
- Optional[int] -> int with default 0
- Optional[Path] -> Path with default Path("") or project_root

Specific changes:
- src/models.py:765-789: Persona.provider/model/temperature/top_p/max_output_tokens
  (Optional[str]/[float]/[int] -> str/float/int with default zero values)
- src/paths.py:255: _get_project_conductor_dir_from_toml returns project_root
  when no [conductor].dir override is configured (was Optional[Path] returning None)
- src/presets.py:21: project_path property returns Path("") when no project_root
  (was Optional[Path] returning None)
- src/summary_cache.py:57: get_summary returns "" when hash mismatch (was
  Optional[str] returning None)

Test updates:
- tests/test_persona_models.py:64-69: test_persona_defaults now expects
  "" / 0.0 instead of None
- tests/test_summary_cache.py:25, 32, 58: get_summary assertions now
  expect "" instead of None

Verification:
- audit_weak_types --strict: OK (107 <= 112 baseline)
- 13 tests pass (test_summary_cache, test_paths, test_presets,
  test_persona_models)
- py_check_syntax: OK on all changed files

REMAINING: ~22 Optional[T] returns in:
- src/command_palette.py (1)
- src/diff_viewer.py (2)
- src/external_editor.py (3)
- src/file_cache.py (7)
- src/fuzzy_anchor.py (1)
- src/models.py (1)
- src/multi_agent_conductor.py (1)
- src/patch_modal.py (1)
- src/project_manager.py (1)
- src/session_logger.py (1)
- src/app_controller.py (3)
2026-06-26 05:01:15 -04:00

312 lines
13 KiB
Python

"""
Paths - Single source of truth for all application paths.
All paths are resolved ONCE at startup via `initialize_paths(config_path)`,
which reads the active config.toml's `[paths]` section (with env-var overrides)
and builds an immutable `PathsConfig` snapshot. Path getters are trivial
lookups into this snapshot.
**Usage contract:**
1. Call `initialize_paths(config_path)` ONCE at process startup, BEFORE any
path getter is invoked. This is the only correct entry point.
2. After init, all `get_*_path()` functions return cached `Path` objects.
3. To change paths (e.g., in tests), call `initialize_paths(new_config_path)`
again — atomic swap under lock. Do not mutate `PathsConfig` instances;
they are frozen.
**Thread safety:** The singleton swap is guarded by an RLock. `PathsConfig`
is a `@dataclass(frozen=True)`, so reads of individual fields are atomic.
Reader threads see a consistent snapshot; writer threads serialize through
the lock. No partial writes.
**Resolution priority** (per key, in `initialize_paths`):
1. Env var (e.g., `SLOP_GLOBAL_PRESETS`) if set
2. `config.toml [paths]` entry if present
3. Default `<project_root>/<default_filename>`
**Codepath ordering:**
The major codepaths that consume paths are:
- `sloppy.py` (production GUI entry point)
- `src/app_controller.py:AppController.__init__`
- `src/presets.py`, `src/tool_presets.py`, `src/personas.py`, etc.
`initialize_paths()` must run BEFORE any of these. In sloppy.py, it runs
at the top of `__main__`. In tests, it runs at conftest module body (before
any src/ import). In other contexts (e.g., direct library use), the caller
is responsible.
If a path getter is called before `initialize_paths()`, a `RuntimeError`
is raised. This catches the "bad programmer" case where ordering is wrong.
"""
import os
import threading
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Any
@dataclass(frozen=True)
class PathsConfig:
"""Immutable snapshot of resolved paths. Created ONCE per process.
[C: src/paths.py:initialize_paths, src/paths.py:_cfg]"""
config_path: Path
presets: Path
tool_presets: Path
personas: Path
themes: Path
workspace_profiles: Path
credentials: Path
logs_dir: Path
scripts_dir: Path
_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(<custom>) 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."""
root_dir = Path(__file__).resolve().parent.parent
if env_var in os.environ:
return Path(os.environ[env_var])
try:
with open(config_path, "rb") as f:
cfg = tomllib.load(f)
if "paths" in cfg and config_key in cfg["paths"]:
p = Path(cfg["paths"][config_key])
return p if p.is_absolute() else root_dir / p
except (FileNotFoundError, tomllib.TOMLDecodeError):
pass
return default if default.is_absolute() else root_dir / default
def initialize_paths(config_path: Optional[Path] = None) -> PathsConfig:
"""Initialize the global paths singleton. Call this ONCE at startup,
BEFORE any path getter is invoked. Atomic swap under RLock.
If config_path is None, uses the default `<project_root>/config.toml`.
This is the SOLE entry point for setting the path graph at runtime.
Tests re-init to reset.
Raises:
OSError: if the config_path cannot be opened (other than FileNotFoundError
which is treated as "no [paths] overrides, use defaults")
TypeError: if config_path is not a Path
Returns:
The newly installed PathsConfig snapshot.
[C: src/paths.py:_cfg, tests/conftest.py:_setup_test_paths, sloppy.py:main]"""
global _PATHS_CONFIG
if config_path is None:
root_dir = Path(__file__).resolve().parent.parent
config_path = root_dir / "config.toml"
config_path = Path(config_path).resolve()
root_dir = Path(__file__).resolve().parent.parent
cfg = PathsConfig(
config_path = config_path,
presets = _resolve_path("SLOP_GLOBAL_PRESETS", "presets", root_dir / "presets.toml", config_path),
tool_presets = _resolve_path("SLOP_GLOBAL_TOOL_PRESETS", "tool_presets", root_dir / "tool_presets.toml", config_path),
personas = _resolve_path("SLOP_GLOBAL_PERSONAS", "personas", root_dir / "personas.toml", config_path),
themes = _resolve_path("SLOP_GLOBAL_THEMES", "themes", root_dir / "themes", config_path),
workspace_profiles = _resolve_path("SLOP_GLOBAL_WORKSPACE_PROFILES", "workspace_profiles", root_dir / "workspace_profiles.toml", config_path),
credentials = _resolve_path("SLOP_CREDENTIALS", "credentials", root_dir / "credentials.toml", config_path),
logs_dir = _resolve_path("SLOP_LOGS_DIR", "logs_dir", root_dir / "logs" / "sessions", config_path),
scripts_dir = _resolve_path("SLOP_SCRIPTS_DIR", "scripts_dir", root_dir / "scripts" / "generated", config_path),
)
with _PATHS_LOCK:
_PATHS_CONFIG = cfg
return cfg
def _cfg() -> PathsConfig:
"""Internal: get the current singleton, raising if uninitialized."""
if _PATHS_CONFIG is None:
raise RuntimeError(
"src.paths not initialized. Call paths.initialize_paths(<config.toml>) "
"BEFORE any path getter. See src/paths.py docstring for codepath ordering."
)
return _PATHS_CONFIG
# === Trivial getters (single source of truth) ===
def get_config_path() -> Path:
"""Active config.toml path. Frozen at initialize_paths() time.
[C: src/app_controller.py:AppController.load_config,
src/app_controller.py:AppController.init_state,
src/models.py:_load_config_from_disk,
tests/test_test_sandbox.py]"""
return _cfg().config_path
def get_global_presets_path() -> Path:
"""Global presets file. Frozen at initialize_paths() time.
[C: src/presets.py:PresetManager.__init__, src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope]"""
return _cfg().presets
def get_project_presets_path(project_root: Path) -> Path:
"""Project-specific presets file. Computed from project_root (no cache).
[C: src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope, src/presets.py:PresetManager.project_path]"""
return project_root / "project_presets.toml"
def get_global_tool_presets_path() -> Path:
"""Global tool presets file. Frozen at initialize_paths() time.
[C: src/tool_presets.py:ToolPresetManager._get_path, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets]"""
return _cfg().tool_presets
def get_project_tool_presets_path(project_root: Path) -> Path:
"""[C: src/tool_presets.py:ToolPresetManager._get_path, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets]"""
return project_root / "project_tool_presets.toml"
def get_global_personas_path() -> Path:
"""Global personas file. Frozen at initialize_paths() time.
[C: src/personas.py:PersonaManager._get_path, src/personas.py:PersonaManager.get_persona_scope, src/personas.py:PersonaManager.load_all]"""
return _cfg().personas
def get_project_personas_path(project_root: Path) -> Path:
"""[C: src/personas.py:PersonaManager._get_path, src/personas.py:PersonaManager.get_persona_scope, src/personas.py:PersonaManager.load_all]"""
return project_root / "project_personas.toml"
def get_global_themes_path() -> Path:
"""Global themes directory. Frozen at initialize_paths() time.
[C: src/theme_2.py:load_themes_from_disk]"""
return _cfg().themes
def get_project_themes_path(project_root: Path) -> Path:
"""[C: src/theme_2.py:load_themes_from_disk]"""
return project_root / "project_themes.toml"
def get_global_workspace_profiles_path() -> Path:
"""Global workspace profiles file. Frozen at initialize_paths() time.
[C: src/workspace_manager.py:WorkspaceManager._get_path, src/workspace_manager.py:WorkspaceManager.load_all_profiles]"""
return _cfg().workspace_profiles
def get_project_workspace_profiles_path(project_root: Path) -> Path:
"""[C: src/workspace_manager.py:WorkspaceManager._get_path, src/workspace_manager.py:WorkspaceManager.load_all_profiles]"""
return project_root / ".ai" / "workspace_profiles.toml"
def get_credentials_path() -> Path:
"""Global credentials file. Frozen at initialize_paths() time.
[C: src/mcp_client.py:_is_allowed]"""
return _cfg().credentials
def get_logs_dir() -> Path:
"""Logs directory (contains session subdirs). Frozen at initialize_paths() time.
[C: src/session_logger.py:close_session, src/session_logger.py:open_session, tests/test_paths.py:test_config_overrides, tests/test_paths.py:test_default_paths, tests/test_paths.py:test_env_var_overrides, tests/test_paths.py:test_precedence]"""
return _cfg().logs_dir
def get_scripts_dir() -> Path:
"""Generated scripts directory. Frozen at initialize_paths() time.
[C: src/session_logger.py:log_tool_call, src/session_logger.py:open_session, tests/test_paths.py:test_config_overrides, tests/test_paths.py:test_default_paths]"""
return _cfg().scripts_dir
def get_tracks_dir(project_path: Optional[str] = None) -> Path:
"""[C: src/project_manager.py:get_all_tracks, tests/test_paths.py:test_conductor_dir_project_relative]"""
return get_conductor_dir(project_path) / "tracks"
def get_track_state_dir(track_id: str, project_path: Optional[str] = None) -> Path:
"""[C: src/project_manager.py:load_track_state, src/project_manager.py:save_track_state, tests/test_paths.py:test_conductor_dir_project_relative]"""
return get_tracks_dir(project_path) / track_id
def get_archive_dir(project_path: Optional[str] = None) -> Path:
"""[C: tests/test_paths.py:test_conductor_dir_project_relative]"""
return get_conductor_dir(project_path) / "archive"
def _get_project_conductor_dir_from_toml(project_root: Path) -> Path:
"""Look for manual_slop.toml in project_root for [conductor] dir override.
Returns the resolved Path, or project_root if no override configured."""
toml_path = project_root / 'manual_slop.toml'
if not toml_path.exists(): return project_root
try:
with open(toml_path, 'rb') as f:
data = tomllib.load(f)
c_dir = data.get('conductor', {}).get('dir')
if c_dir:
p = Path(c_dir)
if not p.is_absolute(): p = project_root / p
return p.resolve()
except: pass
return project_root
def get_conductor_dir(project_path: Optional[str] = None) -> Path:
"""[C: tests/test_paths.py:test_conductor_dir_project_relative, tests/test_project_paths.py:test_get_conductor_dir_default, tests/test_project_paths.py:test_get_conductor_dir_project_specific_with_toml]"""
if not project_path:
return Path('conductor').resolve()
project_root = Path(project_path).resolve()
toml_path = project_root / 'manual_slop.toml'
if toml_path.exists():
return _get_project_conductor_dir_from_toml(project_root)
return (project_root / "conductor").resolve()
def get_full_path_info() -> dict[str, dict[str, Any]]:
"""Return the resolved paths + their source (env / config / default).
For diagnostic UIs (e.g., the Session Hub's "show resolved paths" panel).
[C: src/gui_2.py:App._render_path_field]"""
cfg = _cfg()
def info(value: Path) -> dict[str, Any]:
return {'path': str(value), 'source': 'frozen_at_init'}
return {
'config_path': info(cfg.config_path),
'presets': info(cfg.presets),
'tool_presets': info(cfg.tool_presets),
'personas': info(cfg.personas),
'themes': info(cfg.themes),
'workspace_profiles': info(cfg.workspace_profiles),
'credentials': info(cfg.credentials),
'logs_dir': info(cfg.logs_dir),
'scripts_dir': info(cfg.scripts_dir),
}
def reset_paths() -> None:
"""Clear the singleton. FOR TESTS ONLY — production code should never
call this. After reset, the next path getter raises RuntimeError until
initialize_paths() is called again.
[C: tests/conftest.py:reset_paths, tests/test_paths.py:reset_paths,
tests/test_app_controller_offloading.py:setup_function,
tests/test_gui_phase3.py:setup]"""
global _PATHS_CONFIG
with _PATHS_LOCK:
_PATHS_CONFIG = None