From 98a663dee46653fcdd95f8944415c44abb87b9d2 Mon Sep 17 00:00:00 2001 From: Martin Gran Date: Sun, 30 Nov 2025 21:45:56 +0000 Subject: [PATCH] centralize error --- docs/wip/refactoring-phase1-complete.md | 214 -------- dope/__init__.py | 5 +- dope/cli/config.py | 459 ------------------ dope/cli/config.py.bak | 212 -------- dope/cli/config/interactive.py | 2 +- dope/cli/scope.py | 5 +- dope/cli/suggest.py | 2 +- dope/consumers/doc_consumer.py | 5 +- dope/consumers/git_consumer.py | 7 +- dope/core/context.py | 21 +- dope/core/project.py | 33 +- dope/core/settings.py | 124 +---- dope/core/utils.py | 8 - dope/exceptions.py | 167 +++++++ dope/llms/model_factory.py | 7 +- dope/models/__init__.py | 111 +++++ dope/models/constants.py | 2 +- dope/models/domain/__init__.py | 41 ++ dope/models/domain/doc.py | 12 +- dope/models/domain/documentation.py | 87 ++++ dope/models/domain/scope.py | 77 +++ dope/models/domain/scope_template.py | 68 +-- dope/models/enums.py | 71 +++ dope/models/internal.py | 5 +- dope/models/settings.py | 104 ++++ dope/models/shared.py | 17 + dope/services/changer/changer_agents.py | 5 +- dope/services/changer/changer_service.py | 2 +- dope/services/describer/describer_agents.py | 9 +- .../scoper/scope_template/__init__.py | 5 +- dope/services/scoper/scope_template/large.py | 7 +- .../services/scoper/scope_template/massive.py | 7 +- dope/services/scoper/scope_template/medium.py | 7 +- dope/services/scoper/scope_template/small.py | 7 +- .../services/scoper/scope_template/trivial.py | 7 +- dope/services/scoper/scoper_agents.py | 10 +- dope/services/scoper/scoper_service.py | 2 +- dope/services/suggester/suggester_agents.py | 5 +- dope/services/suggester/suggester_service.py | 2 +- tests/unit/exceptions_test.py | 201 ++++++++ 40 files changed, 967 insertions(+), 1175 deletions(-) delete mode 100644 docs/wip/refactoring-phase1-complete.md delete mode 100644 dope/cli/config.py delete mode 100644 dope/cli/config.py.bak create mode 100644 dope/exceptions.py create mode 100644 dope/models/domain/documentation.py create mode 100644 dope/models/domain/scope.py create mode 100644 dope/models/settings.py create mode 100644 dope/models/shared.py create mode 100644 tests/unit/exceptions_test.py diff --git a/docs/wip/refactoring-phase1-complete.md b/docs/wip/refactoring-phase1-complete.md deleted file mode 100644 index 33130d7..0000000 --- a/docs/wip/refactoring-phase1-complete.md +++ /dev/null @@ -1,214 +0,0 @@ -# Refactoring Complete: High-Priority Simplifications - -**Date**: November 30, 2025 -**Branch**: fix-settings -**Status**: ✅ Complete - -## Summary - -Successfully implemented the four high-priority refactorings from the deep analysis: - -1. ✅ Created shared CLI utilities module -2. ✅ Removed empty model files -3. ✅ Consolidated project size enums -4. ✅ Extracted tree rendering from BaseConsumer - -## Changes Made - -### 1. Shared CLI Utilities (`dope/cli/common.py`) - NEW FILE - -Created a new module with three utility functions: - -- **`get_branch_option()`** - Returns standardized `--branch/-b` option annotation -- **`resolve_branch()`** - Resolves branch parameter to actual branch name -- **`get_state_path()`** - Constructs full path to state files - -**Impact**: Eliminated 5 duplications of branch handling logic across CLI commands. - -**Files Updated**: - -- `dope/cli/scan.py` - Updated `code` command -- `dope/cli/suggest.py` - Updated `suggest` command -- `dope/cli/apply.py` - Updated `apply` command -- `dope/cli/scope.py` - Updated `create` and `apply` commands, plus `_init_scope_service()` -- `dope/cli/status.py` - Updated state file path construction - -**Lines Saved**: ~35 lines of duplicated code removed - -### 2. Tree Rendering Utilities (`dope/core/tree.py`) - NEW FILE - -Extracted tree structure utilities from `BaseConsumer`: - -- **`build_tree()`** - Build tree structure from file paths -- **`render_tree()`** - Render tree structure as string -- **`get_structure()`** - Combined operation for convenience - -**Impact**: BaseConsumer is now a pure abstract base class with no concrete implementation details. - -**Files Updated**: - -- `dope/consumers/base.py` - Removed ~40 lines of tree rendering code, now imports from `dope.core.tree` - -**Lines Saved**: ~40 lines moved to focused utility module - -### 3. Empty Model Files Removed - -Deleted two empty placeholder files: - -- ❌ `dope/models/domain/change.py` - DELETED -- ❌ `dope/models/domain/suggestion.py` - DELETED - -**Impact**: Cleaner codebase, no confusion about where to add code. - -### 4. Consolidated Project Size Enums - -Removed duplicate `ProjectSize` enum from `dope/models/enums.py`: - -**Before**: - -```python -class ProjectSize(str, Enum): # Not used anywhere - TRIVIAL = "trivial" - SMALL = "small" - MEDIUM = "medium" - LARGE = "large" - XL = "xl" - UNSURE = "unsure" - -class ProjectTier(str, Enum): # Actually used - trivial = "trivial" - small = "small" - medium = "medium" - large = "large" - massive = "massive" -``` - -**After**: - -```python -# Only ProjectTier remains (in scope_template.py) -class ProjectTier(str, Enum): - trivial = "trivial" - small = "small" - medium = "medium" - large = "large" - massive = "massive" -``` - -**Impact**: Single source of truth for project tiers, no confusion between Size and Tier. - -**Files Updated**: - -- `dope/models/enums.py` - Removed `ProjectSize` enum (~10 lines) - -## Testing & Verification - -### Unit Tests - -```bash -✅ 11 tests passed - - All existing tests continue to pass - - No regressions introduced -``` - -### CLI Verification - -```bash -✅ dope --help - Works correctly -✅ dope scan code --help - Shows consistent --branch option -✅ dope suggest --help - Shows consistent --branch option -✅ dope apply --help - Shows consistent --branch option -✅ dope scope create --help - Shows consistent --branch option -``` - -### Code Quality - -```bash -✅ ruff check - All checks passed -✅ ruff format - All files properly formatted -``` - -## Code Metrics - -| Metric | Before | After | Improvement | -| --------------------------------------------- | ------ | ----- | ----------- | -| CLI command files with duplicate branch logic | 5 | 0 | 100% | -| Lines of duplicated code | ~85 | 0 | 100% | -| Empty model files | 2 | 0 | 100% | -| Project size enums | 2 | 1 | 50% | -| Total lines removed | - | ~85 | - | -| New focused utility files | 0 | 2 | - | - -## Benefits Achieved - -### Developer Experience - -- **Consistency**: All CLI commands now use identical patterns -- **Discoverability**: Utilities are in obvious locations (`cli/common.py`, `core/tree.py`) -- **Maintainability**: Changes to patterns only need one edit - -### Code Quality - -- **DRY Principle**: Eliminated all identified duplication -- **Separation of Concerns**: Tree rendering separated from consumer logic -- **Clean Architecture**: Abstract base classes are now truly abstract - -### Future-Proofing - -- **Easy to Extend**: Adding new CLI commands is now trivial -- **Easy to Test**: Utilities can be tested independently -- **Easy to Change**: Behavior changes in one place - -## Backward Compatibility - -✅ **Fully backward compatible** - All existing functionality preserved: - -- CLI commands work identically -- Service interfaces unchanged -- Model imports still work (ProjectTier is in original location) - -## Files Created - -1. `/workspace/dope/cli/common.py` (71 lines) -2. `/workspace/dope/core/tree.py` (83 lines) - -## Files Modified - -1. `/workspace/dope/consumers/base.py` (-40 lines) -2. `/workspace/dope/models/enums.py` (-10 lines) -3. `/workspace/dope/cli/scan.py` (-7 lines) -4. `/workspace/dope/cli/suggest.py` (-6 lines) -5. `/workspace/dope/cli/apply.py` (-6 lines) -6. `/workspace/dope/cli/scope.py` (-8 lines) -7. `/workspace/dope/cli/status.py` (-4 lines) - -## Files Deleted - -1. `/workspace/dope/models/domain/change.py` -2. `/workspace/dope/models/domain/suggestion.py` - -## Next Steps - -These refactorings prepare the codebase for the medium-priority improvements: - -1. **Next**: Split `config.py` into focused submodules (380 lines → 4 files) -2. **Next**: Create abstract `StateManager` class for unified state handling -3. **Next**: Split `utils.py` into focused modules (200 lines → 3 files) -4. **Next**: Replace `UsageContext` singleton with injected tracker - -## Validation Checklist - -- [x] All tests pass -- [x] CLI commands work correctly -- [x] Linting passes (ruff) -- [x] Code is formatted (ruff format) -- [x] Documentation updated -- [x] No breaking changes -- [x] Backward compatible -- [x] Performance unchanged - ---- - -**Total Time**: ~2 hours -**Risk Level**: Low (no breaking changes) -**Team Impact**: Immediate improvement to developer experience diff --git a/dope/__init__.py b/dope/__init__.py index 95f1ff1..f4ba87b 100644 --- a/dope/__init__.py +++ b/dope/__init__.py @@ -1,8 +1,5 @@ +from dope.core.config_locator import locate_global_config, locate_local_config_file from dope.core.settings import Settings, get_settings -from dope.core.utils import ( - locate_global_config, - locate_local_config_file, -) from dope.models.constants import CONFIG_FILENAME # Locate config file for reference (doesn't load settings yet) diff --git a/dope/cli/config.py b/dope/cli/config.py deleted file mode 100644 index 50e5db4..0000000 --- a/dope/cli/config.py +++ /dev/null @@ -1,459 +0,0 @@ -"""Configuration management commands.""" - -import functools -import sys -from pathlib import Path -from typing import Annotated - -import questionary -import typer -from git import InvalidGitRepositoryError, Repo -from pydantic import HttpUrl, SecretStr, ValidationError -from questionary import Choice -from rich import print as rprint -from rich.console import Console -from rich.table import Table - -from dope.core.settings import AgentSettings, CodeRepoSettings, DocSettings, Settings -from dope.core.utils import ( - generate_local_cache, - generate_local_config_file, - locate_local_config_file, - require_config, -) -from dope.models.constants import ( - CONFIG_FILENAME, - DEFAULT_DOC_SUFFIX, - DOC_SUFFIX, - EXCLUDE_DIRS, -) -from dope.models.enums import Provider -from dope.models.internal import FileSuffix - -app = typer.Typer(help="Manage application configuration") -console = Console() - - -def handle_questionary_abort(func): - """Decorator to handle questionary abort gracefully.""" - - @functools.wraps(func) - def wrapper(*args, **kwargs): - result = None - try: - result = func(*args, **kwargs) - except KeyboardInterrupt: - pass - except Exception as err: - raise err - finally: - if result is None: - sys.exit(0) - return result - - return wrapper - - -@handle_questionary_abort -def _set_doc_root() -> Path: - return Path( - questionary.path( - "Set path to doc root folder", only_directories=True, default=str(Path(".").resolve()) - ).ask() - ) - - -@handle_questionary_abort -def _set_doc_types() -> set[FileSuffix]: - choices = [ - Choice(title=suffix, value=suffix, checked=suffix in DEFAULT_DOC_SUFFIX) - for suffix in sorted(DOC_SUFFIX) - ] - - return set(questionary.checkbox("Select doc file types.", choices=choices).ask()) # pylint: disable=no-value-for-parameter - - -@handle_questionary_abort -def _set_provider() -> Provider: - return questionary.select( - message="Which LLM provider?", - choices=[Choice(title=provider.value, value=provider) for provider in Provider], - ).ask() - - -@handle_questionary_abort -def _set_exclude_folders(doc_root: Path) -> set[str]: - doc_root = Path(doc_root) - - def _check_folder(file: Path): - if file.name.startswith("."): - return True - return file.name in EXCLUDE_DIRS - - choices = [ - Choice(title=file.name, value=file.name, checked=_check_folder(file)) - for file in doc_root.iterdir() - if file.is_dir() - ] - if choices: - result = questionary.checkbox( - message="Select folders to exclude from doc scan", choices=choices - ) - return set(result.ask()) # pylint: disable=no-value-for-parameter - return set() - - -@handle_questionary_abort -def _set_default_branch(repo_path: str): - repo = Repo(str(repo_path), search_parent_directories=True) - branches = [str(branch) for branch in repo.branches] - return questionary.select("Select default branch", choices=branches, default="main").ask() - - -@handle_questionary_abort -def _set_code_repo_root(): - suggested_root = Repo(".", search_parent_directories=True) - return Path( - questionary.path( - "Set path to code root folder", - only_directories=True, - default=str(Path(suggested_root.working_dir).resolve()), - ).ask() - ) - - -def _validate_url(text): - try: - # Will raise if not a valid URL - HttpUrl(url=text) - return True - except ValidationError as e: - # Extract the first error message - msg = e.errors()[0]["msg"] - return f"🚫 {msg}" - - -@handle_questionary_abort -def _set_deployment_endpoint(): - return questionary.text(message="Azure deployment URL:", validate=_validate_url).ask() - - -@handle_questionary_abort -def _set_token(): - return SecretStr(questionary.password("Input API token").ask()) - - -@handle_questionary_abort -def _set_state_directory(): - cache_dir = Path(".") / Path(".dope") - return Path( - questionary.path("Set state directory path", default=str(cache_dir.resolve())).ask() - ) - - -@handle_questionary_abort -def _add_cache_dir_to_git(): - return questionary.confirm("Add cache dir to git?").ask() - - -def _verify_provider(provider: Provider, base_url: str | None) -> bool: - if provider == Provider.AZURE: - if not base_url: - raise typer.BadParameter( - f"Missing base URL when provider is set to '{Provider.AZURE.value}'" - ) - try: - HttpUrl(base_url) # Just validate, don't reassign - except ValidationError as err: - raise typer.BadParameter(f"{base_url} not valid URL") from err - return True - - -def _create_default_settings( - provider: Provider, base_url: str | None, token: SecretStr -) -> Settings: - """Create settings with smart defaults.""" - # Try to detect git repo - try: - repo = Repo(".", search_parent_directories=True) - repo_root = Path(repo.working_tree_dir) if repo.working_tree_dir else Path.cwd() - default_branch = str(repo.active_branch) if repo.active_branch else "main" - except (InvalidGitRepositoryError, TypeError): - repo_root = Path.cwd() - default_branch = "main" - - new_settings = Settings( - state_directory=Path(".dope"), - docs=DocSettings( - docs_root=repo_root, - doc_filetypes=DEFAULT_DOC_SUFFIX, # Just .md and .mdx - exclude_dirs=EXCLUDE_DIRS, - ), - git=CodeRepoSettings( - code_repo_root=repo_root, - default_branch=default_branch, - ), - agent=AgentSettings( - provider=provider, - base_url=HttpUrl(base_url) if base_url else None, - token=token, - ), - ) - - return new_settings - - -def _interactive_setup(): - """Full interactive setup with all options.""" - new_settings = Settings() - new_settings.state_directory = _set_state_directory() - add_cache_to_git = _add_cache_dir_to_git() - new_settings.git.code_repo_root = _set_code_repo_root() - new_settings.git.default_branch = _set_default_branch(new_settings.git.code_repo_root) - new_settings.docs.docs_root = _set_doc_root() - new_settings.docs.exclude_dirs = _set_exclude_folders(new_settings.docs.docs_root) - new_settings.docs.doc_filetypes = _set_doc_types() - provider = _set_provider() - base_url = _set_deployment_endpoint() if provider == Provider.AZURE else None - token = _set_token() - new_settings.agent = AgentSettings( - provider=provider, - base_url=base_url, - token=token, - ) - - return new_settings, add_cache_to_git - - -@app.command() -def show( - format: Annotated[ - str, typer.Option(help="Output format: table (default), json, yaml") - ] = "table", -): - """Display current configuration.""" - settings = require_config() - - if format == "json": - import json - - # Exclude sensitive fields - safe_dump = settings.model_dump(mode="json", exclude={"agent": {"token"}}) - console.print_json(json.dumps(safe_dump, indent=2)) - - elif format == "yaml": - import yaml - - safe_dump = settings.model_dump(mode="json", exclude={"agent": {"token"}}) - console.print(yaml.dump(safe_dump, sort_keys=False)) - - else: # table (default) - table = Table(title="DOPE Configuration", show_header=True, header_style="bold cyan") - table.add_column("Setting", style="cyan", no_wrap=True) - table.add_column("Value", style="green") - - # General settings - table.add_row("State Directory", str(settings.state_directory)) - table.add_row("", "") # Spacer - - # Git settings - table.add_row("[bold]Git Settings[/bold]", "") - table.add_row(" Code Root", str(settings.git.code_repo_root)) - table.add_row(" Default Branch", settings.git.default_branch) - table.add_row("", "") - - # Docs settings - table.add_row("[bold]Docs Settings[/bold]", "") - table.add_row(" Docs Root", str(settings.docs.docs_root)) - table.add_row(" File Types", ", ".join(sorted(settings.docs.doc_filetypes))) - exclude_list = sorted(list(settings.docs.exclude_dirs)) - exclude_display = ", ".join(exclude_list[:5]) + (", ..." if len(exclude_list) > 5 else "") - table.add_row(" Excluded Dirs", exclude_display) - table.add_row("", "") - - # Agent settings - table.add_row("[bold]LLM Settings[/bold]", "") - table.add_row(" Provider", settings.agent.provider.value) - if settings.agent.base_url: - table.add_row(" Base URL", str(settings.agent.base_url)) - table.add_row(" Token", "[dim]●●●●●●●●[/dim] (hidden)") - - console.print(table) - - # Show config file location - from dope import config_filepath - - console.print(f"\n📄 Config file: [blue]{config_filepath}[/blue]") - - -@app.command() -def init( - interactive: Annotated[ - bool, typer.Option("--interactive", "-i", help="Full interactive setup") - ] = False, - force: Annotated[bool, typer.Option("--force", help="Overwrite existing config")] = False, - provider: Annotated[ - Provider, typer.Option(help="Choose LLM provider to use") - ] = Provider.OPENAI, - base_url: Annotated[ - str | None, typer.Option("--base-url", help="Deployment base URL for Azure") - ] = None, -): - """Initialize configuration (quickstart by default, --interactive for full setup).""" - _verify_provider(provider=provider, base_url=base_url) - - local_config_path = locate_local_config_file(CONFIG_FILENAME) - - if local_config_path and not force: - rprint(f"[yellow]⚠️ Config already exists at {local_config_path}[/yellow]") - if typer.confirm("Overwrite?"): - force = True - else: - rprint("[blue]Keeping existing config[/blue]") - raise typer.Exit() - - add_cache_to_git = False - - if not interactive: - # QUICKSTART MODE - minimal questions - rprint("[bold cyan]🚀 Quick setup[/bold cyan] (use --interactive for full configuration)") - rprint("") - - # Only ask essential questions - provider = _set_provider() - - base_url = _set_deployment_endpoint() if provider == Provider.AZURE else None - - token = _set_token() - - # Use smart defaults for everything else - new_settings = _create_default_settings(provider, base_url, token) - - rprint("\n[green]✅ Config created with defaults:[/green]") - rprint(f" 📁 Docs root: [blue]{new_settings.docs.docs_root}[/blue]") - rprint(f" 🔧 Code root: [blue]{new_settings.git.code_repo_root}[/blue]") - rprint(f" 💾 State dir: [blue]{new_settings.state_directory}[/blue]") - rprint(f" 🌿 Default branch: [blue]{new_settings.git.default_branch}[/blue]") - rprint("\n[dim]💡 Run 'dope config show' to see all settings[/dim]") - rprint("[dim]💡 Run 'dope config init -i --force' for full customization[/dim]") - - else: - # INTERACTIVE MODE - all questions - rprint("[bold cyan]🔧 Interactive setup[/bold cyan]") - rprint("") - new_settings, add_cache_to_git = _interactive_setup() - - # Save config - generate_local_cache(new_settings.state_directory, add_to_git=add_cache_to_git) - generate_local_config_file(CONFIG_FILENAME, new_settings) - - rprint(f"\n[green]✅ Configuration saved to {Path.cwd() / CONFIG_FILENAME}[/green]") - - -@app.command() -def validate(): - """Validate current configuration.""" - settings = require_config() - - errors = [] - warnings = [] - - # Check state directory - if not settings.state_directory.exists(): - warnings.append(f"State directory doesn't exist: {settings.state_directory}") - - # Check git repo - if settings.git.code_repo_root: - try: - repo = Repo(str(settings.git.code_repo_root)) - # Check if branch exists - branches = [str(b) for b in repo.branches] - if settings.git.default_branch not in branches: - errors.append( - f"Branch '{settings.git.default_branch}' not found in repo. " - f"Available: {', '.join(branches[:5])}" - ) - except InvalidGitRepositoryError: - errors.append(f"Not a git repository: {settings.git.code_repo_root}") - - # Check docs root - if settings.docs.docs_root and not settings.docs.docs_root.exists(): - errors.append(f"Docs root doesn't exist: {settings.docs.docs_root}") - - # Check LLM configuration - if not settings.agent.token: - errors.append("LLM token not configured") - - if settings.agent.provider == Provider.AZURE and not settings.agent.base_url: - errors.append("Azure provider requires base_url to be set") - - # Display results - if errors: - console.print("\n[red]❌ Configuration has errors:[/red]") - for error in errors: - console.print(f" • {error}") - - if warnings: - console.print("\n[yellow]⚠️ Warnings:[/yellow]") - for warning in warnings: - console.print(f" • {warning}") - - if not errors and not warnings: - console.print("\n[green]✅ Configuration is valid![/green]") - return - - if errors: - console.print("\n[blue]💡 Run 'dope config init --force' to fix[/blue]") - raise typer.Exit(1) - - -@app.command(name="set") -def update_setting( - key: Annotated[str, typer.Argument(help="Setting key (e.g., 'git.default_branch')")], - value: Annotated[str, typer.Argument(help="New value")], -): - """Update a single configuration value.""" - settings = require_config() - - # Parse nested key - parts = key.split(".") - - try: - # Navigate to the setting - obj = settings - for part in parts[:-1]: - obj = getattr(obj, part) - - # Get the field name and current value - field_name = parts[-1] - old_value = getattr(obj, field_name) - - # Set new value (with type conversion) - # pylint: disable=no-value-for-parameter - if isinstance(old_value, bool): - new_value = value.lower() in ("true", "1", "yes") - elif isinstance(old_value, Path): - new_value = Path(value) - elif isinstance(old_value, set): - new_value = set(value.split(",")) - else: - new_value = type(old_value)(value) - # pylint: enable=no-value-for-parameter - - setattr(obj, field_name, new_value) - - # Save config - from dope import config_filepath - - generate_local_config_file(CONFIG_FILENAME, settings) - - rprint(f"[green]✅ Updated {key}:[/green] {old_value} → {new_value}") - rprint(f"[dim]📄 Saved to {config_filepath}[/dim]") - - except AttributeError as e: - rprint(f"[red]❌ Unknown setting: {key}[/red]") - rprint("[blue]💡 Run 'dope config show' to see available settings[/blue]") - raise typer.Exit(1) from e - except Exception as e: - rprint(f"[red]❌ Error: {e}[/red]") - raise typer.Exit(1) from e diff --git a/dope/cli/config.py.bak b/dope/cli/config.py.bak deleted file mode 100644 index 4b67ee1..0000000 --- a/dope/cli/config.py.bak +++ /dev/null @@ -1,212 +0,0 @@ -import functools -import sys -from pathlib import Path -from typing import Annotated - -import questionary -import typer -from git import Repo -from pydantic import HttpUrl, SecretStr, ValidationError -from questionary import Choice -from rich import print - -from dope import settings -from dope.core.settings import Settings -from dope.core.utils import ( - generate_local_cache, - generate_local_config_file, - locate_local_config_file, -) -from dope.models.constants import ( - CONFIG_FILENAME, - DEFAULT_DOC_SUFFIX, - DOC_SUFFIX, - EXCLUDE_DIRS, - LOCAL_CACHE_FOLDER, -) -from dope.models.enums import Provider -from dope.models.internal import FileSuffix - -app = typer.Typer() - - -def handle_questionary_abort(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - result = None - try: - result = func(*args, **kwargs) - except KeyboardInterrupt: - pass - except Exception as err: - raise err - finally: - if result is None: - sys.exit(0) - return result - - return wrapper - - -@handle_questionary_abort -def _set_doc_root() -> str: - return Path( - questionary.path( - "Set path to doc rootfolder", only_directories=True, default=str(Path(".").resolve()) - ).ask() - ) - - -@handle_questionary_abort -def _set_doc_types() -> list[FileSuffix]: - choices = [ - Choice(title=suffix, value=suffix, checked=suffix in DEFAULT_DOC_SUFFIX) - for suffix in sorted(DOC_SUFFIX) - ] - - return set(questionary.checkbox(message="Select doc filetypes.", choices=choices).ask()) - - -@handle_questionary_abort -def _set_provider() -> Provider: - return questionary.select( - message="Which provider?", - choices=[Choice(title=provider.value, value=provider) for provider in Provider], - ).ask() - - -@handle_questionary_abort -def _set_exclude_folders(doc_root: Path) -> list[str]: - doc_root = Path(doc_root) - - def _check_folder(file: Path): - if file.name.startswith("."): - return True - if file.name in EXCLUDE_DIRS: - return True - - choices = [ - Choice(title=file.name, value=file.name, checked=_check_folder(file)) - for file in doc_root.iterdir() - if file.is_dir() - ] - if choices: - return set( - questionary.checkbox("Select folders to exclude from doc scan", choices=choices).ask() - ) - return [] - - -@handle_questionary_abort -def _set_default_branch(repo_path: str): - repo = Repo(str(repo_path), search_parent_directories=True) - branches = [str(branch) for branch in repo.branches] - return questionary.select("Select default branch", choices=branches, default="main").ask() - - -@handle_questionary_abort -def _set_code_repo_root(): - suggested_root = Repo(".", search_parent_directories=True) - return Path( - questionary.path( - "Set path to code root folder", - only_directories=True, - default=str(Path(suggested_root.working_dir).resolve()), - ).ask() - ) - - -def _validate_url(text): - try: - # Will raise if not a valid URL - HttpUrl(url=text) - return True - except ValidationError as e: - # Extract the first error message - msg = e.errors()[0]["msg"] - return f"🚫 {msg}" - - -@handle_questionary_abort -def _set_deployment_endpoint(): - return questionary.text(message="Azure deployment url:", validate=_validate_url).ask() - - -@handle_questionary_abort -def _set_token(): - return SecretStr(questionary.password("Input token").ask()) - - -@handle_questionary_abort -def _set_state_directory(): - cache_dir = Path(".") / Path(LOCAL_CACHE_FOLDER) - return Path(questionary.path("Set state dir path", default=str(cache_dir.resolve())).ask()) - - -@handle_questionary_abort -def _add_cache_dir_to_git(): - return questionary.confirm("Add cache dir to git?").ask() - - -def _verify_provider(provider: Provider, base_url: str | None) -> bool: - if provider == Provider.AZURE: - if not base_url: - raise typer.BadParameter( - f"Missing base url when provider is set to '{Provider.AZURE.value}'" - ) - try: - base_url = HttpUrl(base_url) - except ValidationError as err: - raise typer.BadParameter(f"{base_url} not valid url") from err - return True - - -@app.command() -def show(): - print(settings.model_dump(mode="json")) - - -@app.command() -def init( - all_default: Annotated[bool, typer.Option("--yes", help="All default values")] = False, - force: Annotated[bool, typer.Option("--force", help="Override existing config")] = False, - provider: Annotated[ - Provider, typer.Option(help="Choose LLM provider to use") - ] = Provider.OPENAI, - base_url: Annotated[ - str | None, typer.Option("--base-url", help="Deployment base url Azure") - ] = None, -): - """Initialize a YAML config file for Dope CLI.""" - _verify_provider(provider=provider, base_url=base_url) - - local_config_path = locate_local_config_file(CONFIG_FILENAME) - cache_dir = generate_local_cache() - new_settings = Settings() - new_settings.state_directory = cache_dir - new_settings.agent.provider = provider - - add_cache_to_git = False - - if not all_default: - new_settings.state_directory = _set_state_directory() - add_cache_to_git = _add_cache_dir_to_git() - new_settings.git.code_repo_root = _set_code_repo_root() - new_settings.git.default_branch = _set_default_branch(new_settings.git.code_repo_root) - new_settings.docs.docs_root = Path(_set_doc_root()) - new_settings.docs.exclude_dirs = _set_exclude_folders(new_settings.docs.docs_root) - new_settings.docs.doc_filetypes = _set_doc_types() - new_settings.agent.provider = _set_provider() - if new_settings.agent.provider == Provider.AZURE: - new_settings.agent.base_url = _set_deployment_endpoint() - else: - new_settings.agent.base_url = None - new_settings.agent.token = _set_token() - force = typer.confirm(f"Config found at {local_config_path}. Overwrite?") - if local_config_path and not force: - typer.echo(f"Aborted – keeping existing config found at {local_config_path}.") - raise typer.Exit() - - cache_dir = generate_local_cache(new_settings.state_directory, add_to_git=add_cache_to_git) - generate_local_config_file(CONFIG_FILENAME, new_settings) - raise typer.Exit() diff --git a/dope/cli/config/interactive.py b/dope/cli/config/interactive.py index 8bc9990..056c166 100644 --- a/dope/cli/config/interactive.py +++ b/dope/cli/config/interactive.py @@ -11,7 +11,7 @@ from dope.models.constants import DEFAULT_DOC_SUFFIX, DOC_SUFFIX, EXCLUDE_DIRS from dope.models.enums import Provider -from dope.models.internal import FileSuffix +from dope.models.shared import FileSuffix def handle_questionary_abort(func): diff --git a/dope/cli/scope.py b/dope/cli/scope.py index 41f2612..aa679c1 100644 --- a/dope/cli/scope.py +++ b/dope/cli/scope.py @@ -12,12 +12,11 @@ from dope.consumers.git_consumer import GitConsumer from dope.core.usage import UsageTracker from dope.core.utils import require_config -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocTemplate, - DocTemplateKey, - ProjectTier, ScopeTemplate, ) +from dope.models.enums import DocTemplateKey, ProjectTier from dope.services.scoper.scope_template import get_scope from dope.services.scoper.scoper_service import ScopeService diff --git a/dope/cli/suggest.py b/dope/cli/suggest.py index ee621c2..0c925f5 100644 --- a/dope/cli/suggest.py +++ b/dope/cli/suggest.py @@ -16,7 +16,7 @@ DESCRIBE_DOCS_STATE_FILENAME, SUGGESTION_STATE_FILENAME, ) -from dope.models.domain.scope_template import ScopeTemplate +from dope.models.domain.scope import ScopeTemplate from dope.services.describer.describer_base import CodeDescriberService, DescriberService from dope.services.suggester.suggester_service import DocChangeSuggester diff --git a/dope/consumers/doc_consumer.py b/dope/consumers/doc_consumer.py index 8c8c058..f94e388 100644 --- a/dope/consumers/doc_consumer.py +++ b/dope/consumers/doc_consumer.py @@ -4,7 +4,8 @@ from git import InvalidGitRepositoryError, Repo from dope.consumers.base import BaseConsumer -from dope.models.internal import FileSuffix +from dope.exceptions import InvalidDirectoryError +from dope.models.shared import FileSuffix class DocConsumer(BaseConsumer): @@ -28,7 +29,7 @@ def __init__(self, root_path: Path, file_type_filter: set[FileSuffix], exclude_d def _get_root_path(root_path) -> Path: root_path = Path(root_path) if not root_path.is_dir(): - raise NotADirectoryError(f"{root_path} is not a valid directory") + raise InvalidDirectoryError(str(root_path), "Not a valid directory") return root_path def discover_files(self, file_filter=None, exclude_dirs=None) -> list[Path]: diff --git a/dope/consumers/git_consumer.py b/dope/consumers/git_consumer.py index 322c1c2..49ca522 100644 --- a/dope/consumers/git_consumer.py +++ b/dope/consumers/git_consumer.py @@ -4,7 +4,8 @@ from git import Repo from dope.consumers.base import BaseConsumer -from dope.models.domain.doc import CodeMetadata +from dope.exceptions import DocumentNotFoundError +from dope.models.domain.documentation import CodeMetadata class GitConsumer(BaseConsumer): @@ -46,7 +47,7 @@ def discover_files( elif mode == "all": return self._get_all_files() else: - raise ValueError(f"Unsupported mode: {mode}") + raise ValueError(f"Unsupported discover_files mode: {mode}. Use 'diff' or 'all'.") def _get_diff_files(self, branch_name, exclude_patterns): ref = branch_name if branch_name else self.base_branch @@ -71,7 +72,7 @@ def get_full_content(self, file_path): with code_path.open("r") as file: return file.read() else: - raise FileNotFoundError(str(code_path)) + raise DocumentNotFoundError(str(code_path)) def _get_lines_of_code(self): all_files = self._get_all_files() diff --git a/dope/core/context.py b/dope/core/context.py index 1083ec0..ad4041a 100644 --- a/dope/core/context.py +++ b/dope/core/context.py @@ -1,10 +1,14 @@ +"""Context module - legacy placeholder. + +This module is currently unused and may be removed in a future version. +""" + import functools import threading -from pydantic_ai.usage import Usage - def _threadsafe_singleton(cls): + """Thread-safe singleton decorator (currently unused).""" lock = threading.Lock() @functools.wraps(cls) @@ -16,16 +20,3 @@ def wrapper(*args, **kwargs): return cls._instance return wrapper - - -@_threadsafe_singleton -class UsageContext: - """Global usage context.""" - - def __init__(self): - self._data_lock = threading.Lock() - self.usage: Usage = Usage() - - def log_usage(self) -> None: - """Log the total number of tokens spent.""" - print(f"Total tokens used: {self.usage.total_tokens or 0}") diff --git a/dope/core/project.py b/dope/core/project.py index c987b57..f517cd4 100644 --- a/dope/core/project.py +++ b/dope/core/project.py @@ -1,37 +1,6 @@ """Project and Git repository utilities.""" -from pathlib import Path - -from git import InvalidGitRepositoryError, Repo - - -def get_project_root() -> Path: - """Get the project root directory. - - Returns: - Path to project root (Git repository root or current directory) - """ - try: - repo = Repo(".", search_parent_directories=True) - return Path(repo.working_tree_dir) if repo.working_tree_dir else Path.cwd() - except InvalidGitRepositoryError: - return Path.cwd() - - -def is_git_repository(path: Path) -> bool: - """Check if path is within a Git repository. - - Args: - path: Path to check - - Returns: - True if path is in a Git repository - """ - try: - Repo(path, search_parent_directories=True) - return True - except InvalidGitRepositoryError: - return False +from git import Repo def get_graphical_repo_tree(repo_path: str) -> str: diff --git a/dope/core/settings.py b/dope/core/settings.py index b5d1748..ad6f438 100644 --- a/dope/core/settings.py +++ b/dope/core/settings.py @@ -1,102 +1,22 @@ -from functools import lru_cache -from pathlib import Path - -from platformdirs import user_cache_dir -from pydantic import BaseModel, Field, HttpUrl, SecretStr, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - -from dope.models.constants import DEFAULT_BRANCH, DOC_SUFFIX, EXCLUDE_DIRS -from dope.models.enums import Provider -from dope.models.internal import FileSuffix - -APP_NAME = "dope" - - -class DocSettings(BaseModel): - """Settings for documentation processing.""" - - doc_filetypes: set[FileSuffix] = DOC_SUFFIX - exclude_dirs: set[str] = EXCLUDE_DIRS - docs_root: Path | None = None - - -class CodeRepoSettings(BaseModel): - """Settings for code repository configuration.""" - - default_branch: str = DEFAULT_BRANCH - code_repo_root: Path | None = None - - -class AgentSettings(BaseModel): - """Settings for LLM agent configuration.""" - - provider: Provider = Provider.OPENAI - token: SecretStr = Field(..., exclude=True) - base_url: HttpUrl | None = None - api_version: str = Field("2024-12-01-preview") - - @model_validator(mode="after") - def validate_base_url_required_for_custom(self) -> "AgentSettings": - """Validate that base_url is provided for Azure provider.""" - if self.provider == Provider.AZURE and not self.base_url: - raise ValueError(f"base_url must be provided when provider is {Provider.AZURE.value}") - return self - - -class Settings(BaseSettings): - """Main application settings.""" - - state_directory: Path = Path(user_cache_dir(appname=APP_NAME)) - docs: DocSettings = DocSettings() - git: CodeRepoSettings = CodeRepoSettings() - agent: AgentSettings | None = None - model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") - - -@lru_cache(maxsize=1) -def get_settings() -> Settings: - """Get cached application settings. - - This function loads settings from configuration files (local or global) on first call, - then returns the cached instance on subsequent calls. This pattern: - - Avoids circular import issues by deferring imports until function call - - Prevents repeated file I/O and YAML parsing - - Makes testing easier (can clear cache with get_settings.cache_clear()) - - Provides single source of truth for settings access - - Returns: - Settings: The cached settings instance. - - Raises: - SystemExit: If configuration file exists but is invalid. - - Example: - >>> settings = get_settings() - >>> settings.agent.provider - Provider.OPENAI - """ - from dope.core.config_io import load_settings_from_yaml - from dope.core.config_locator import locate_global_config, locate_local_config_file - from dope.models.constants import CONFIG_FILENAME - - config_filepath = locate_local_config_file(CONFIG_FILENAME) or locate_global_config( - CONFIG_FILENAME - ) - - # Always create settings object - agent will be None if no config - settings = Settings() - if config_filepath: - try: - settings = Settings(**load_settings_from_yaml(config_filepath)) - except Exception as e: - # Config exists but is invalid - this is an error - import sys - - from rich import print as rprint - - rprint(f"[red]❌ Config file invalid: {config_filepath}[/red]") - rprint(f"[yellow]Error: {e}[/yellow]") - rprint("[blue]Run 'dope config init --force' to recreate[/blue]") - sys.exit(1) - - return settings +"""Settings module - backward compatibility wrapper. + +DEPRECATED: Import from dope.models.settings instead. +This module provides backward compatibility and will remain for the foreseeable future. +""" + +# Import from new location for backward compatibility +from dope.models.settings import ( + AgentSettings, + CodeRepoSettings, + DocSettings, + Settings, + get_settings, +) + +__all__ = [ + "AgentSettings", + "CodeRepoSettings", + "DocSettings", + "Settings", + "get_settings", +] diff --git a/dope/core/utils.py b/dope/core/utils.py index 1621dcf..8bf6dad 100644 --- a/dope/core/utils.py +++ b/dope/core/utils.py @@ -11,14 +11,6 @@ import sys # Re-export from new modules for backward compatibility -from dope.core.config_io import ( # noqa: F401 - generate_global_config_file, - generate_local_cache, - generate_local_config_file, - load_settings_from_yaml, -) -from dope.core.config_locator import locate_global_config, locate_local_config_file # noqa: F401 -from dope.core.project import get_graphical_repo_tree # noqa: F401 def require_config(): diff --git a/dope/exceptions.py b/dope/exceptions.py new file mode 100644 index 0000000..e8f9ae2 --- /dev/null +++ b/dope/exceptions.py @@ -0,0 +1,167 @@ +"""Custom exceptions for dope application. + +This module provides a hierarchical exception structure for better error handling +and debugging throughout the dope application. +""" + + +class DopeError(Exception): + """Base exception for all dope errors.""" + + +class ConfigurationError(DopeError): + """Base class for configuration-related errors.""" + + +class ConfigNotFoundError(ConfigurationError): + """Raised when configuration file cannot be found.""" + + def __init__(self, search_paths: list[str] | None = None): + """Initialize ConfigNotFoundError. + + Args: + search_paths: List of paths searched for config file + """ + self.search_paths = search_paths + msg = "Configuration file not found" + if search_paths: + msg += f". Searched in: {', '.join(search_paths)}" + super().__init__(msg) + + +class InvalidConfigError(ConfigurationError): + """Raised when configuration is invalid.""" + + def __init__(self, config_path: str, reason: str): + """Initialize InvalidConfigError. + + Args: + config_path: Path to the invalid config file + reason: Explanation of why config is invalid + """ + self.config_path = config_path + self.reason = reason + super().__init__(f"Invalid configuration in {config_path}: {reason}") + + +class GitError(DopeError): + """Base class for git-related errors.""" + + +class GitRepositoryNotFoundError(GitError): + """Raised when git repository is not found.""" + + def __init__(self, path: str): + """Initialize GitRepositoryNotFoundError. + + Args: + path: Path where git repository was expected + """ + self.path = path + super().__init__(f"Not a git repository: {path}") + + +class GitBranchNotFoundError(GitError): + """Raised when git branch does not exist.""" + + def __init__(self, branch: str, available_branches: list[str] | None = None): + """Initialize GitBranchNotFoundError. + + Args: + branch: Name of the branch that was not found + available_branches: List of available branches + """ + self.branch = branch + self.available_branches = available_branches + msg = f"Branch '{branch}' not found" + if available_branches: + branches_str = ", ".join(available_branches[:5]) + if len(available_branches) > 5: + branches_str += f", ... ({len(available_branches) - 5} more)" + msg += f". Available branches: {branches_str}" + super().__init__(msg) + + +class DocumentError(DopeError): + """Base class for document-related errors.""" + + +class DocumentNotFoundError(DocumentError): + """Raised when a document cannot be found.""" + + def __init__(self, doc_path: str): + """Initialize DocumentNotFoundError. + + Args: + doc_path: Path to the document that was not found + """ + self.doc_path = doc_path + super().__init__(f"Document not found: {doc_path}") + + +class DirectoryError(DopeError): + """Base class for directory-related errors.""" + + +class InvalidDirectoryError(DirectoryError): + """Raised when a path is not a valid directory.""" + + def __init__(self, path: str, reason: str = "Not a directory"): + """Initialize InvalidDirectoryError. + + Args: + path: Path that is not a valid directory + reason: Explanation of why path is invalid + """ + self.path = path + self.reason = reason + super().__init__(f"{reason}: {path}") + + +class AgentError(DopeError): + """Base class for LLM agent-related errors.""" + + +class AgentNotConfiguredError(AgentError): + """Raised when agent is not properly configured.""" + + def __init__( + self, + message: str = "Agent configuration not found. Run 'dope config init' first.", + ): + """Initialize AgentNotConfiguredError. + + Args: + message: Custom error message + """ + super().__init__(message) + + +class ProviderError(AgentError): + """Raised when LLM provider configuration is invalid.""" + + def __init__(self, provider: str, reason: str): + """Initialize ProviderError. + + Args: + provider: Name of the LLM provider + reason: Explanation of the configuration error + """ + self.provider = provider + self.reason = reason + super().__init__(f"Provider '{provider}' configuration error: {reason}") + + +class InvalidSuffixError(DopeError): + """Raised when a file suffix is invalid.""" + + def __init__(self, suffix: str, reason: str = "Invalid suffix"): + """Initialize InvalidSuffixError. + + Args: + suffix: The invalid suffix + reason: Explanation of why suffix is invalid + """ + self.suffix = suffix + self.reason = reason + super().__init__(f"{reason}: {suffix}") diff --git a/dope/llms/model_factory.py b/dope/llms/model_factory.py index b0d88dc..3dac637 100644 --- a/dope/llms/model_factory.py +++ b/dope/llms/model_factory.py @@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider from dope.core.settings import get_settings +from dope.exceptions import AgentNotConfiguredError, ProviderError from dope.models.enums import Provider @@ -13,10 +14,12 @@ def _get_openai_provider(provider): settings = get_settings() if not settings.agent: - raise ValueError("Agent settings not configured") + raise AgentNotConfiguredError( + "Agent settings not configured. Run 'dope config init' first." + ) if provider == Provider.AZURE: if not settings.agent.base_url: - raise ValueError("Azure provider requires base_url to be configured") + raise ProviderError("azure", "base_url must be configured for Azure provider") return AzureProvider( azure_endpoint=settings.agent.base_url.unicode_string(), api_version=settings.agent.api_version, diff --git a/dope/models/__init__.py b/dope/models/__init__.py index e69de29..66c09da 100644 --- a/dope/models/__init__.py +++ b/dope/models/__init__.py @@ -0,0 +1,111 @@ +"""Pydantic models for dope application. + +This module provides all data models used throughout the application, +organized into logical groups for easy importing. +""" + +# Settings models +# Constants +from dope.models.constants import ( + APP_NAME, + CONFIG_FILENAME, + DEFAULT_BRANCH, + DEFAULT_DOC_SUFFIX, + DESCRIBE_CODE_STATE_FILENAME, + DESCRIBE_DOCS_STATE_FILENAME, + DOC_SUFFIX, + EXCLUDE_DIRS, + LOCAL_CACHE_FOLDER, + SUGGESTION_STATE_FILENAME, +) + +# Domain models - Code +from dope.models.domain.code import CodeChange, CodeChanges + +# Domain models - Documentation +from dope.models.domain.documentation import ( + ChangeSuggestion, + CodeMetadata, + DocSection, + DocSuggestions, + DocSummary, + SuggestedChange, +) + +# Domain models - Scope +from dope.models.domain.scope import ( + AlignedScope, + DocSectionTemplate, + DocTemplate, + ScopeTemplate, + StructureTemplate, +) +from dope.models.domain.scope import ( + SuggestedChange as ScopeSuggestedChange, +) + +# Enums +from dope.models.enums import ( + ChangeType, + DocTemplateKey, + ProjectTier, + Provider, + SectionAudience, + SectionTheme, +) +from dope.models.settings import ( + AgentSettings, + CodeRepoSettings, + DocSettings, + Settings, + get_settings, +) + +# Shared types +from dope.models.shared import FileSuffix + +__all__ = [ + # Settings + "AgentSettings", + "CodeRepoSettings", + "DocSettings", + "Settings", + "get_settings", + # Shared + "FileSuffix", + # Enums + "ChangeType", + "DocTemplateKey", + "ProjectTier", + "Provider", + "SectionAudience", + "SectionTheme", + # Domain - Code + "CodeChange", + "CodeChanges", + # Domain - Documentation + "ChangeSuggestion", + "CodeMetadata", + "DocSection", + "DocSuggestions", + "DocSummary", + "SuggestedChange", + # Domain - Scope + "AlignedScope", + "DocSectionTemplate", + "DocTemplate", + "ScopeTemplate", + "ScopeSuggestedChange", + "StructureTemplate", + # Constants + "APP_NAME", + "CONFIG_FILENAME", + "DEFAULT_BRANCH", + "DEFAULT_DOC_SUFFIX", + "DESCRIBE_CODE_STATE_FILENAME", + "DESCRIBE_DOCS_STATE_FILENAME", + "DOC_SUFFIX", + "EXCLUDE_DIRS", + "LOCAL_CACHE_FOLDER", + "SUGGESTION_STATE_FILENAME", +] diff --git a/dope/models/constants.py b/dope/models/constants.py index 3604af2..58db752 100644 --- a/dope/models/constants.py +++ b/dope/models/constants.py @@ -1,4 +1,4 @@ -from dope.models.internal import FileSuffix +from dope.models.shared import FileSuffix SUGGESTION_STATE_FILENAME: str = "suggestion-state.json" DESCRIBE_DOCS_STATE_FILENAME: str = "doc-state.json" diff --git a/dope/models/domain/__init__.py b/dope/models/domain/__init__.py index e69de29..5dc9fd3 100644 --- a/dope/models/domain/__init__.py +++ b/dope/models/domain/__init__.py @@ -0,0 +1,41 @@ +"""Domain models for dope application.""" + +from dope.models.domain.code import CodeChange, CodeChanges +from dope.models.domain.documentation import ( + ChangeSuggestion, + CodeMetadata, + DocSection, + DocSuggestions, + DocSummary, + SuggestedChange, +) +from dope.models.domain.scope import ( + AlignedScope, + DocSectionTemplate, + DocTemplate, + ScopeTemplate, + StructureTemplate, +) +from dope.models.domain.scope import ( + SuggestedChange as ScopeSuggestedChange, +) + +__all__ = [ + # Code models + "CodeChange", + "CodeChanges", + # Documentation models + "ChangeSuggestion", + "CodeMetadata", + "DocSection", + "DocSuggestions", + "DocSummary", + "SuggestedChange", + # Scope models + "AlignedScope", + "DocSectionTemplate", + "DocTemplate", + "ScopeTemplate", + "ScopeSuggestedChange", + "StructureTemplate", +] diff --git a/dope/models/domain/doc.py b/dope/models/domain/doc.py index ec930cd..ea2537d 100644 --- a/dope/models/domain/doc.py +++ b/dope/models/domain/doc.py @@ -1,7 +1,7 @@ -from enum import Enum - from pydantic import BaseModel, Field +from dope.models.enums import ChangeType + class DocSection(BaseModel): """Summary of a section within the doc.""" @@ -29,14 +29,6 @@ class DocSummary(BaseModel): ) -class ChangeType(str, Enum): - """Enum to indicate if doc is to be added, modified or deleted.""" - - ADD = "add" - CHANGE = "change_existing" - DELETE = "delete" - - class ChangeSuggestion(BaseModel): """Change suggestion for a specific doc part based on code.""" diff --git a/dope/models/domain/documentation.py b/dope/models/domain/documentation.py new file mode 100644 index 0000000..ea2537d --- /dev/null +++ b/dope/models/domain/documentation.py @@ -0,0 +1,87 @@ +from pydantic import BaseModel, Field + +from dope.models.enums import ChangeType + + +class DocSection(BaseModel): + """Summary of a section within the doc.""" + + section_name: str = Field(..., description="Name of the section") + summary: str | None = Field( + ..., + description="A detailed description of the section describing current content in the file." + " Be specific about names, commands or variables in the text. This is intended as a summary" + "for someone who knows the codebase well.", + ) + references: list[str] = Field( + ..., + description="References to code in the text such as commands," + "config values, libraries files etc.", + ) + + +class DocSummary(BaseModel): + """Summary of a doc.""" + + sections: list[DocSection] | None = Field( + ..., + description="List of major headings or sections (e.g., Introduction, Setup, API, Examples)", + ) + + +class ChangeSuggestion(BaseModel): + """Change suggestion for a specific doc part based on code.""" + + suggestion: str = Field( + ..., + description=( + "List of detailed and specific instructions to what changes to apply to the " + "documentation file based on the code changes. " + "All changes related to this file are grouped here." + ), + ) + code_references: list[str] = Field( + ..., + description="Files with code changes that are important to apply this change.", + ) + + +class SuggestedChange(BaseModel): + """All changes to apply to a particular documentation file.""" + + change_type: ChangeType = Field( + ..., + description="Indicate if this change is to add a new file, change an existing file, " + "or delete a redundant file.", + example=ChangeType.ADD, + ) + documentation_file_path: str = Field( + ..., + description="Path to the documentation file to add, change, or delete.", + example="docs/readme.md", + ) + suggested_changes: list[ChangeSuggestion] = Field( + ..., description="List of particular changes." + ) + + +class DocSuggestions(BaseModel): + """Changes to apply to the whole documentation based on code changes.""" + + changes_to_apply: list[SuggestedChange] = Field( + ..., + description=( + "List of changes to apply to documentation, keyed by the unique documentation file " + "path. This ensures that each file path occurs only once." + ), + ) + + +class CodeMetadata(BaseModel): + """Repo metadata.""" + + commits: int = Field(..., description="number of commits in branch") + num_contributors: int = Field(..., description="Number of unique contributors.") + branches: list[str] = Field(..., description="Name of branches in repo.") + tags: list[str] = Field(..., description="Name of tags in repo.") + lines_of_code: int = Field(..., description="lines of code in repo") diff --git a/dope/models/domain/scope.py b/dope/models/domain/scope.py new file mode 100644 index 0000000..234303a --- /dev/null +++ b/dope/models/domain/scope.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, Field + +from dope.models.enums import ( + DocTemplateKey, + ProjectTier, + SectionAudience, + SectionTheme, +) + + +class DocSectionTemplate(BaseModel): + """Section of a doc.""" + + description: str = Field( + ..., description="Functional description of the section and expected content." + ) + themes: list[SectionTheme] = Field(..., description="Theme of the section.") + roles: list[SectionAudience] | None = Field( + None, description="Roles which whom the section is relevant for", exclude=True + ) + + +class DocTemplate(BaseModel): + """Template for a doc in a doc structure.""" + + tiers: list[ProjectTier] | None = Field( + None, description="Tier the doc is suited for.", exclude=True + ) + roles: list[SectionAudience] | None = Field( + None, description="Roles for whom the document is relevant", exclude=True + ) + implemented_in_path: str | None = Field( + None, description="Path to the implementation of the documentation." + ) + description: str = Field( + ..., description="Functional description of the documentation and expected content." + ) + sections: dict[str, DocSectionTemplate] = Field(..., description="Sections in the doc.") + + +class StructureTemplate(BaseModel): + """Template for a doc structure.""" + + docs: dict[DocTemplateKey, DocTemplate] + + +class ScopeTemplate(BaseModel): + """Scope Template.""" + + size: ProjectTier = Field(..., description="The perceived complexity tier of the application") + documentation_structure: dict[DocTemplateKey, DocTemplate] = Field( + ..., description="The set of documentation sections to include" + ) + + def get_all_documents(self) -> set[DocTemplateKey]: + """Returns a set of all document keys in the documentation structure. + + Returns: + Set of document template keys + """ + # pylint: disable=no-member # Pylint confused by Pydantic Field descriptor + return set(self.documentation_structure.keys()) + + +class SuggestedChange(BaseModel): + """Changes suggested based on reviewing doc file.""" + + filepath: str = Field(..., description="Path to doc file to apply the suggested change to.") + instructions: str = Field(..., description="Instructions on what has to change in the file.") + content: str = Field(..., description="Content to add or implement in another file.") + + +class AlignedScope(BaseModel): + """Result of aligning scope.""" + + content: str = Field(..., description="Markdown content of the modified file.") + changes_in_other_files: list[SuggestedChange] diff --git a/dope/models/domain/scope_template.py b/dope/models/domain/scope_template.py index 6243c5e..234303a 100644 --- a/dope/models/domain/scope_template.py +++ b/dope/models/domain/scope_template.py @@ -1,67 +1,11 @@ -from enum import Enum - from pydantic import BaseModel, Field - -class ProjectTier(str, Enum): - """Estimated size or complexity of a project.""" - - trivial = "trivial" - small = "small" - medium = "medium" - large = "large" - massive = "massive" - - -class SectionAudience(str, Enum): - """Audience for a specific section in the documentation.""" - - all = "all" - management = "management" - engineering = "engineering" - user = "user" - support = "support" - finance = "finance" - compliance = "compliance" - - -class SectionTheme(str, Enum): - """Content of a secific section.""" - - introductory = "introductory" - technical = "technical" - operational = "operational" - governance = "governance" - strategic = "strategic" - - -class DocTemplateKey(str, Enum): - """Doc type in structure.""" - - readme = "readme" - contributing = "contributing" - quickstart = "quickstart" - examples_cookbook = "examples_cookbook" - api_reference = "api_reference" - changelog = "changelog" - user_guide = "user_guide" - tutorials = "tutorials" - installation_guide = "installation_guide" - architecture_overview = "architecture_overview" - developer_guide = "developer_guide" - testing_guide = "testing_guide" - ci_cd_release_notes = "ci_cd_release_notes" - deployment_guide = "deployment_guide" - operations_runbooks = "operations_runbooks" - security_compliance = "security_compliance" - performance_tuning = "performance_tuning" - monitoring_metrics = "monitoring_metrics" - onboarding_guide = "onboarding_guide" - governance_rfcs = "governance_rfcs" - compliance_manuals = "compliance_manuals" - run_cost_capacity_planning = "run_cost_capacity_planning" - glossary = "glossary" - faq_troubleshooting = "faq_troubleshooting" +from dope.models.enums import ( + DocTemplateKey, + ProjectTier, + SectionAudience, + SectionTheme, +) class DocSectionTemplate(BaseModel): diff --git a/dope/models/enums.py b/dope/models/enums.py index 0f470ab..be6ecc5 100644 --- a/dope/models/enums.py +++ b/dope/models/enums.py @@ -1,3 +1,5 @@ +"""Enumerations used across dope models.""" + from enum import Enum @@ -6,3 +8,72 @@ class Provider(str, Enum): OPENAI = "openai" AZURE = "azure" + + +class ChangeType(str, Enum): + """Enum to indicate if doc is to be added, modified or deleted.""" + + ADD = "add" + CHANGE = "change_existing" + DELETE = "delete" + + +class ProjectTier(str, Enum): + """Estimated size or complexity of a project.""" + + trivial = "trivial" + small = "small" + medium = "medium" + large = "large" + massive = "massive" + + +class SectionAudience(str, Enum): + """Audience for a specific section in the documentation.""" + + all = "all" + management = "management" + engineering = "engineering" + user = "user" + support = "support" + finance = "finance" + compliance = "compliance" + + +class SectionTheme(str, Enum): + """Content of a specific section.""" + + introductory = "introductory" + technical = "technical" + operational = "operational" + governance = "governance" + strategic = "strategic" + + +class DocTemplateKey(str, Enum): + """Doc type in structure.""" + + readme = "readme" + contributing = "contributing" + quickstart = "quickstart" + examples_cookbook = "examples_cookbook" + api_reference = "api_reference" + changelog = "changelog" + user_guide = "user_guide" + tutorials = "tutorials" + installation_guide = "installation_guide" + architecture_overview = "architecture_overview" + developer_guide = "developer_guide" + testing_guide = "testing_guide" + ci_cd_release_notes = "ci_cd_release_notes" + deployment_guide = "deployment_guide" + operations_runbooks = "operations_runbooks" + security_compliance = "security_compliance" + performance_tuning = "performance_tuning" + monitoring_metrics = "monitoring_metrics" + onboarding_guide = "onboarding_guide" + governance_rfcs = "governance_rfcs" + compliance_manuals = "compliance_manuals" + run_cost_capacity_planning = "run_cost_capacity_planning" + glossary = "glossary" + faq_troubleshooting = "faq_troubleshooting" diff --git a/dope/models/internal.py b/dope/models/internal.py index fb20b21..b79e156 100644 --- a/dope/models/internal.py +++ b/dope/models/internal.py @@ -1,3 +1,6 @@ +from dope.exceptions import InvalidSuffixError + + class FileSuffix(str): """File suffix validation class.""" @@ -8,7 +11,7 @@ def __get_validators__(cls): # noqa: D105 @classmethod def validate(cls, v, _info=None): # noqa: D102 if not isinstance(v, str): - raise TypeError("string required") + raise InvalidSuffixError(str(v), "Suffix must be a string") if not v.startswith("."): v = "." + v return v.lower() diff --git a/dope/models/settings.py b/dope/models/settings.py new file mode 100644 index 0000000..df9ee0e --- /dev/null +++ b/dope/models/settings.py @@ -0,0 +1,104 @@ +"""Pydantic models for application settings.""" + +from functools import lru_cache +from pathlib import Path + +from platformdirs import user_cache_dir +from pydantic import BaseModel, Field, HttpUrl, SecretStr, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from dope.models.constants import DEFAULT_BRANCH, DOC_SUFFIX, EXCLUDE_DIRS +from dope.models.enums import Provider +from dope.models.shared import FileSuffix + +APP_NAME = "dope" + + +class DocSettings(BaseModel): + """Settings for documentation processing.""" + + doc_filetypes: set[FileSuffix] = DOC_SUFFIX + exclude_dirs: set[str] = EXCLUDE_DIRS + docs_root: Path | None = None + + +class CodeRepoSettings(BaseModel): + """Settings for code repository configuration.""" + + default_branch: str = DEFAULT_BRANCH + code_repo_root: Path | None = None + + +class AgentSettings(BaseModel): + """Settings for LLM agent configuration.""" + + provider: Provider = Provider.OPENAI + token: SecretStr = Field(..., exclude=True) + base_url: HttpUrl | None = None + api_version: str = Field("2024-12-01-preview") + + @model_validator(mode="after") + def validate_base_url_required_for_custom(self) -> "AgentSettings": + """Validate that base_url is provided for Azure provider.""" + if self.provider == Provider.AZURE and not self.base_url: + raise ValueError(f"base_url must be provided when provider is {Provider.AZURE.value}") + return self + + +class Settings(BaseSettings): + """Main application settings.""" + + state_directory: Path = Path(user_cache_dir(appname=APP_NAME)) + docs: DocSettings = DocSettings() + git: CodeRepoSettings = CodeRepoSettings() + agent: AgentSettings | None = None + model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Get cached application settings. + + This function loads settings from configuration files (local or global) on first call, + then returns the cached instance on subsequent calls. This pattern: + - Avoids circular import issues by deferring imports until function call + - Prevents repeated file I/O and YAML parsing + - Makes testing easier (can clear cache with get_settings.cache_clear()) + - Provides single source of truth for settings access + + Returns: + Settings: The cached settings instance. + + Raises: + SystemExit: If configuration file exists but is invalid. + + Example: + >>> settings = get_settings() + >>> settings.agent.provider + Provider.OPENAI + """ + from dope.core.config_io import load_settings_from_yaml + from dope.core.config_locator import locate_global_config, locate_local_config_file + from dope.models.constants import CONFIG_FILENAME + + config_filepath = locate_local_config_file(CONFIG_FILENAME) or locate_global_config( + CONFIG_FILENAME + ) + + # Always create settings object - agent will be None if no config + settings = Settings() + if config_filepath: + try: + settings = Settings(**load_settings_from_yaml(config_filepath)) + except Exception as e: + # Config exists but is invalid - this is an error + import sys + + from rich import print as rprint + + rprint(f"[red]❌ Config file invalid: {config_filepath}[/red]") + rprint(f"[yellow]Error: {e}[/yellow]") + rprint("[blue]Run 'dope config init --force' to recreate[/blue]") + sys.exit(1) + + return settings diff --git a/dope/models/shared.py b/dope/models/shared.py new file mode 100644 index 0000000..b79e156 --- /dev/null +++ b/dope/models/shared.py @@ -0,0 +1,17 @@ +from dope.exceptions import InvalidSuffixError + + +class FileSuffix(str): + """File suffix validation class.""" + + @classmethod + def __get_validators__(cls): # noqa: D105 + yield cls.validate + + @classmethod + def validate(cls, v, _info=None): # noqa: D102 + if not isinstance(v, str): + raise InvalidSuffixError(str(v), "Suffix must be a string") + if not v.startswith("."): + v = "." + v + return v.lower() diff --git a/dope/services/changer/changer_agents.py b/dope/services/changer/changer_agents.py index 01a8697..09ca696 100644 --- a/dope/services/changer/changer_agents.py +++ b/dope/services/changer/changer_agents.py @@ -6,6 +6,7 @@ from dope.consumers.git_consumer import GitConsumer from dope.core.settings import get_settings +from dope.exceptions import AgentNotConfiguredError, DocumentNotFoundError from dope.llms.model_factory import get_model from dope.services.changer.prompts import CHANGE_DOC_PROMPT @@ -22,7 +23,7 @@ def get_changer_agent() -> Agent[Deps, str]: """Get the changer agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() agent = Agent( model=get_model(settings.agent.provider, "gpt-4.1"), deps_type=Deps, @@ -40,7 +41,7 @@ def get_code_file_content(_ctx: RunContext[Deps], code_filepath: str) -> str: """ print(f"Calling code tool for file {code_filepath}") if not Path(code_filepath).is_file(): - raise FileNotFoundError(code_filepath) + raise DocumentNotFoundError(code_filepath) content = _ctx.deps.git_consumer.get_full_content(file_path=code_filepath) return content diff --git a/dope/services/changer/changer_service.py b/dope/services/changer/changer_service.py index 37630f3..090ab57 100644 --- a/dope/services/changer/changer_service.py +++ b/dope/services/changer/changer_service.py @@ -3,7 +3,7 @@ from pydantic.json import pydantic_encoder from dope.core.usage import UsageTracker -from dope.models.domain.doc import SuggestedChange +from dope.models.domain.documentation import SuggestedChange from dope.services.changer.changer_agents import Deps, get_changer_agent from dope.services.changer.prompts import ADD_DOC_USER_PROMPT, CHANGE_DOC_USER_PROMPT diff --git a/dope/services/describer/describer_agents.py b/dope/services/describer/describer_agents.py index 7e8e92c..e484ab4 100644 --- a/dope/services/describer/describer_agents.py +++ b/dope/services/describer/describer_agents.py @@ -6,9 +6,10 @@ from dope.consumers.git_consumer import GitConsumer from dope.core.settings import get_settings +from dope.exceptions import AgentNotConfiguredError, DocumentNotFoundError from dope.llms.model_factory import get_model from dope.models.domain.code import CodeChanges -from dope.models.domain.doc import DocSummary +from dope.models.domain.documentation import DocSummary from dope.services.describer.prompts import CODE_DESCRIPTION_PROMPT, DOC_DESCRIPTION_PROMPT @@ -24,7 +25,7 @@ def get_code_change_agent() -> Agent[Deps, CodeChanges]: """Get the code change agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() agent = Agent( model=get_model(settings.agent.provider, "gpt-4.1-mini"), deps_type=Deps, @@ -47,7 +48,7 @@ def get_code_file_content(_ctx: RunContext[Deps], code_filepath: str) -> str: """ print(f"Calling code tool for file {code_filepath}") if not Path(code_filepath).is_file(): - raise FileNotFoundError(code_filepath) + raise DocumentNotFoundError(code_filepath) content = _ctx.deps.consumer.get_full_content(file_path=code_filepath) return content @@ -59,7 +60,7 @@ def get_doc_summarization_agent() -> Agent[None, DocSummary]: """Get the doc summarization agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1-mini"), output_type=DocSummary) @agent.system_prompt diff --git a/dope/services/scoper/scope_template/__init__.py b/dope/services/scoper/scope_template/__init__.py index 526af39..f6ddac2 100644 --- a/dope/services/scoper/scope_template/__init__.py +++ b/dope/services/scoper/scope_template/__init__.py @@ -1,9 +1,8 @@ -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocTemplate, - DocTemplateKey, - ProjectTier, StructureTemplate, ) +from dope.models.enums import DocTemplateKey, ProjectTier from dope.services.scoper.scope_template.large import large from dope.services.scoper.scope_template.massive import massive from dope.services.scoper.scope_template.medium import medium diff --git a/dope/services/scoper/scope_template/large.py b/dope/services/scoper/scope_template/large.py index 4ca1b5f..508a059 100644 --- a/dope/services/scoper/scope_template/large.py +++ b/dope/services/scoper/scope_template/large.py @@ -1,11 +1,8 @@ -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocSectionTemplate, DocTemplate, - DocTemplateKey, - ProjectTier, - SectionAudience, - SectionTheme, ) +from dope.models.enums import DocTemplateKey, ProjectTier, SectionAudience, SectionTheme TIERS = [ProjectTier.large, ProjectTier.massive] diff --git a/dope/services/scoper/scope_template/massive.py b/dope/services/scoper/scope_template/massive.py index 7b0e382..2510d5e 100644 --- a/dope/services/scoper/scope_template/massive.py +++ b/dope/services/scoper/scope_template/massive.py @@ -1,11 +1,8 @@ -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocSectionTemplate, DocTemplate, - DocTemplateKey, - ProjectTier, - SectionAudience, - SectionTheme, ) +from dope.models.enums import DocTemplateKey, ProjectTier, SectionAudience, SectionTheme TIERS = [ProjectTier.large, ProjectTier.massive] onboarding_guide = DocTemplate( diff --git a/dope/services/scoper/scope_template/medium.py b/dope/services/scoper/scope_template/medium.py index c44f0d7..9a296fb 100644 --- a/dope/services/scoper/scope_template/medium.py +++ b/dope/services/scoper/scope_template/medium.py @@ -1,11 +1,8 @@ -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocSectionTemplate, DocTemplate, - DocTemplateKey, - ProjectTier, - SectionAudience, - SectionTheme, ) +from dope.models.enums import DocTemplateKey, ProjectTier, SectionAudience, SectionTheme TIERS = [ProjectTier.medium, ProjectTier.large, ProjectTier.massive] diff --git a/dope/services/scoper/scope_template/small.py b/dope/services/scoper/scope_template/small.py index 4908cd3..6882553 100644 --- a/dope/services/scoper/scope_template/small.py +++ b/dope/services/scoper/scope_template/small.py @@ -1,11 +1,8 @@ -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocSectionTemplate, DocTemplate, - DocTemplateKey, - ProjectTier, - SectionAudience, - SectionTheme, ) +from dope.models.enums import DocTemplateKey, ProjectTier, SectionAudience, SectionTheme TIERS = [ProjectTier.small, ProjectTier.medium, ProjectTier.large, ProjectTier.massive] diff --git a/dope/services/scoper/scope_template/trivial.py b/dope/services/scoper/scope_template/trivial.py index e2bc27f..563f490 100644 --- a/dope/services/scoper/scope_template/trivial.py +++ b/dope/services/scoper/scope_template/trivial.py @@ -1,11 +1,8 @@ -from dope.models.domain.scope_template import ( +from dope.models.domain.scope import ( DocSectionTemplate, DocTemplate, - DocTemplateKey, - ProjectTier, - SectionAudience, - SectionTheme, ) +from dope.models.enums import DocTemplateKey, ProjectTier, SectionAudience, SectionTheme TIERS = [ ProjectTier.trivial, diff --git a/dope/services/scoper/scoper_agents.py b/dope/services/scoper/scoper_agents.py index 4750e02..bc974ed 100644 --- a/dope/services/scoper/scoper_agents.py +++ b/dope/services/scoper/scoper_agents.py @@ -3,8 +3,10 @@ from pydantic_ai import Agent from dope.core.settings import get_settings +from dope.exceptions import AgentNotConfiguredError from dope.llms.model_factory import get_model -from dope.models.domain.scope_template import AlignedScope, ProjectTier +from dope.models.domain.scope import AlignedScope +from dope.models.enums import ProjectTier from dope.services.scoper.prompts import ( ALIGN_DOC_PROMPT, COMPLEXITY_DETERMINATION, @@ -17,7 +19,7 @@ def get_project_complexity_agent() -> Agent[None, ProjectTier]: """Get the project complexity agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1-mini"), output_type=ProjectTier) @agent.system_prompt @@ -32,7 +34,7 @@ def get_scope_creator_agent() -> Agent[None, dict[str, str]]: """Get the scope creator agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1"), output_type=dict[str, str]) @agent.system_prompt @@ -47,7 +49,7 @@ def get_doc_aligner_agent() -> Agent[None, AlignedScope]: """Get the doc aligner agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1"), output_type=AlignedScope) @agent.system_prompt diff --git a/dope/services/scoper/scoper_service.py b/dope/services/scoper/scoper_service.py index fe18c1f..8770a84 100644 --- a/dope/services/scoper/scoper_service.py +++ b/dope/services/scoper/scoper_service.py @@ -4,7 +4,7 @@ from dope.consumers.git_consumer import GitConsumer from dope.core.progress import track from dope.core.usage import UsageTracker -from dope.models.domain.scope_template import ScopeTemplate, SuggestedChange +from dope.models.domain.scope import ScopeTemplate, SuggestedChange from dope.services.scoper.prompts import CHANGE_FILE_PROMPT, MOVE_CONTENT_PROMPT, PROMPT from dope.services.scoper.scoper_agents import ( get_doc_aligner_agent, diff --git a/dope/services/suggester/suggester_agents.py b/dope/services/suggester/suggester_agents.py index 3616cd0..1ec0643 100644 --- a/dope/services/suggester/suggester_agents.py +++ b/dope/services/suggester/suggester_agents.py @@ -3,8 +3,9 @@ from pydantic_ai import Agent from dope.core.settings import get_settings +from dope.exceptions import AgentNotConfiguredError from dope.llms.model_factory import get_model -from dope.models.domain.doc import DocSuggestions +from dope.models.domain.documentation import DocSuggestions from dope.services.suggester.prompts import SYSTEM_PROMPT @@ -13,7 +14,7 @@ def get_suggester_agent() -> Agent[None, DocSuggestions]: """Get the suggester agent (lazy-initialized and cached).""" settings = get_settings() if settings.agent is None: - raise RuntimeError("Agent configuration not found. Run 'dope config init' first.") + raise AgentNotConfiguredError() model = get_model(settings.agent.provider, "o4-mini") agent = Agent(model=model, output_type=DocSuggestions) diff --git a/dope/services/suggester/suggester_service.py b/dope/services/suggester/suggester_service.py index 3027ebd..c92c3ec 100644 --- a/dope/services/suggester/suggester_service.py +++ b/dope/services/suggester/suggester_service.py @@ -5,7 +5,7 @@ from pydantic.json import pydantic_encoder from dope.core.usage import UsageTracker -from dope.models.domain.doc import DocSuggestions +from dope.models.domain.documentation import DocSuggestions from dope.services.suggester.prompts import FILE_SUMMARY_PROMPT, SUGGESTION_PROMPT from dope.services.suggester.suggester_agents import get_suggester_agent diff --git a/tests/unit/exceptions_test.py b/tests/unit/exceptions_test.py new file mode 100644 index 0000000..12f0101 --- /dev/null +++ b/tests/unit/exceptions_test.py @@ -0,0 +1,201 @@ +"""Tests for custom exceptions.""" + +import pytest + +from dope.exceptions import ( + AgentError, + AgentNotConfiguredError, + ConfigNotFoundError, + ConfigurationError, + DirectoryError, + DocumentError, + DocumentNotFoundError, + DopeError, + GitBranchNotFoundError, + GitError, + GitRepositoryNotFoundError, + InvalidConfigError, + InvalidDirectoryError, + InvalidSuffixError, + ProviderError, +) + + +def test_dope_error_is_base_exception(): + """Test that DopeError is base for all exceptions.""" + assert issubclass(ConfigurationError, DopeError) + assert issubclass(GitError, DopeError) + assert issubclass(DocumentError, DopeError) + assert issubclass(DirectoryError, DopeError) + assert issubclass(AgentError, DopeError) + + +def test_config_not_found_error_without_paths(): + """Test ConfigNotFoundError without search paths.""" + error = ConfigNotFoundError() + assert error.search_paths is None + assert str(error) == "Configuration file not found" + + +def test_config_not_found_error_with_search_paths(): + """Test ConfigNotFoundError with search paths.""" + paths = ["/path/1", "/path/2"] + error = ConfigNotFoundError(search_paths=paths) + assert error.search_paths == paths + assert "/path/1" in str(error) + assert "/path/2" in str(error) + + +def test_invalid_config_error(): + """Test InvalidConfigError includes path and reason.""" + error = InvalidConfigError("/config.yaml", "Missing required field 'api_key'") + assert error.config_path == "/config.yaml" + assert error.reason == "Missing required field 'api_key'" + assert "/config.yaml" in str(error) + assert "Missing required field" in str(error) + + +def test_git_repository_not_found_error(): + """Test GitRepositoryNotFoundError includes path.""" + error = GitRepositoryNotFoundError("/not/a/repo") + assert error.path == "/not/a/repo" + assert "/not/a/repo" in str(error) + + +def test_git_branch_not_found_error_without_available(): + """Test GitBranchNotFoundError without available branches.""" + error = GitBranchNotFoundError("feature") + assert error.branch == "feature" + assert error.available_branches is None + assert "feature" in str(error) + + +def test_git_branch_not_found_error_with_available_branches(): + """Test GitBranchNotFoundError shows available branches.""" + branches = ["main", "dev", "staging"] + error = GitBranchNotFoundError("feature", available_branches=branches) + assert error.branch == "feature" + assert error.available_branches == branches + assert "main" in str(error) + assert "dev" in str(error) + + +def test_git_branch_not_found_error_truncates_long_list(): + """Test GitBranchNotFoundError truncates long branch lists.""" + branches = [f"branch-{i}" for i in range(10)] + error = GitBranchNotFoundError("feature", available_branches=branches) + error_str = str(error) + # Should show first 5 and indicate more + assert "branch-0" in error_str + assert "branch-4" in error_str + assert "5 more" in error_str + + +def test_document_not_found_error(): + """Test DocumentNotFoundError includes document path.""" + error = DocumentNotFoundError("/docs/missing.md") + assert error.doc_path == "/docs/missing.md" + assert "/docs/missing.md" in str(error) + + +def test_invalid_directory_error_default_reason(): + """Test InvalidDirectoryError with default reason.""" + error = InvalidDirectoryError("/not/a/dir") + assert error.path == "/not/a/dir" + assert error.reason == "Not a directory" + assert "/not/a/dir" in str(error) + + +def test_invalid_directory_error_custom_reason(): + """Test InvalidDirectoryError with custom reason.""" + error = InvalidDirectoryError("/path", "Directory is empty") + assert error.path == "/path" + assert error.reason == "Directory is empty" + assert "Directory is empty" in str(error) + + +def test_agent_not_configured_error_default_message(): + """Test AgentNotConfiguredError with default message.""" + error = AgentNotConfiguredError() + assert "Agent configuration not found" in str(error) + assert "dope config init" in str(error) + + +def test_agent_not_configured_error_custom_message(): + """Test AgentNotConfiguredError with custom message.""" + custom_msg = "Custom agent error message" + error = AgentNotConfiguredError(custom_msg) + assert str(error) == custom_msg + + +def test_provider_error(): + """Test ProviderError includes provider and reason.""" + error = ProviderError("openai", "Invalid API key") + assert error.provider == "openai" + assert error.reason == "Invalid API key" + assert "openai" in str(error) + assert "Invalid API key" in str(error) + + +def test_invalid_suffix_error_default_reason(): + """Test InvalidSuffixError with default reason.""" + error = InvalidSuffixError(".bad") + assert error.suffix == ".bad" + assert error.reason == "Invalid suffix" + assert ".bad" in str(error) + + +def test_invalid_suffix_error_custom_reason(): + """Test InvalidSuffixError with custom reason.""" + error = InvalidSuffixError(".xyz", "Suffix must be .md or .txt") + assert error.suffix == ".xyz" + assert error.reason == "Suffix must be .md or .txt" + assert ".xyz" in str(error) + assert "must be .md or .txt" in str(error) + + +def test_all_exceptions_catchable_as_dope_error(): + """Test all exceptions inherit from DopeError.""" + exceptions = [ + ConfigNotFoundError(), + InvalidConfigError("path", "reason"), + GitRepositoryNotFoundError("/path"), + GitBranchNotFoundError("branch"), + DocumentNotFoundError("/doc.md"), + InvalidDirectoryError("/dir"), + AgentNotConfiguredError(), + ProviderError("provider", "reason"), + InvalidSuffixError(".bad"), + ] + + for exc in exceptions: + assert isinstance(exc, DopeError) + + +def test_exception_hierarchy(): + """Test exception inheritance hierarchy is correct.""" + # Configuration exceptions + assert issubclass(ConfigNotFoundError, ConfigurationError) + assert issubclass(InvalidConfigError, ConfigurationError) + assert issubclass(ConfigurationError, DopeError) + + # Git exceptions + assert issubclass(GitRepositoryNotFoundError, GitError) + assert issubclass(GitBranchNotFoundError, GitError) + assert issubclass(GitError, DopeError) + + # Document exceptions + assert issubclass(DocumentNotFoundError, DocumentError) + assert issubclass(DocumentError, DopeError) + + # Directory exceptions + assert issubclass(InvalidDirectoryError, DirectoryError) + assert issubclass(DirectoryError, DopeError) + + # Agent exceptions + assert issubclass(AgentNotConfiguredError, AgentError) + assert issubclass(ProviderError, AgentError) + assert issubclass(AgentError, DopeError) + + # Other exceptions + assert issubclass(InvalidSuffixError, DopeError)