feat(paths): add aipass-paths root resolution module v1.0.0
Foundation package with zero external dependencies. Implements 3-tier AIPASS_ROOT resolution: env var → .aipass/ marker walk → ~/.aipass/ default. Includes derived path helpers (system_logs_dir, branch_registry_path, branch_path, branch_logs_dir). 32 tests, full coverage. Also positions placeholder modules (cortex, mail, prax) with documented intent for upcoming implementation. Version bumped to 1.0.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
155f57aaf5
commit
c5126b300c
+16
-1
@@ -1 +1,16 @@
|
||||
__version__ = "0.1.0-dev"
|
||||
"""AIPass — Orchestration framework for autonomous AI agent ecosystems.
|
||||
|
||||
Modules:
|
||||
aipass.routing — Command routing & discovery (v1.0.0 — BUILT)
|
||||
aipass.paths — Root resolution, AIPASS_ROOT (PLACEHOLDER)
|
||||
aipass.prax — Visibility & monitoring layer (PLACEHOLDER)
|
||||
aipass.cortex — Branch creation & templates (PLACEHOLDER)
|
||||
aipass.mail — Inter-branch messaging (PLACEHOLDER)
|
||||
|
||||
Standalone packages:
|
||||
seedgo — Code standards framework (v1.0.0 — BUILT)
|
||||
|
||||
Architecture reference: vera/projects/framework/architecture_reference.md
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""AIPass Cortex — Branch Creation & Template Engine.
|
||||
|
||||
Creates new branches (agent workspaces) following a 7-step flow:
|
||||
1. Validate template — verify template directory exists
|
||||
2. Extract metadata — branch name, profile, git repo from path
|
||||
3. Build placeholders — replacement dict: {{BRANCHNAME}}, {{CWD}}, {{DATE}}, etc.
|
||||
4. Smart file rename — rename existing memory files to standard convention
|
||||
5. Copy template — copy entire template, replace placeholders in text files
|
||||
6. File renaming — LOCAL.json -> BRANCHNAME.local.json, etc.
|
||||
7. Registry entry — add to BRANCH_REGISTRY.json + fire trigger event
|
||||
|
||||
Three template types:
|
||||
- branch_template/ — standard dev branch (apps/, modules/, handlers/, tests/)
|
||||
- business_branch_template/ — business branch (playbooks/, strategy/, research/)
|
||||
- team_template/ — think-tank manager (research/, ideas/, decisions/)
|
||||
|
||||
Accessed through drone: `drone @cortex create`, NOT standalone.
|
||||
|
||||
Architecture reference: vera/projects/framework/architecture_reference.md §5
|
||||
|
||||
Status: PLACEHOLDER — not yet implemented.
|
||||
"""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""AIPass Mail — Inter-Branch Messaging.
|
||||
|
||||
Communication layer for AI agent ecosystems. Branches coordinate through
|
||||
email, not direct file access. This is how 10+ agents work simultaneously
|
||||
without stepping on each other.
|
||||
|
||||
Each branch has a mailbox (BRANCHNAME.ai_mail.json). Mail supports:
|
||||
- send/receive between branches
|
||||
- dispatch flags (--dispatch marks for autonomous execution)
|
||||
- inbox/outbox management
|
||||
- reply chains with auto-close and archival
|
||||
|
||||
Integration requirement: branches register in BRANCH_REGISTRY.json
|
||||
to be addressable. Seed enforces this.
|
||||
|
||||
Depends on: aipass-paths.
|
||||
|
||||
Architecture reference: vera/projects/framework/architecture_reference.md §8
|
||||
|
||||
Status: PLACEHOLDER — not yet implemented.
|
||||
"""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
AIPass Paths — Root Resolution Module.
|
||||
|
||||
Foundation package. ZERO external dependencies.
|
||||
|
||||
Resolves the AIPass root directory using a 3-tier priority:
|
||||
1. AIPASS_ROOT environment variable (explicit — deployment/CI)
|
||||
2. .aipass/ marker directory walk (auto-detect — like git finding .git/)
|
||||
3. ~/.aipass/ default fallback (fresh install — XDG pattern)
|
||||
|
||||
All other modules derive paths from get_root():
|
||||
- system_logs_dir() -> get_root() / "system_logs"
|
||||
- branch_registry_path() -> get_root() / "BRANCH_REGISTRY.json"
|
||||
- branch_path(name) -> registry lookup, returns None if not found
|
||||
- branch_logs_dir(dir) -> dir / "logs"
|
||||
|
||||
Exceptions:
|
||||
- PathResolutionError -> raised when root cannot be resolved
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .derived import branch_logs_dir, branch_path, branch_registry_path, system_logs_dir
|
||||
from .exceptions import PathResolutionError
|
||||
from .resolver import get_root
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
# Core
|
||||
"get_root",
|
||||
# Derived paths
|
||||
"system_logs_dir",
|
||||
"branch_registry_path",
|
||||
"branch_path",
|
||||
"branch_logs_dir",
|
||||
# Exceptions
|
||||
"PathResolutionError",
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Derived path functions for AIPass.
|
||||
|
||||
All functions derive their paths from get_root() and return Path objects.
|
||||
No side effects beyond reading the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .resolver import get_root
|
||||
|
||||
|
||||
def system_logs_dir() -> Path:
|
||||
"""
|
||||
Return the system logs directory.
|
||||
|
||||
Returns:
|
||||
get_root() / "system_logs"
|
||||
"""
|
||||
return get_root() / "system_logs"
|
||||
|
||||
|
||||
def branch_registry_path() -> Path:
|
||||
"""
|
||||
Return the path to BRANCH_REGISTRY.json.
|
||||
|
||||
Returns:
|
||||
get_root() / "BRANCH_REGISTRY.json"
|
||||
"""
|
||||
return get_root() / "BRANCH_REGISTRY.json"
|
||||
|
||||
|
||||
def branch_path(name: str) -> Path | None:
|
||||
"""
|
||||
Look up a branch by name in BRANCH_REGISTRY.json and return its path.
|
||||
|
||||
Args:
|
||||
name: Branch name (without @ prefix).
|
||||
|
||||
Returns:
|
||||
Absolute Path to the branch directory, or None if the branch is not
|
||||
found or the registry file does not exist.
|
||||
"""
|
||||
registry_file = branch_registry_path()
|
||||
|
||||
if not registry_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(registry_file, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
branches = data.get("branches", {})
|
||||
entry = branches.get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
raw_path = entry.get("path")
|
||||
if raw_path is None:
|
||||
return None
|
||||
|
||||
return Path(raw_path)
|
||||
|
||||
|
||||
def branch_logs_dir(branch_dir: Path) -> Path:
|
||||
"""
|
||||
Return the logs directory for a given branch directory.
|
||||
|
||||
Args:
|
||||
branch_dir: Absolute path to a branch directory.
|
||||
|
||||
Returns:
|
||||
branch_dir / "logs"
|
||||
"""
|
||||
return branch_dir / "logs"
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Paths module custom exceptions.
|
||||
|
||||
Defines the exception hierarchy for path resolution errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class PathResolutionError(Exception):
|
||||
"""Raised when the AIPass root directory cannot be resolved."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Root resolution logic for AIPass paths.
|
||||
|
||||
Resolves the AIPass root directory using a 3-tier priority:
|
||||
1. AIPASS_ROOT environment variable (if set and valid directory)
|
||||
2. .aipass/ marker directory walk (like git finding .git/)
|
||||
3. ~/.aipass/ default fallback (created if it doesn't exist)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .exceptions import PathResolutionError
|
||||
|
||||
|
||||
def _resolve_from_env() -> Path | None:
|
||||
"""
|
||||
Attempt to resolve root from AIPASS_ROOT environment variable.
|
||||
|
||||
Returns:
|
||||
Path if env var is set and points to a valid directory, None otherwise.
|
||||
|
||||
Raises:
|
||||
PathResolutionError: If env var is set but points to a non-existent directory.
|
||||
"""
|
||||
env_val = os.environ.get("AIPASS_ROOT")
|
||||
if not env_val: # None or empty string → not set
|
||||
return None
|
||||
|
||||
candidate = Path(env_val)
|
||||
if not candidate.is_dir():
|
||||
raise PathResolutionError(
|
||||
f"AIPASS_ROOT environment variable points to a non-existent directory: {env_val!r}"
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def _resolve_from_marker(start: Path | None = None) -> Path | None:
|
||||
"""
|
||||
Walk up the directory tree from start looking for a directory containing .aipass/.
|
||||
|
||||
Args:
|
||||
start: Directory to start from. Defaults to Path.cwd().
|
||||
|
||||
Returns:
|
||||
The directory that contains a .aipass/ subdirectory, or None if not found.
|
||||
"""
|
||||
current = (start or Path.cwd()).resolve()
|
||||
|
||||
for candidate in [current, *current.parents]:
|
||||
if (candidate / ".aipass").is_dir():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_default() -> Path:
|
||||
"""
|
||||
Return the default fallback root (~/.aipass/), creating it if needed.
|
||||
|
||||
Returns:
|
||||
Path to ~/.aipass/, guaranteed to exist.
|
||||
"""
|
||||
default = Path.home() / ".aipass"
|
||||
default.mkdir(parents=True, exist_ok=True)
|
||||
return default
|
||||
|
||||
|
||||
def get_root(start: Path | None = None) -> Path:
|
||||
"""
|
||||
Resolve the AIPass root directory using 3-tier priority.
|
||||
|
||||
Priority order:
|
||||
1. AIPASS_ROOT environment variable (if set and valid)
|
||||
2. .aipass/ marker directory walk (starting from CWD or start)
|
||||
3. ~/.aipass/ default fallback (created if missing)
|
||||
|
||||
Args:
|
||||
start: Optional starting directory for marker walk. Defaults to Path.cwd().
|
||||
|
||||
Returns:
|
||||
Resolved AIPass root directory as an absolute Path.
|
||||
|
||||
Raises:
|
||||
PathResolutionError: If AIPASS_ROOT env var is set but points to a non-existent directory.
|
||||
"""
|
||||
# Tier 1: environment variable
|
||||
env_root = _resolve_from_env()
|
||||
if env_root is not None:
|
||||
return env_root
|
||||
|
||||
# Tier 2: marker walk
|
||||
marker_root = _resolve_from_marker(start)
|
||||
if marker_root is not None:
|
||||
return marker_root
|
||||
|
||||
# Tier 3: default fallback
|
||||
return _resolve_default()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""AIPass Prax — Visibility & Monitoring Layer.
|
||||
|
||||
Project-scoped logging, monitoring, and observability for AI agent ecosystems.
|
||||
Depends on: aipass-paths, watchdog.
|
||||
|
||||
Internal Prax has 10 subsystems. The public version ships with 4 non-negotiable
|
||||
trust changes:
|
||||
1. No global logging.getLogger() override — users call prax.logger() explicitly
|
||||
2. Project-scoped only — never operates outside the project directory
|
||||
3. All external communication opt-in — zero external by default
|
||||
4. Consent layer — prax init / prax status / prax stop (through drone)
|
||||
|
||||
Three modes:
|
||||
- Public: Safe defaults. Minimal, transparent, project-scoped.
|
||||
- Full: Opt-in power features (file watcher, log tailing, Telegram relay).
|
||||
- AIPass: Internal unchanged (not shipped publicly).
|
||||
|
||||
Other modules don't configure Prax — they import correctly and use
|
||||
warning(), info(), error() methods. Prax manages himself internally.
|
||||
|
||||
Architecture reference: vera/projects/framework/architecture_reference.md §6
|
||||
|
||||
Status: PLACEHOLDER — not yet implemented.
|
||||
"""
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Tests for aipass.paths — root resolution and derived path functions.
|
||||
|
||||
Covers:
|
||||
- get_root() env var resolution
|
||||
- get_root() marker walk resolution
|
||||
- get_root() default fallback
|
||||
- Priority order (env var beats marker walk, marker walk beats default)
|
||||
- Invalid env var raises PathResolutionError
|
||||
- All derived path functions
|
||||
- branch_path() with a mock registry
|
||||
- branch_path() returns None for missing branch / missing file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.paths import (
|
||||
PathResolutionError,
|
||||
branch_logs_dir,
|
||||
branch_path,
|
||||
branch_registry_path,
|
||||
get_root,
|
||||
system_logs_dir,
|
||||
)
|
||||
from aipass.paths.resolver import _resolve_from_marker, get_root as _get_root_direct
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_registry(path: Path, branches: dict) -> None:
|
||||
"""Write a minimal BRANCH_REGISTRY.json to *path*."""
|
||||
path.write_text(
|
||||
json.dumps({"version": "1.0", "branches": branches}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_root() — environment variable resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnvVarResolution:
|
||||
"""Tier 1: AIPASS_ROOT environment variable."""
|
||||
|
||||
def test_env_var_valid_directory_is_returned(self, tmp_path, monkeypatch):
|
||||
"""When AIPASS_ROOT points to an existing directory it is returned."""
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
assert get_root() == tmp_path
|
||||
|
||||
def test_env_var_takes_priority_over_marker(self, tmp_path, monkeypatch):
|
||||
"""Env var wins even when a .aipass/ marker exists in CWD."""
|
||||
env_root = tmp_path / "env_root"
|
||||
env_root.mkdir()
|
||||
|
||||
marker_root = tmp_path / "marker_root"
|
||||
marker_root.mkdir()
|
||||
(marker_root / ".aipass").mkdir()
|
||||
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(env_root))
|
||||
monkeypatch.chdir(marker_root)
|
||||
|
||||
assert get_root() == env_root
|
||||
|
||||
def test_env_var_nonexistent_raises_path_resolution_error(self, monkeypatch):
|
||||
"""AIPASS_ROOT pointing to a non-existent path raises PathResolutionError."""
|
||||
monkeypatch.setenv("AIPASS_ROOT", "/does/not/exist/at/all")
|
||||
with pytest.raises(PathResolutionError):
|
||||
get_root()
|
||||
|
||||
def test_env_var_empty_string_falls_through(self, tmp_path, monkeypatch):
|
||||
"""An empty AIPASS_ROOT string is treated as unset (falls through to next tier)."""
|
||||
# Make a marker so tier 2 picks it up (avoids writing to real home)
|
||||
marker_root = tmp_path / "marker"
|
||||
marker_root.mkdir()
|
||||
(marker_root / ".aipass").mkdir()
|
||||
|
||||
monkeypatch.setenv("AIPASS_ROOT", "")
|
||||
monkeypatch.chdir(marker_root)
|
||||
|
||||
# Empty string → env var absent → should find marker
|
||||
result = get_root()
|
||||
assert result == marker_root
|
||||
|
||||
def test_env_var_unset_falls_through(self, tmp_path, monkeypatch):
|
||||
"""When AIPASS_ROOT is not set at all, env tier returns None and we fall through."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
marker_root = tmp_path / "marker"
|
||||
marker_root.mkdir()
|
||||
(marker_root / ".aipass").mkdir()
|
||||
monkeypatch.chdir(marker_root)
|
||||
|
||||
result = get_root()
|
||||
assert result == marker_root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_root() — marker walk resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMarkerWalkResolution:
|
||||
"""Tier 2: .aipass/ marker directory walk."""
|
||||
|
||||
def test_marker_in_cwd_is_found(self, tmp_path, monkeypatch):
|
||||
"""A .aipass/ directory in CWD is resolved to CWD."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
(tmp_path / ".aipass").mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
assert get_root() == tmp_path
|
||||
|
||||
def test_marker_in_parent_is_found(self, tmp_path, monkeypatch):
|
||||
"""A .aipass/ in a parent directory is discovered by walking up."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
root = tmp_path / "project"
|
||||
root.mkdir()
|
||||
(root / ".aipass").mkdir()
|
||||
|
||||
deep = root / "a" / "b" / "c"
|
||||
deep.mkdir(parents=True)
|
||||
monkeypatch.chdir(deep)
|
||||
|
||||
assert get_root() == root
|
||||
|
||||
def test_marker_in_grandparent_is_found(self, tmp_path, monkeypatch):
|
||||
"""Walk stops at the nearest ancestor that contains .aipass/."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
grandparent = tmp_path / "gp"
|
||||
grandparent.mkdir()
|
||||
(grandparent / ".aipass").mkdir()
|
||||
|
||||
child = grandparent / "child" / "grandchild"
|
||||
child.mkdir(parents=True)
|
||||
monkeypatch.chdir(child)
|
||||
|
||||
assert get_root() == grandparent
|
||||
|
||||
def test_marker_file_not_directory_is_ignored(self, tmp_path, monkeypatch):
|
||||
"""If .aipass exists but is a file (not dir), it should not match."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
# .aipass as a file, not a directory
|
||||
(tmp_path / ".aipass").write_text("not a dir")
|
||||
|
||||
# Provide a parent with the real marker so the walk finds something
|
||||
parent = tmp_path.parent
|
||||
# We cannot rely on parent having .aipass, so use custom start to
|
||||
# test the helper directly
|
||||
result = _resolve_from_marker(start=tmp_path)
|
||||
# The file should not match; result will be None or a higher ancestor
|
||||
# that may or may not have .aipass — what matters is tmp_path itself
|
||||
# is NOT returned because .aipass there is a file.
|
||||
if result is not None:
|
||||
assert result != tmp_path
|
||||
|
||||
def test_explicit_start_directory(self, tmp_path, monkeypatch):
|
||||
"""_resolve_from_marker accepts an explicit start directory."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
(root / ".aipass").mkdir()
|
||||
|
||||
start = root / "sub"
|
||||
start.mkdir()
|
||||
|
||||
result = _resolve_from_marker(start=start)
|
||||
assert result == root
|
||||
|
||||
def test_no_marker_returns_none(self, tmp_path, monkeypatch):
|
||||
"""_resolve_from_marker returns None when no .aipass/ is found."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
# tmp_path has no .aipass and neither do its parents (almost certainly)
|
||||
result = _resolve_from_marker(start=tmp_path)
|
||||
# We can only assert it does not match tmp_path itself
|
||||
if result is not None:
|
||||
assert (result / ".aipass").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_root() — default fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultFallback:
|
||||
"""Tier 3: ~/.aipass/ default fallback."""
|
||||
|
||||
def test_default_fallback_returns_home_aipass(self, tmp_path, monkeypatch):
|
||||
"""When env var absent and no marker found, default is ~/.aipass/."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
# Use a temp home with no .aipass marker anywhere in the tree
|
||||
fake_home = tmp_path / "home" / "user"
|
||||
fake_home.mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
# Patch Path.home() by redirecting the env; also chdir to a location
|
||||
# with no .aipass marker
|
||||
isolated = tmp_path / "isolated"
|
||||
isolated.mkdir()
|
||||
monkeypatch.chdir(isolated)
|
||||
|
||||
result = get_root()
|
||||
expected = Path.home() / ".aipass"
|
||||
assert result == expected
|
||||
|
||||
def test_default_fallback_creates_directory(self, tmp_path, monkeypatch):
|
||||
"""The default fallback creates ~/.aipass/ if it does not exist."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
fake_home = tmp_path / "fresh_home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
|
||||
isolated = tmp_path / "isolated2"
|
||||
isolated.mkdir()
|
||||
monkeypatch.chdir(isolated)
|
||||
|
||||
result = get_root()
|
||||
assert result.is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Priority order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPriorityOrder:
|
||||
"""Verify env var > marker walk > default."""
|
||||
|
||||
def test_env_var_beats_marker_walk(self, tmp_path, monkeypatch):
|
||||
"""Env var takes priority over marker walk."""
|
||||
env_root = tmp_path / "env"
|
||||
env_root.mkdir()
|
||||
|
||||
cwd_with_marker = tmp_path / "cwd"
|
||||
cwd_with_marker.mkdir()
|
||||
(cwd_with_marker / ".aipass").mkdir()
|
||||
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(env_root))
|
||||
monkeypatch.chdir(cwd_with_marker)
|
||||
|
||||
assert get_root() == env_root
|
||||
|
||||
def test_marker_walk_beats_default(self, tmp_path, monkeypatch):
|
||||
"""Marker walk takes priority over default fallback."""
|
||||
monkeypatch.delenv("AIPASS_ROOT", raising=False)
|
||||
|
||||
marker_root = tmp_path / "project"
|
||||
marker_root.mkdir()
|
||||
(marker_root / ".aipass").mkdir()
|
||||
monkeypatch.chdir(marker_root)
|
||||
|
||||
result = get_root()
|
||||
assert result == marker_root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived path functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSystemLogsDir:
|
||||
def test_returns_system_logs_under_root(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
assert system_logs_dir() == tmp_path / "system_logs"
|
||||
|
||||
def test_returns_path_object(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
assert isinstance(system_logs_dir(), Path)
|
||||
|
||||
|
||||
class TestBranchRegistryPath:
|
||||
def test_returns_registry_under_root(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
assert branch_registry_path() == tmp_path / "BRANCH_REGISTRY.json"
|
||||
|
||||
def test_returns_path_object(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
assert isinstance(branch_registry_path(), Path)
|
||||
|
||||
|
||||
class TestBranchPath:
|
||||
"""branch_path(name) reads BRANCH_REGISTRY.json and returns a Path or None."""
|
||||
|
||||
def test_known_branch_returns_path(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
_write_registry(
|
||||
tmp_path / "BRANCH_REGISTRY.json",
|
||||
{"vera": {"name": "vera", "path": "/home/aipass/vera", "type": "agent", "status": "active"}},
|
||||
)
|
||||
result = branch_path("vera")
|
||||
assert result == Path("/home/aipass/vera")
|
||||
|
||||
def test_unknown_branch_returns_none(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
_write_registry(tmp_path / "BRANCH_REGISTRY.json", {})
|
||||
assert branch_path("nonexistent") is None
|
||||
|
||||
def test_missing_registry_returns_none(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
# No registry file written
|
||||
assert branch_path("vera") is None
|
||||
|
||||
def test_corrupt_registry_returns_none(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
(tmp_path / "BRANCH_REGISTRY.json").write_text("not valid json {{{{", encoding="utf-8")
|
||||
assert branch_path("vera") is None
|
||||
|
||||
def test_branch_entry_missing_path_key_returns_none(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
_write_registry(
|
||||
tmp_path / "BRANCH_REGISTRY.json",
|
||||
{"vera": {"name": "vera", "type": "agent"}}, # no "path" key
|
||||
)
|
||||
assert branch_path("vera") is None
|
||||
|
||||
def test_returns_path_object(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
branch_dir = tmp_path / "branches" / "flow"
|
||||
branch_dir.mkdir(parents=True)
|
||||
_write_registry(
|
||||
tmp_path / "BRANCH_REGISTRY.json",
|
||||
{"flow": {"name": "flow", "path": str(branch_dir), "type": "agent", "status": "active"}},
|
||||
)
|
||||
result = branch_path("flow")
|
||||
assert isinstance(result, Path)
|
||||
assert result == branch_dir
|
||||
|
||||
def test_multiple_branches_correct_one_returned(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", str(tmp_path))
|
||||
_write_registry(
|
||||
tmp_path / "BRANCH_REGISTRY.json",
|
||||
{
|
||||
"alpha": {"name": "alpha", "path": "/agents/alpha", "type": "agent", "status": "active"},
|
||||
"beta": {"name": "beta", "path": "/agents/beta", "type": "agent", "status": "active"},
|
||||
},
|
||||
)
|
||||
assert branch_path("alpha") == Path("/agents/alpha")
|
||||
assert branch_path("beta") == Path("/agents/beta")
|
||||
assert branch_path("gamma") is None
|
||||
|
||||
|
||||
class TestBranchLogsDir:
|
||||
def test_returns_logs_under_branch_dir(self, tmp_path):
|
||||
result = branch_logs_dir(tmp_path)
|
||||
assert result == tmp_path / "logs"
|
||||
|
||||
def test_returns_path_object(self, tmp_path):
|
||||
assert isinstance(branch_logs_dir(tmp_path), Path)
|
||||
|
||||
def test_arbitrary_path(self):
|
||||
p = Path("/some/branch/dir")
|
||||
assert branch_logs_dir(p) == Path("/some/branch/dir/logs")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PathResolutionError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPathResolutionError:
|
||||
def test_is_exception_subclass(self):
|
||||
assert issubclass(PathResolutionError, Exception)
|
||||
|
||||
def test_carries_message(self):
|
||||
err = PathResolutionError("bad path")
|
||||
assert "bad path" in str(err)
|
||||
|
||||
def test_raised_on_invalid_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_ROOT", "/totally/nonexistent/12345")
|
||||
with pytest.raises(PathResolutionError, match="AIPASS_ROOT"):
|
||||
get_root()
|
||||
@@ -2,4 +2,4 @@ from aipass import __version__
|
||||
|
||||
|
||||
def test_version():
|
||||
assert __version__ == "0.1.0-dev"
|
||||
assert __version__ == "1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user