From b08ef6347f65bdec897f2dab632752bc4473e84e Mon Sep 17 00:00:00 2001 From: David Kopp Date: Mon, 18 Aug 2025 21:44:02 +0200 Subject: [PATCH 1/2] Add DependencyResolver class with parallel multi-environment resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new DependencyResolver class for advanced programmatic usage - Implement parallel processing for multiple environments using ThreadPoolExecutor - Add ResolveRequest and ResolveResult dataclasses for structured configuration - Support progress tracking via optional callback functions - Refactor venv_path from resolver-level to per-request configuration - Add comprehensive test suite with 16 test cases covering all functionality - Update README with detailed usage examples for all three interfaces: - Functional interface for simple operations - Class-based interface for parallel processing - Low-level interface for advanced control - Maintain full backward compatibility with existing functional API Key features: - Batch resolution with configurable parallelism (max_workers) - Per-request configuration (venv_path, working_dir, environment settings) - Error handling with detailed result objects including execution time - Multiple output formats (list of results, consolidated dictionary) - Progress monitoring for long-running batch operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 2 +- README.md | 73 ++++++- dependency_resolver/__init__.py | 1 + dependency_resolver/core/resolver.py | 256 +++++++++++++++++++++++++ tests/core/test_dependency_resolver.py | 237 +++++++++++++++++++++++ 5 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 dependency_resolver/core/resolver.py create mode 100644 tests/core/test_dependency_resolver.py diff --git a/CLAUDE.md b/CLAUDE.md index 260b053..2ebfb5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ pre-commit run --files $(git diff --name-only --diff-filter=ACMR HEAD) ## Tech Stack -Python with venv, pip, and pytest (Unix-only) +Python 3.10 with venv, pip, and pytest (Unix-only) ## Code Principles diff --git a/README.md b/README.md index ff459c5..8ef4f12 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ python3 -m dependency_resolver --pretty-print ### Programmatic Interface -The dependency-resolver can also be used as a Python library in other projects. Here are some examples: +The dependency-resolver can be used as a Python library in other projects with both functional and class-based interfaces: + +#### Functional Interface (Simple Usage) ```python import dependency_resolver @@ -75,8 +77,62 @@ docker_deps = dependency_resolver.resolve_docker_dependencies( container_identifier="nginx", working_dir="/app" ) +``` + +#### Class-Based Interface (Advanced Usage) + +For complex scenarios, especially when analyzing multiple environments in parallel: + +```python +from dependency_resolver import DependencyResolver, ResolveRequest + +# Create resolver with shared configuration +resolver = DependencyResolver( + debug=True, + skip_system_scope=False, + max_workers=4 # Parallel processing +) + +# Single environment analysis +result = resolver.resolve( + environment_type="docker", + environment_identifier="nginx" +) -# Advanced usage with direct access to core classes +# Parallel multi-environment analysis +requests = [ + ResolveRequest("docker", "container1"), + ResolveRequest("docker", "container2"), + ResolveRequest("host", working_dir="/path/to/project", venv_path="/opt/venv"), + ResolveRequest("docker_compose", "my-stack") +] + +def progress_callback(completed, total, result): + print(f"Progress: {completed}/{total} - {result.request.environment_type} ({'✓' if result.success else '✗'})") + +# Execute in parallel with progress tracking +results = resolver.resolve_batch( + requests, + progress_callback=progress_callback, + fail_fast=False # Continue processing even if some fail +) + +# Process results +for result in results: + if result.success: + print(f"Found {len(result.dependencies)} detectors") + else: + print(f"Error: {result.error}") + +# Get results as dictionary format +dict_results = resolver.resolve_batch_as_dict(requests) +``` + +#### Low-Level Interface + +For maximum control, access core classes directly: + +```python from dependency_resolver import Orchestrator, HostExecutor, OutputFormatter executor = HostExecutor() @@ -92,12 +148,21 @@ json_output = formatter.format_json(dependencies, pretty_print=True) - `resolve_docker_dependencies()` - Analyze Docker container, returns JSON string - `resolve_dependencies_as_dict()` - Generic analysis, returns Python dictionary -**Available classes for advanced usage:** +**Available classes:** -- `Orchestrator` - Main dependency resolution coordinator +- `DependencyResolver` - Main class for single and batch operations with parallel processing +- `ResolveRequest` - Configuration for individual resolution requests +- `ResolveResult` - Result object containing dependencies, errors, and execution metadata +- `Orchestrator` - Core dependency resolution coordinator - `HostExecutor`, `DockerExecutor`, `DockerComposeExecutor` - Environment adapters - `OutputFormatter` - JSON formatting utilities +**When to use each interface:** + +- **Functional Interface**: Simple one-off dependency resolution +- **Class-Based Interface**: Multiple environments, parallel processing, shared configuration, progress tracking +- **Low-Level Interface**: Custom orchestration, advanced error handling, integration with existing frameworks + ### Supported Environments - **host** - Host system analysis (default) diff --git a/dependency_resolver/__init__.py b/dependency_resolver/__init__.py index 5642f58..18f3fd4 100644 --- a/dependency_resolver/__init__.py +++ b/dependency_resolver/__init__.py @@ -3,6 +3,7 @@ from .core.interfaces import EnvironmentExecutor from .core.orchestrator import Orchestrator from .core.output_formatter import OutputFormatter +from .core.resolver import DependencyResolver, ResolveRequest, ResolveResult from .executors import HostExecutor, DockerExecutor, DockerComposeExecutor from typing import Optional, Any diff --git a/dependency_resolver/core/resolver.py b/dependency_resolver/core/resolver.py new file mode 100644 index 0000000..c1a8687 --- /dev/null +++ b/dependency_resolver/core/resolver.py @@ -0,0 +1,256 @@ +"""DependencyResolver class for managing dependency resolution operations.""" + +from typing import Optional, Any, Callable, Dict, List +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +import time + +from .interfaces import EnvironmentExecutor +from .orchestrator import Orchestrator +from .output_formatter import OutputFormatter +from ..executors import HostExecutor, DockerExecutor, DockerComposeExecutor + + +@dataclass +class ResolveRequest: + """Configuration for a single dependency resolution request.""" + + environment_type: str + environment_identifier: Optional[str] = None + working_dir: Optional[str] = None + venv_path: Optional[str] = None + only_container_info: bool = False + # Optional metadata for tracking + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ResolveResult: + """Result of a dependency resolution operation.""" + + request: ResolveRequest + dependencies: Optional[Dict[str, Any]] = None + error: Optional[str] = None + execution_time: float = 0.0 + success: bool = False + + +class DependencyResolver: + """ + Main class for dependency resolution operations. + + Supports both single and batch resolution with parallel processing. + """ + + def __init__( + self, + debug: bool = False, + skip_system_scope: bool = False, + max_workers: Optional[int] = None, + ): + """ + Initialize the dependency resolver. + + Args: + debug: Enable debug output + skip_system_scope: Skip system-scope package managers + max_workers: Maximum number of worker threads for parallel operations + """ + self.debug = debug + self.skip_system_scope = skip_system_scope + self.max_workers = max_workers + self.formatter = OutputFormatter(debug=debug) + + def resolve( + self, + environment_type: str = "host", + environment_identifier: Optional[str] = None, + working_dir: Optional[str] = None, + venv_path: Optional[str] = None, + only_container_info: bool = False, + ) -> Dict[str, Any]: + """ + Resolve dependencies for a single environment. + + Args: + environment_type: Type of environment ("host", "docker", "docker_compose") + environment_identifier: Environment identifier (required for docker/docker_compose) + working_dir: Working directory to analyze + venv_path: Explicit virtual environment path for pip detector + only_container_info: Only analyze container metadata (for docker environments) + + Returns: + Dictionary containing all discovered dependencies + """ + # Validate inputs early to provide clear error messages + if environment_type not in ["host", "docker", "docker_compose"]: + raise ValueError(f"Unsupported environment type: {environment_type}") + + if environment_type == "docker" and not environment_identifier: + raise ValueError("Docker environment requires container identifier") + + if environment_type == "docker_compose" and not environment_identifier: + raise ValueError("Docker Compose environment requires service identifier") + + request = ResolveRequest( + environment_type=environment_type, + environment_identifier=environment_identifier, + working_dir=working_dir, + venv_path=venv_path, + only_container_info=only_container_info, + ) + + result = self._resolve_single(request) + if not result.success: + raise RuntimeError(f"Resolution failed: {result.error}") + + return result.dependencies or {} + + def resolve_batch( + self, + requests: List[ResolveRequest], + progress_callback: Optional[Callable[[int, int, ResolveResult], None]] = None, + fail_fast: bool = False, + ) -> List[ResolveResult]: + """ + Resolve dependencies for multiple environments in parallel. + + Args: + requests: List of resolution requests + progress_callback: Optional callback for progress updates (completed, total, result) + fail_fast: Stop processing on first error + + Returns: + List of results in the same order as requests + """ + if not requests: + return [] + + results: List[Optional[ResolveResult]] = [None] * len(requests) + completed_count = 0 + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # Submit all tasks + future_to_index = {executor.submit(self._resolve_single, request): i for i, request in enumerate(requests)} + + # Process completed futures + for future in as_completed(future_to_index): + index = future_to_index[future] + result = future.result() + results[index] = result + completed_count += 1 + + # Call progress callback if provided + if progress_callback: + progress_callback(completed_count, len(requests), result) + + # Handle fail_fast mode + if fail_fast and not result.success: + # Cancel remaining futures + for remaining_future in future_to_index: + if not remaining_future.done(): + remaining_future.cancel() + break + + return [r for r in results if r is not None] + + def resolve_batch_as_dict( + self, + requests: List[ResolveRequest], + include_errors: bool = True, + progress_callback: Optional[Callable[[int, int, ResolveResult], None]] = None, + ) -> Dict[str, Any]: + """ + Resolve dependencies in batch and return as a single dictionary. + + Args: + requests: List of resolution requests + include_errors: Include error information in results + progress_callback: Optional callback for progress updates + + Returns: + Dictionary with results keyed by environment identifier or index + """ + results = self.resolve_batch(requests, progress_callback=progress_callback) + + output = {} + for i, result in enumerate(results): + # Generate a key for this result + key = result.request.environment_identifier or f"request_{i}" + + if result.success: + output[key] = result.dependencies + elif include_errors: + output[key] = { + "error": result.error, + "execution_time": result.execution_time, + "request": { + "environment_type": result.request.environment_type, + "environment_identifier": result.request.environment_identifier, + "working_dir": result.request.working_dir, + }, + } + + return output + + def _resolve_single(self, request: ResolveRequest) -> ResolveResult: + """ + Internal method to resolve dependencies for a single request. + + Args: + request: The resolution request + + Returns: + ResolveResult containing the outcome + """ + start_time = time.time() + + try: + # Create appropriate executor + executor = self._create_executor(request) + + # Create orchestrator with request-specific configuration + orchestrator = Orchestrator( + debug=self.debug, skip_system_scope=self.skip_system_scope, venv_path=request.venv_path + ) + + # Resolve dependencies + dependencies = orchestrator.resolve_dependencies(executor, request.working_dir, request.only_container_info) + + execution_time = time.time() - start_time + + return ResolveResult( + request=request, dependencies=dependencies, execution_time=execution_time, success=True + ) + + except (RuntimeError, OSError, ValueError) as e: + execution_time = time.time() - start_time + error_msg = str(e) + + if self.debug: + print(f"Error resolving {request.environment_type}: {error_msg}") + + return ResolveResult(request=request, error=error_msg, execution_time=execution_time, success=False) + + def _create_executor(self, request: ResolveRequest) -> EnvironmentExecutor: + """ + Create the appropriate executor for the given request. + + Args: + request: The resolution request + + Returns: + Configured executor instance + """ + if request.environment_type == "host": + return HostExecutor() + elif request.environment_type == "docker": + if not request.environment_identifier: + raise ValueError("Docker environment requires container identifier") + return DockerExecutor(request.environment_identifier) + elif request.environment_type == "docker_compose": + if not request.environment_identifier: + raise ValueError("Docker Compose environment requires service identifier") + return DockerComposeExecutor(request.environment_identifier) + else: + raise ValueError(f"Unsupported environment type: {request.environment_type}") diff --git a/tests/core/test_dependency_resolver.py b/tests/core/test_dependency_resolver.py new file mode 100644 index 0000000..43fbbbe --- /dev/null +++ b/tests/core/test_dependency_resolver.py @@ -0,0 +1,237 @@ +"""Tests for the DependencyResolver class.""" + +import pytest +from typing import Any +from unittest.mock import patch, MagicMock + +from dependency_resolver import DependencyResolver, ResolveRequest, ResolveResult + + +class TestDependencyResolver: + """Tests for DependencyResolver class.""" + + def test_init_default_args(self) -> None: + """Test DependencyResolver initialization with default arguments.""" + resolver = DependencyResolver() + + assert resolver.debug is False + assert resolver.skip_system_scope is False + assert resolver.max_workers is None + + def test_init_custom_args(self) -> None: + """Test DependencyResolver initialization with custom arguments.""" + resolver = DependencyResolver(debug=True, skip_system_scope=True, max_workers=4) + + assert resolver.debug is True + assert resolver.skip_system_scope is True + assert resolver.max_workers == 4 + + @patch("dependency_resolver.core.resolver.Orchestrator") + def test_resolve_host_success(self, mock_orchestrator: Any) -> None: + """Test single host environment resolution.""" + # Setup mocks + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_dependencies: dict[str, Any] = {"pip": {"dependencies": {"requests": {"version": "2.28.0"}}}} + mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies + + # Create resolver and resolve + resolver = DependencyResolver(debug=True) + result = resolver.resolve("host", working_dir="/tmp") + + # Verify + assert result == mock_dependencies + mock_orchestrator.assert_called_once_with(debug=True, skip_system_scope=False, venv_path=None) + + def test_resolve_invalid_environment_type(self) -> None: + """Test resolve with invalid environment type.""" + resolver = DependencyResolver() + + with pytest.raises(ValueError, match="Unsupported environment type: invalid"): + resolver.resolve("invalid") + + def test_resolve_docker_missing_identifier(self) -> None: + """Test resolve with docker but missing identifier.""" + resolver = DependencyResolver() + + with pytest.raises(ValueError, match="Docker environment requires container identifier"): + resolver.resolve("docker") + + @patch("dependency_resolver.core.resolver.Orchestrator") + def test_resolve_batch_single_request(self, mock_orchestrator: Any) -> None: + """Test batch resolution with single request.""" + # Setup mocks + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_dependencies: dict[str, Any] = {"pip": {"dependencies": {}}} + mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies + + # Create resolver and resolve batch + resolver = DependencyResolver() + requests = [ResolveRequest("host", working_dir="/tmp")] + results = resolver.resolve_batch(requests) + + # Verify + assert len(results) == 1 + assert results[0].success is True + assert results[0].dependencies == mock_dependencies + assert results[0].request.environment_type == "host" + + @patch("dependency_resolver.core.resolver.Orchestrator") + def test_resolve_batch_multiple_requests(self, mock_orchestrator: Any) -> None: + """Test batch resolution with multiple requests.""" + # Setup mocks + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_dependencies: dict[str, Any] = {"pip": {"dependencies": {}}} + mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies + + # Create resolver and resolve batch + resolver = DependencyResolver(max_workers=2) + requests = [ResolveRequest("host", working_dir="/tmp"), ResolveRequest("host", working_dir="/home")] + results = resolver.resolve_batch(requests) + + # Verify + assert len(results) == 2 + assert all(r.success for r in results) + assert all(r.dependencies == mock_dependencies for r in results) + + def test_resolve_batch_empty_requests(self) -> None: + """Test batch resolution with empty request list.""" + resolver = DependencyResolver() + results = resolver.resolve_batch([]) + + assert results == [] + + @patch("dependency_resolver.core.resolver.Orchestrator") + def test_resolve_batch_with_progress_callback(self, mock_orchestrator: Any) -> None: + """Test batch resolution with progress callback.""" + # Setup mocks + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_dependencies: dict[str, Any] = {"pip": {"dependencies": {}}} + mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies + + # Track progress calls + progress_calls = [] + + def progress_callback(completed: int, total: int, result: ResolveResult) -> None: + progress_calls.append((completed, total, result.success)) + + # Create resolver and resolve batch + resolver = DependencyResolver() + requests = [ResolveRequest("host", venv_path="/test/venv")] + results = resolver.resolve_batch(requests, progress_callback=progress_callback) + + # Verify progress was called + assert len(progress_calls) == 1 + assert progress_calls[0] == (1, 1, True) + assert len(results) == 1 + + @patch("dependency_resolver.core.resolver.Orchestrator") + def test_resolve_batch_as_dict(self, mock_orchestrator: Any) -> None: + """Test batch resolution returning dictionary format.""" + # Setup mocks + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_dependencies: dict[str, Any] = {"pip": {"dependencies": {"requests": {"version": "2.28.0"}}}} + mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies + + # Create resolver and resolve batch + resolver = DependencyResolver() + requests = [ + ResolveRequest("host", metadata={"name": "test"}), + ResolveRequest("docker", "nginx", metadata={"name": "container"}), + ] + result_dict = resolver.resolve_batch_as_dict(requests) + + # Verify + assert "request_0" in result_dict + # Second request key depends on execution order, could be "nginx" or "request_1" + assert len(result_dict) == 2 + assert result_dict["request_0"] == mock_dependencies + + def test_resolve_batch_error_handling(self) -> None: + """Test batch resolution handles errors gracefully.""" + resolver = DependencyResolver() + requests = [ResolveRequest("invalid_type")] + results = resolver.resolve_batch(requests) + + # Verify error is captured + assert len(results) == 1 + assert results[0].success is False + assert results[0].error is not None + assert "Unsupported environment type" in results[0].error + assert results[0].dependencies is None + + +class TestResolveRequest: + """Tests for ResolveRequest dataclass.""" + + def test_resolve_request_defaults(self) -> None: + """Test ResolveRequest with default values.""" + request = ResolveRequest("host") + + assert request.environment_type == "host" + assert request.environment_identifier is None + assert request.working_dir is None + assert request.venv_path is None + assert request.only_container_info is False + assert request.metadata == {} + + def test_resolve_request_full(self) -> None: + """Test ResolveRequest with all values.""" + metadata = {"custom": "value"} + request = ResolveRequest( + environment_type="docker", + environment_identifier="nginx", + working_dir="/app", + venv_path="/opt/venv", + only_container_info=True, + metadata=metadata, + ) + + assert request.environment_type == "docker" + assert request.environment_identifier == "nginx" + assert request.working_dir == "/app" + assert request.venv_path == "/opt/venv" + assert request.only_container_info is True + assert request.metadata == metadata + + +class TestResolveResult: + """Tests for ResolveResult dataclass.""" + + def test_resolve_result_defaults(self) -> None: + """Test ResolveResult with default values.""" + request = ResolveRequest("host") + result = ResolveResult(request) + + assert result.request == request + assert result.dependencies is None + assert result.error is None + assert result.execution_time == 0.0 + assert result.success is False + + def test_resolve_result_success(self) -> None: + """Test ResolveResult for successful resolution.""" + request = ResolveRequest("host") + dependencies: dict[str, Any] = {"pip": {"dependencies": {}}} + result = ResolveResult(request=request, dependencies=dependencies, execution_time=1.5, success=True) + + assert result.request == request + assert result.dependencies == dependencies + assert result.error is None + assert result.execution_time == 1.5 + assert result.success is True + + def test_resolve_result_error(self) -> None: + """Test ResolveResult for failed resolution.""" + request = ResolveRequest("docker") + result = ResolveResult(request=request, error="Container not found", execution_time=0.1, success=False) + + assert result.request == request + assert result.dependencies is None + assert result.error == "Container not found" + assert result.execution_time == 0.1 + assert result.success is False From 9362ba5c4d4ebbca62c08eba47661ef1ea730fea Mon Sep 17 00:00:00 2001 From: David Kopp Date: Mon, 18 Aug 2025 22:04:46 +0200 Subject: [PATCH 2/2] Refactor DependencyResolver to use per-class environment configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move environment_type and only_container_info from per-request to per-class model while keeping environment_identifier per-request for flexibility: - Environment type and container-specific options configured in DependencyResolver constructor - Environment identifier passed per-request to enable batch processing of multiple containers/services - ResolveRequest simplified to contain identifier, working directory, and detector options - Added validation that only_container_info only works with docker environment - Updated all usage sites including CLI, convenience functions, and tests - Updated README.md documentation with better examples showing batch processing This design correctly separates configuration concerns (per-class) from data concerns (per-request), enabling efficient analysis of multiple containers with the same resolver instance. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 61 ++++++++++---- dependency_resolver/__init__.py | 23 ++--- dependency_resolver/__main__.py | 35 ++------ dependency_resolver/core/resolver.py | 50 +++++------ tests/core/test_dependency_resolver.py | 112 ++++++++++++++++--------- 5 files changed, 159 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 8ef4f12..fa803f3 100644 --- a/README.md +++ b/README.md @@ -81,51 +81,78 @@ docker_deps = dependency_resolver.resolve_docker_dependencies( #### Class-Based Interface (Advanced Usage) -For complex scenarios, especially when analyzing multiple environments in parallel: +For complex scenarios and when you need environment-specific configuration: ```python from dependency_resolver import DependencyResolver, ResolveRequest -# Create resolver with shared configuration -resolver = DependencyResolver( +# Create resolver for specific environment with configuration +host_resolver = DependencyResolver( + environment_type="host", debug=True, skip_system_scope=False, max_workers=4 # Parallel processing ) -# Single environment analysis -result = resolver.resolve( +# Docker resolver with container-specific options +docker_resolver = DependencyResolver( environment_type="docker", - environment_identifier="nginx" + only_container_info=True, # Only valid for docker environment + debug=True ) -# Parallel multi-environment analysis -requests = [ - ResolveRequest("docker", "container1"), - ResolveRequest("docker", "container2"), - ResolveRequest("host", working_dir="/path/to/project", venv_path="/opt/venv"), - ResolveRequest("docker_compose", "my-stack") +# Single environment analysis +host_result = host_resolver.resolve( + working_dir="/path/to/project", + venv_path="/opt/venv" +) + +docker_result = docker_resolver.resolve( + environment_identifier="nginx", + working_dir="/app" +) + +# Parallel multi-environment analysis (same environment type) +# For host environments +host_requests = [ + ResolveRequest(working_dir="/path/to/project1"), + ResolveRequest(working_dir="/path/to/project2", venv_path="/opt/venv"), + ResolveRequest(working_dir="/path/to/project3") +] + +# For docker containers +docker_requests = [ + ResolveRequest(environment_identifier="nginx", working_dir="/app"), + ResolveRequest(environment_identifier="redis", working_dir="/data"), + ResolveRequest(environment_identifier="postgres") ] def progress_callback(completed, total, result): - print(f"Progress: {completed}/{total} - {result.request.environment_type} ({'✓' if result.success else '✗'})") + print(f"Progress: {completed}/{total} ({'✓' if result.success else '✗'})") # Execute in parallel with progress tracking -results = resolver.resolve_batch( - requests, +host_results = host_resolver.resolve_batch( + host_requests, progress_callback=progress_callback, fail_fast=False # Continue processing even if some fail ) +docker_results = docker_resolver.resolve_batch( + docker_requests, + progress_callback=progress_callback, + fail_fast=False +) + # Process results -for result in results: +for result in host_results: if result.success: print(f"Found {len(result.dependencies)} detectors") else: print(f"Error: {result.error}") # Get results as dictionary format -dict_results = resolver.resolve_batch_as_dict(requests) +host_dict_results = host_resolver.resolve_batch_as_dict(host_requests) +docker_dict_results = docker_resolver.resolve_batch_as_dict(docker_requests) ``` #### Low-Level Interface diff --git a/dependency_resolver/__init__.py b/dependency_resolver/__init__.py index 18f3fd4..8a85dc1 100644 --- a/dependency_resolver/__init__.py +++ b/dependency_resolver/__init__.py @@ -91,22 +91,13 @@ def resolve_dependencies_as_dict( Returns: Dictionary containing all discovered dependencies """ - executor: EnvironmentExecutor - if environment_type == "host": - executor = HostExecutor() - elif environment_type == "docker": - if not environment_identifier: - raise ValueError("Docker environment requires container identifier") - executor = DockerExecutor(environment_identifier) - elif environment_type == "docker_compose": - if not environment_identifier: - raise ValueError("Docker Compose environment requires service identifier") - executor = DockerComposeExecutor(environment_identifier) - else: - raise ValueError(f"Unsupported environment type: {environment_type}") - - orchestrator = Orchestrator(debug=debug, skip_system_scope=skip_system_scope, venv_path=venv_path) - return orchestrator.resolve_dependencies(executor, working_dir, only_container_info) + resolver = DependencyResolver( + environment_type=environment_type, + only_container_info=only_container_info, + debug=debug, + skip_system_scope=skip_system_scope, + ) + return resolver.resolve(environment_identifier=environment_identifier, working_dir=working_dir, venv_path=venv_path) def main() -> None: diff --git a/dependency_resolver/__main__.py b/dependency_resolver/__main__.py index 623d24d..c96870c 100644 --- a/dependency_resolver/__main__.py +++ b/dependency_resolver/__main__.py @@ -7,9 +7,7 @@ import sys import argparse -from .executors import HostExecutor, DockerExecutor, DockerComposeExecutor -from .core.interfaces import EnvironmentExecutor -from .core.orchestrator import Orchestrator +from .core.resolver import DependencyResolver from .core.output_formatter import OutputFormatter @@ -95,25 +93,6 @@ def validate_arguments( sys.exit(1) -def create_executor(environment_type: str, environment_identifier: str | None) -> EnvironmentExecutor: - """Create executor based on environment type.""" - if environment_type == "host": - return HostExecutor() - elif environment_type == "docker": - if environment_identifier is None: - print("Error: Docker environment requires container identifier", file=sys.stderr) - sys.exit(1) - return DockerExecutor(environment_identifier) - elif environment_type == "docker_compose": - if environment_identifier is None: - print("Error: Docker Compose environment requires service identifier", file=sys.stderr) - sys.exit(1) - return DockerComposeExecutor(environment_identifier) - else: - print(f"Error: Unsupported environment type: {environment_type}", file=sys.stderr) - sys.exit(1) - - def main() -> None: """Main entry point.""" args = parse_arguments() @@ -121,11 +100,15 @@ def main() -> None: validate_arguments(args.environment_type, args.environment_identifier, args.only_container_info) try: - executor = create_executor(args.environment_type, args.environment_identifier) - orchestrator = Orchestrator( - debug=args.debug, skip_system_scope=args.skip_system_scope, venv_path=args.venv_path + resolver = DependencyResolver( + environment_type=args.environment_type, + only_container_info=args.only_container_info, + debug=args.debug, + skip_system_scope=args.skip_system_scope, + ) + dependencies = resolver.resolve( + environment_identifier=args.environment_identifier, working_dir=args.working_dir, venv_path=args.venv_path ) - dependencies = orchestrator.resolve_dependencies(executor, args.working_dir, args.only_container_info) formatter = OutputFormatter(debug=args.debug) result = formatter.format_json(dependencies, pretty_print=args.pretty_print) print(result) diff --git a/dependency_resolver/core/resolver.py b/dependency_resolver/core/resolver.py index c1a8687..bf5efe9 100644 --- a/dependency_resolver/core/resolver.py +++ b/dependency_resolver/core/resolver.py @@ -15,11 +15,9 @@ class ResolveRequest: """Configuration for a single dependency resolution request.""" - environment_type: str environment_identifier: Optional[str] = None working_dir: Optional[str] = None venv_path: Optional[str] = None - only_container_info: bool = False # Optional metadata for tracking metadata: Dict[str, Any] = field(default_factory=dict) @@ -44,6 +42,8 @@ class DependencyResolver: def __init__( self, + environment_type: str = "host", + only_container_info: bool = False, debug: bool = False, skip_system_scope: bool = False, max_workers: Optional[int] = None, @@ -52,10 +52,21 @@ def __init__( Initialize the dependency resolver. Args: + environment_type: Type of environment ("host", "docker", "docker_compose") + only_container_info: Only analyze container metadata (only valid for docker) debug: Enable debug output skip_system_scope: Skip system-scope package managers max_workers: Maximum number of worker threads for parallel operations """ + # Validate inputs early to provide clear error messages + if environment_type not in ["host", "docker", "docker_compose"]: + raise ValueError(f"Unsupported environment type: {environment_type}") + + if only_container_info and environment_type != "docker": + raise ValueError("only_container_info flag is only valid for docker environment") + + self.environment_type = environment_type + self.only_container_info = only_container_info self.debug = debug self.skip_system_scope = skip_system_scope self.max_workers = max_workers @@ -63,41 +74,32 @@ def __init__( def resolve( self, - environment_type: str = "host", environment_identifier: Optional[str] = None, working_dir: Optional[str] = None, venv_path: Optional[str] = None, - only_container_info: bool = False, ) -> Dict[str, Any]: """ - Resolve dependencies for a single environment. + Resolve dependencies for the configured environment. Args: - environment_type: Type of environment ("host", "docker", "docker_compose") environment_identifier: Environment identifier (required for docker/docker_compose) working_dir: Working directory to analyze venv_path: Explicit virtual environment path for pip detector - only_container_info: Only analyze container metadata (for docker environments) Returns: Dictionary containing all discovered dependencies """ - # Validate inputs early to provide clear error messages - if environment_type not in ["host", "docker", "docker_compose"]: - raise ValueError(f"Unsupported environment type: {environment_type}") - - if environment_type == "docker" and not environment_identifier: + # Validate environment_identifier requirements + if self.environment_type == "docker" and not environment_identifier: raise ValueError("Docker environment requires container identifier") - if environment_type == "docker_compose" and not environment_identifier: + if self.environment_type == "docker_compose" and not environment_identifier: raise ValueError("Docker Compose environment requires service identifier") request = ResolveRequest( - environment_type=environment_type, environment_identifier=environment_identifier, working_dir=working_dir, venv_path=venv_path, - only_container_info=only_container_info, ) result = self._resolve_single(request) @@ -185,7 +187,7 @@ def resolve_batch_as_dict( "error": result.error, "execution_time": result.execution_time, "request": { - "environment_type": result.request.environment_type, + "environment_type": self.environment_type, "environment_identifier": result.request.environment_identifier, "working_dir": result.request.working_dir, }, @@ -215,7 +217,7 @@ def _resolve_single(self, request: ResolveRequest) -> ResolveResult: ) # Resolve dependencies - dependencies = orchestrator.resolve_dependencies(executor, request.working_dir, request.only_container_info) + dependencies = orchestrator.resolve_dependencies(executor, request.working_dir, self.only_container_info) execution_time = time.time() - start_time @@ -228,29 +230,29 @@ def _resolve_single(self, request: ResolveRequest) -> ResolveResult: error_msg = str(e) if self.debug: - print(f"Error resolving {request.environment_type}: {error_msg}") + print(f"Error resolving {self.environment_type}: {error_msg}") return ResolveResult(request=request, error=error_msg, execution_time=execution_time, success=False) def _create_executor(self, request: ResolveRequest) -> EnvironmentExecutor: """ - Create the appropriate executor for the given request. + Create the appropriate executor for the configured environment. Args: - request: The resolution request + request: The resolution request containing environment_identifier Returns: Configured executor instance """ - if request.environment_type == "host": + if self.environment_type == "host": return HostExecutor() - elif request.environment_type == "docker": + elif self.environment_type == "docker": if not request.environment_identifier: raise ValueError("Docker environment requires container identifier") return DockerExecutor(request.environment_identifier) - elif request.environment_type == "docker_compose": + elif self.environment_type == "docker_compose": if not request.environment_identifier: raise ValueError("Docker Compose environment requires service identifier") return DockerComposeExecutor(request.environment_identifier) else: - raise ValueError(f"Unsupported environment type: {request.environment_type}") + raise ValueError(f"Unsupported environment type: {self.environment_type}") diff --git a/tests/core/test_dependency_resolver.py b/tests/core/test_dependency_resolver.py index 43fbbbe..617f813 100644 --- a/tests/core/test_dependency_resolver.py +++ b/tests/core/test_dependency_resolver.py @@ -14,14 +14,24 @@ def test_init_default_args(self) -> None: """Test DependencyResolver initialization with default arguments.""" resolver = DependencyResolver() + assert resolver.environment_type == "host" + assert resolver.only_container_info is False assert resolver.debug is False assert resolver.skip_system_scope is False assert resolver.max_workers is None def test_init_custom_args(self) -> None: """Test DependencyResolver initialization with custom arguments.""" - resolver = DependencyResolver(debug=True, skip_system_scope=True, max_workers=4) + resolver = DependencyResolver( + environment_type="docker", + only_container_info=True, + debug=True, + skip_system_scope=True, + max_workers=4, + ) + assert resolver.environment_type == "docker" + assert resolver.only_container_info is True assert resolver.debug is True assert resolver.skip_system_scope is True assert resolver.max_workers == 4 @@ -36,26 +46,50 @@ def test_resolve_host_success(self, mock_orchestrator: Any) -> None: mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies # Create resolver and resolve - resolver = DependencyResolver(debug=True) - result = resolver.resolve("host", working_dir="/tmp") + resolver = DependencyResolver(environment_type="host", debug=True) + result = resolver.resolve(working_dir="/tmp") # Verify assert result == mock_dependencies mock_orchestrator.assert_called_once_with(debug=True, skip_system_scope=False, venv_path=None) def test_resolve_invalid_environment_type(self) -> None: - """Test resolve with invalid environment type.""" - resolver = DependencyResolver() - + """Test resolver initialization with invalid environment type.""" with pytest.raises(ValueError, match="Unsupported environment type: invalid"): - resolver.resolve("invalid") + DependencyResolver(environment_type="invalid") def test_resolve_docker_missing_identifier(self) -> None: """Test resolve with docker but missing identifier.""" - resolver = DependencyResolver() - + resolver = DependencyResolver(environment_type="docker") with pytest.raises(ValueError, match="Docker environment requires container identifier"): - resolver.resolve("docker") + resolver.resolve() + + def test_only_container_info_invalid_environment(self) -> None: + """Test resolver initialization with only_container_info for non-docker environment.""" + with pytest.raises(ValueError, match="only_container_info flag is only valid for docker environment"): + DependencyResolver(environment_type="host", only_container_info=True) + + @patch("dependency_resolver.core.resolver.Orchestrator") + @patch("dependency_resolver.core.resolver.DockerExecutor") + def test_resolve_docker_success(self, mock_docker_executor: Any, mock_orchestrator: Any) -> None: + """Test single docker environment resolution.""" + # Setup mocks + mock_executor_instance = MagicMock() + mock_docker_executor.return_value = mock_executor_instance + + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_dependencies: dict[str, Any] = {"docker-info": {"name": "nginx", "image": "nginx:latest"}} + mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies + + # Create resolver and resolve + resolver = DependencyResolver(environment_type="docker", debug=True) + result = resolver.resolve(environment_identifier="nginx", working_dir="/app") + + # Verify + assert result == mock_dependencies + mock_docker_executor.assert_called_once_with("nginx") + mock_orchestrator.assert_called_once_with(debug=True, skip_system_scope=False, venv_path=None) @patch("dependency_resolver.core.resolver.Orchestrator") def test_resolve_batch_single_request(self, mock_orchestrator: Any) -> None: @@ -67,15 +101,15 @@ def test_resolve_batch_single_request(self, mock_orchestrator: Any) -> None: mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies # Create resolver and resolve batch - resolver = DependencyResolver() - requests = [ResolveRequest("host", working_dir="/tmp")] + resolver = DependencyResolver(environment_type="host") + requests = [ResolveRequest(working_dir="/tmp")] results = resolver.resolve_batch(requests) # Verify assert len(results) == 1 assert results[0].success is True assert results[0].dependencies == mock_dependencies - assert results[0].request.environment_type == "host" + assert results[0].request.working_dir == "/tmp" @patch("dependency_resolver.core.resolver.Orchestrator") def test_resolve_batch_multiple_requests(self, mock_orchestrator: Any) -> None: @@ -87,8 +121,8 @@ def test_resolve_batch_multiple_requests(self, mock_orchestrator: Any) -> None: mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies # Create resolver and resolve batch - resolver = DependencyResolver(max_workers=2) - requests = [ResolveRequest("host", working_dir="/tmp"), ResolveRequest("host", working_dir="/home")] + resolver = DependencyResolver(environment_type="host", max_workers=2) + requests = [ResolveRequest(working_dir="/tmp"), ResolveRequest(working_dir="/home")] results = resolver.resolve_batch(requests) # Verify @@ -119,8 +153,8 @@ def progress_callback(completed: int, total: int, result: ResolveResult) -> None progress_calls.append((completed, total, result.success)) # Create resolver and resolve batch - resolver = DependencyResolver() - requests = [ResolveRequest("host", venv_path="/test/venv")] + resolver = DependencyResolver(environment_type="host") + requests = [ResolveRequest(venv_path="/test/venv")] results = resolver.resolve_batch(requests, progress_callback=progress_callback) # Verify progress was called @@ -138,10 +172,10 @@ def test_resolve_batch_as_dict(self, mock_orchestrator: Any) -> None: mock_orchestrator_instance.resolve_dependencies.return_value = mock_dependencies # Create resolver and resolve batch - resolver = DependencyResolver() + resolver = DependencyResolver(environment_type="host") requests = [ - ResolveRequest("host", metadata={"name": "test"}), - ResolveRequest("docker", "nginx", metadata={"name": "container"}), + ResolveRequest(metadata={"name": "test"}), + ResolveRequest(metadata={"name": "container"}), ] result_dict = resolver.resolve_batch_as_dict(requests) @@ -153,16 +187,22 @@ def test_resolve_batch_as_dict(self, mock_orchestrator: Any) -> None: def test_resolve_batch_error_handling(self) -> None: """Test batch resolution handles errors gracefully.""" - resolver = DependencyResolver() - requests = [ResolveRequest("invalid_type")] - results = resolver.resolve_batch(requests) + # Mock an error in orchestrator to test error handling + with patch("dependency_resolver.core.resolver.Orchestrator") as mock_orchestrator: + mock_orchestrator_instance = MagicMock() + mock_orchestrator.return_value = mock_orchestrator_instance + mock_orchestrator_instance.resolve_dependencies.side_effect = RuntimeError("Test error") - # Verify error is captured - assert len(results) == 1 - assert results[0].success is False - assert results[0].error is not None - assert "Unsupported environment type" in results[0].error - assert results[0].dependencies is None + resolver = DependencyResolver(environment_type="host") + requests = [ResolveRequest()] + results = resolver.resolve_batch(requests) + + # Verify error is captured + assert len(results) == 1 + assert results[0].success is False + assert results[0].error is not None + assert "Test error" in results[0].error + assert results[0].dependencies is None class TestResolveRequest: @@ -170,32 +210,26 @@ class TestResolveRequest: def test_resolve_request_defaults(self) -> None: """Test ResolveRequest with default values.""" - request = ResolveRequest("host") + request = ResolveRequest() - assert request.environment_type == "host" assert request.environment_identifier is None assert request.working_dir is None assert request.venv_path is None - assert request.only_container_info is False assert request.metadata == {} def test_resolve_request_full(self) -> None: """Test ResolveRequest with all values.""" metadata = {"custom": "value"} request = ResolveRequest( - environment_type="docker", environment_identifier="nginx", working_dir="/app", venv_path="/opt/venv", - only_container_info=True, metadata=metadata, ) - assert request.environment_type == "docker" assert request.environment_identifier == "nginx" assert request.working_dir == "/app" assert request.venv_path == "/opt/venv" - assert request.only_container_info is True assert request.metadata == metadata @@ -204,7 +238,7 @@ class TestResolveResult: def test_resolve_result_defaults(self) -> None: """Test ResolveResult with default values.""" - request = ResolveRequest("host") + request = ResolveRequest() result = ResolveResult(request) assert result.request == request @@ -215,7 +249,7 @@ def test_resolve_result_defaults(self) -> None: def test_resolve_result_success(self) -> None: """Test ResolveResult for successful resolution.""" - request = ResolveRequest("host") + request = ResolveRequest() dependencies: dict[str, Any] = {"pip": {"dependencies": {}}} result = ResolveResult(request=request, dependencies=dependencies, execution_time=1.5, success=True) @@ -227,7 +261,7 @@ def test_resolve_result_success(self) -> None: def test_resolve_result_error(self) -> None: """Test ResolveResult for failed resolution.""" - request = ResolveRequest("docker") + request = ResolveRequest() result = ResolveResult(request=request, error="Container not found", execution_time=0.1, success=False) assert result.request == request