Files
Aperant/apps/backend/cli/utils.py
T
StillKnotKnown 71a9fc848f fix(windows): prevent pywintypes import errors before dependency validation (ACS-253) (#1057)
* fix(windows): prevent pywintypes import errors before dependency validation

This fixes ACS-253 where Windows users on Python 3.12+ encounter
ModuleNotFoundError for pywintypes when importing mcp.client.stdio.

The issue occurred because graphiti_config was imported at module level
in cli/utils.py, which triggered the import chain:
  graphiti_config → graphiti_core → real_ladybug → pywintypes

This happened BEFORE validate_platform_dependencies() could check for
pywin32 and provide helpful installation instructions.

Changes:
- cli/utils.py: Made graphiti_config import lazy (moved into
  validate_environment() function where it's actually used)
- run.py: Added early validate_platform_dependencies() call before
  importing cli.main
- runners/spec_runner.py: Added early validate_platform_dependencies()
  call before importing cli.utils

Users now get a clear error message with installation instructions
when pywin32 is missing, rather than a cryptic pywintypes import error.

Refs: ACS-253

* test(windows): add comprehensive tests for dependency validator

This adds test coverage for the ACS-253 fix preventing pywintypes import
errors on Windows Python 3.12+.

Test Coverage:
- TestValidatePlatformDependencies (7 tests):
  - Windows + Python 3.12+ with pywin32 missing → exits with error
  - Windows + Python 3.12+ with pywin32 installed → continues
  - Windows + Python < 3.12 → skips validation
  - Linux/macOS → skips validation
  - Windows + Python 3.13+ → validates
  - Windows + Python 3.10 → skips validation

- TestExitWithPywin32Error (3 tests):
  - Error message contains helpful instructions
  - Error message contains venv path
  - Error message contains Python executable

- TestImportOrderPreventsEarlyFailure (3 tests):
  - validate_platform_dependencies doesn't import graphiti
  - cli/utils.py imports graphiti_config lazily
  - Entry points validate before CLI imports

- TestCliUtilsFindSpec (4 tests):
  - Find spec by number prefix
  - Find spec by full name
  - Return None when not found
  - Require spec.md to exist

- TestCliUtilsGetProjectDir (2 tests):
  - Return provided directory
  - Auto-detect from apps/backend directory

- TestCliUtilsSetupEnvironment (2 tests):
  - Returns apps/backend directory
  - Adds to sys.path

Total: 21 tests, all passing

Refs: ACS-253

* refactor(tests): improve test robustness with AST and fix assertions

Improvements made to test_dependency_validator.py:

1. AST-based function detection: Replace fragile string parsing with
   ast.parse() to find the first module-level function, avoiding false
   matches in docstrings or multi-line strings.

2. Fix setup_environment test: Remove unused temp_dir fixture and
   misleading assertion. Split into two focused tests:
   - test_setup_environment_returns_backend_dir: Verifies directory structure
   - test_setup_environment_adds_to_path: Verifies sys.path behavior

3. Remove redundant imports: Consolidate builtins imports to module-level,
   removing duplicate inner imports that shadow the top-level import.

4. Selective mock for pywintypes: Use selective_mock that returns
   MagicMock for pywintypes only, delegating all other imports to the
   original __import__ for more realistic test environment.

5. Strengthen venv path assertion: Require both "/path/to/venv" AND
   "Scripts" to be present in the error message, not just one or the other.

6. Add ast import: Add AST module import for robust parsing.

All 21 tests pass.

Refs: ACS-253

* fix(tests): address CodeQL and CodeRabbit review feedback

- Remove unused Mock import from unittest.mock
- Remove unused ast import from module level (kept local import in function)
- Initialize validate_env_end_lineno before loop to prevent potential
  uninitialized variable use

All 21 tests pass.

Addresses review comments on PR #1057
Refs: ACS-253

* feat(windows): add dependency validation to all entry points for consistency

Add validate_platform_dependencies() to all runner entry points for
consistency with run.py and spec_runner.py. This provides defense-in-depth
and ensures all entry points validate pywin32 on Windows Python 3.12+
before importing from cli.utils.

Changes:
- roadmap_runner.py: Added validate_platform_dependencies() before cli.utils import
- ideation_runner.py: Added validate_platform_dependencies() before cli.utils import
- insights_runner.py: Added validate_platform_dependencies() before cli.utils import
- github/runner.py: Added validate_platform_dependencies() before cli.utils import
- gitlab/runner.py: Added validate_platform_dependencies() before cli.utils import

This completes the consistency improvements suggested by the Auto Claude PR Review.

Refs: ACS-253

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-01-14 21:13:42 +02:00

279 lines
8.8 KiB
Python

"""
CLI Utilities
==============
Shared utility functions for the Auto Claude CLI.
"""
import os
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from core.auth import get_auth_token, get_auth_token_source
from core.dependency_validator import validate_platform_dependencies
def import_dotenv():
"""
Import and return load_dotenv with helpful error message if not installed.
This centralized function ensures consistent error messaging across all
runner scripts when python-dotenv is not available.
Returns:
The load_dotenv function
Raises:
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
"""
try:
from dotenv import load_dotenv as _load_dotenv
return _load_dotenv
except ImportError:
sys.exit(
"Error: Required Python package 'python-dotenv' is not installed.\n"
"\n"
"This usually means you're not using the virtual environment.\n"
"\n"
"To fix this:\n"
"1. From the 'apps/backend/' directory, activate the venv:\n"
" source .venv/bin/activate # Linux/macOS\n"
" .venv\\Scripts\\activate # Windows\n"
"\n"
"2. Or install dependencies directly:\n"
" pip install python-dotenv\n"
" pip install -r requirements.txt\n"
"\n"
f"Current Python: {sys.executable}\n"
)
# Load .env with helpful error if dependencies not installed
load_dotenv = import_dotenv()
# NOTE: graphiti_config is imported lazily in validate_environment() to avoid
# triggering graphiti_core -> real_ladybug -> pywintypes import chain before
# platform dependency validation can run. See ACS-253.
from linear_integration import LinearManager
from linear_updater import is_linear_enabled
from spec.pipeline import get_specs_dir
from ui import (
Icons,
bold,
box,
icon,
muted,
)
# Configuration - uses shorthand that resolves via API Profile if configured
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
def setup_environment() -> Path:
"""
Set up the environment and return the script directory.
Returns:
Path to the auto-claude directory
"""
# Add auto-claude directory to path for imports
script_dir = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(script_dir))
# Load .env file - check both auto-claude/ and dev/auto-claude/ locations
env_file = script_dir / ".env"
dev_env_file = script_dir.parent / "dev" / "auto-claude" / ".env"
if env_file.exists():
load_dotenv(env_file)
elif dev_env_file.exists():
load_dotenv(dev_env_file)
return script_dir
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
"""
Find a spec by number or full name.
Args:
project_dir: Project root directory
spec_identifier: Either "001" or "001-feature-name"
Returns:
Path to spec folder, or None if not found
"""
specs_dir = get_specs_dir(project_dir)
if specs_dir.exists():
# Try exact match first
exact_path = specs_dir / spec_identifier
if exact_path.exists() and (exact_path / "spec.md").exists():
return exact_path
# Try matching by number prefix
for spec_folder in specs_dir.iterdir():
if spec_folder.is_dir() and spec_folder.name.startswith(
spec_identifier + "-"
):
if (spec_folder / "spec.md").exists():
return spec_folder
# Check worktree specs (for merge-preview, merge, review, discard operations)
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
if worktree_base.exists():
# Try exact match in worktree
worktree_spec = (
worktree_base / spec_identifier / ".auto-claude" / "specs" / spec_identifier
)
if worktree_spec.exists() and (worktree_spec / "spec.md").exists():
return worktree_spec
# Try matching by prefix in worktrees
for worktree_dir in worktree_base.iterdir():
if worktree_dir.is_dir() and worktree_dir.name.startswith(
spec_identifier + "-"
):
spec_in_worktree = (
worktree_dir / ".auto-claude" / "specs" / worktree_dir.name
)
if (
spec_in_worktree.exists()
and (spec_in_worktree / "spec.md").exists()
):
return spec_in_worktree
return None
def validate_environment(spec_dir: Path) -> bool:
"""
Validate that the environment is set up correctly.
Returns:
True if valid, False otherwise (with error messages printed)
"""
# Validate platform-specific dependencies first (exits if missing)
validate_platform_dependencies()
valid = True
# Check for OAuth token (API keys are not supported)
if not get_auth_token():
print("Error: No OAuth token found")
print("\nAuto Claude requires Claude Code OAuth authentication.")
print("Direct API keys (ANTHROPIC_API_KEY) are not supported.")
print("\nTo authenticate, run:")
print(" claude setup-token")
valid = False
else:
# Show which auth source is being used
source = get_auth_token_source()
if source:
print(f"Auth: {source}")
# Show custom base URL if set
base_url = os.environ.get("ANTHROPIC_BASE_URL")
if base_url:
print(f"API Endpoint: {base_url}")
# Check for spec.md in spec directory
spec_file = spec_dir / "spec.md"
if not spec_file.exists():
print(f"\nError: spec.md not found in {spec_dir}")
valid = False
# Check Linear integration (optional but show status)
if is_linear_enabled():
print("Linear integration: ENABLED")
# Show Linear project status if initialized
project_dir = (
spec_dir.parent.parent
) # auto-claude/specs/001-name -> project root
linear_manager = LinearManager(spec_dir, project_dir)
if linear_manager.is_initialized:
summary = linear_manager.get_progress_summary()
print(f" Project: {summary.get('project_name', 'Unknown')}")
print(
f" Issues: {summary.get('mapped_subtasks', 0)}/{summary.get('total_subtasks', 0)} mapped"
)
else:
print(" Status: Will be initialized during planner session")
else:
print("Linear integration: DISABLED (set LINEAR_API_KEY to enable)")
# Check Graphiti integration (optional but show status)
# Lazy import to avoid triggering pywintypes import before validation (ACS-253)
from graphiti_config import get_graphiti_status
graphiti_status = get_graphiti_status()
if graphiti_status["available"]:
print("Graphiti memory: ENABLED")
print(f" Database: {graphiti_status['database']}")
if graphiti_status.get("db_path"):
print(f" Path: {graphiti_status['db_path']}")
elif graphiti_status["enabled"]:
print(
f"Graphiti memory: CONFIGURED but unavailable ({graphiti_status['reason']})"
)
else:
print("Graphiti memory: DISABLED (set GRAPHITI_ENABLED=true to enable)")
print()
return valid
def print_banner() -> None:
"""Print the Auto-Build banner."""
content = [
bold(f"{icon(Icons.LIGHTNING)} AUTO-BUILD FRAMEWORK"),
"",
"Autonomous Multi-Session Coding Agent",
muted("Subtask-Based Implementation with Phase Dependencies"),
]
print()
print(box(content, width=70, style="heavy"))
def get_project_dir(provided_dir: Path | None) -> Path:
"""
Determine the project directory.
Args:
provided_dir: User-provided project directory (or None)
Returns:
Resolved project directory path
"""
if provided_dir:
return provided_dir.resolve()
project_dir = Path.cwd()
# Auto-detect if running from within apps/backend directory (the source code)
if project_dir.name == "backend" and (project_dir / "run.py").exists():
# Running from within apps/backend/ source directory, go up 2 levels
project_dir = project_dir.parent.parent
return project_dir
def find_specs_dir(project_dir: Path) -> Path:
"""
Find the specs directory for a project.
Returns the '.auto-claude/specs' directory path.
The directory is guaranteed to exist (get_specs_dir calls init_auto_claude_dir).
Args:
project_dir: Project root directory
Returns:
Path to specs directory (always returns a valid Path)
"""
return get_specs_dir(project_dir)