diff --git a/forklet/__main__.py b/forklet/__main__.py index d1a2f0d..04c7d57 100644 --- a/forklet/__main__.py +++ b/forklet/__main__.py @@ -145,6 +145,36 @@ def info(ctx, repository: str, ref: str): sys.exit(1) +@cli.command() +def status(): + """Show current download status and progress""" + + try: + app = ForkletCLI() + app.initialize_services() + + # Check if there's an active download + progress = app.orchestrator.get_current_progress() if hasattr(app, 'orchestrator') else None + + if progress is None: + click.echo("📊 No active downloads") + else: + click.echo("📊 Current Download Status:") + click.echo(f" 📁 Files: {progress.downloaded_files}/{progress.total_files}") + click.echo(f" 📊 Progress: {progress.progress_percentage:.1f}%") + click.echo(f" 💾 Downloaded: {progress.downloaded_bytes}/{progress.total_bytes} bytes") + if progress.current_file: + click.echo(f" 📄 Current file: {progress.current_file}") + if progress.download_speed > 0: + click.echo(f" ⚡ Speed: {progress.download_speed:.2f} bytes/sec") + if progress.eta_seconds: + click.echo(f" ⏱️ ETA: {progress.eta_seconds:.0f} seconds") + + except Exception as e: + click.echo(f"❌ Error: {e}", err=True) + sys.exit(1) + + @cli.command() def version(): """Print Forklet version""" diff --git a/forklet/interfaces/api.py b/forklet/interfaces/api.py index 0c69071..f01f212 100644 --- a/forklet/interfaces/api.py +++ b/forklet/interfaces/api.py @@ -271,30 +271,70 @@ async def get_rate_limit_info(self) -> Dict[str, Any]: return await self.github_service.get_rate_limit_info() - async def cancel_current_download(self) -> None: - """Cancel the currently running download operation.""" - - await self.orchestrator.cancel() + def cancel_current_download(self) -> Optional[DownloadResult]: + """ + Cancel the currently running download operation. + + Returns: + DownloadResult marked as cancelled, or None if no active download + + Example: + >>> downloader = GitHubDownloader() + >>> # Start download in background... + >>> result = downloader.cancel_current_download() + >>> if result: + ... print(f"Download cancelled: {result.status}") + """ + return self.orchestrator.cancel() - async def pause_current_download(self) -> None: - """Pause the currently running download operation.""" - - await self.orchestrator.pause() + async def pause_current_download(self) -> Optional[DownloadResult]: + """ + Pause the currently running download operation. + + Returns: + DownloadResult marked as paused, or None if no active download + + Example: + >>> downloader = GitHubDownloader() + >>> # Start download in background... + >>> result = await downloader.pause_current_download() + >>> if result: + ... print(f"Download paused: {result.status}") + """ + return await self.orchestrator.pause() - async def resume_current_download(self) -> None: - """Resume a paused download operation.""" - - await self.orchestrator.resume() + async def resume_current_download(self) -> Optional[DownloadResult]: + """ + Resume a paused download operation. + + Returns: + DownloadResult marked as resuming, or None if no paused download + + Example: + >>> downloader = GitHubDownloader() + >>> # After pausing a download... + >>> result = await downloader.resume_current_download() + >>> if result: + ... print(f"Download resumed: {result.status}") + """ + return await self.orchestrator.resume() - async def get_download_progress(self) -> Optional[ProgressInfo]: + def get_download_progress(self) -> Optional[ProgressInfo]: """ Get progress information for the current download. Returns: ProgressInfo object, or None if no download in progress + + Example: + >>> downloader = GitHubDownloader() + >>> # During an active download... + >>> progress = downloader.get_download_progress() + >>> if progress: + ... print(f"Progress: {progress.progress_percentage:.1f}%") + ... print(f"Files: {progress.downloaded_files}/{progress.total_files}") """ - - return await self.orchestrator.get_current_progress() + return self.orchestrator.get_current_progress() def set_verbose(self, verbose: bool) -> None: """ diff --git a/tests/interfaces/test_download_control.py b/tests/interfaces/test_download_control.py new file mode 100644 index 0000000..7dde253 --- /dev/null +++ b/tests/interfaces/test_download_control.py @@ -0,0 +1,167 @@ +""" +Unit tests for download control functionality in GitHubDownloader API. +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from forklet.interfaces.api import GitHubDownloader +from forklet.models import DownloadResult, DownloadStatus, ProgressInfo + + +class TestDownloadControl: + """Test cases for download control functionality.""" + + def test_cancel_current_download_success(self): + """Test successful cancellation of current download.""" + # Create downloader + downloader = GitHubDownloader() + + # Mock the orchestrator's cancel method + mock_result = Mock(spec=DownloadResult) + mock_result.status = DownloadStatus.CANCELLED + downloader.orchestrator.cancel = Mock(return_value=mock_result) + + # Call cancel + result = downloader.cancel_current_download() + + # Verify + assert result == mock_result + assert result.status == DownloadStatus.CANCELLED + downloader.orchestrator.cancel.assert_called_once() + + def test_cancel_current_download_no_active(self): + """Test cancellation when no download is active.""" + downloader = GitHubDownloader() + downloader.orchestrator.cancel = Mock(return_value=None) + + result = downloader.cancel_current_download() + + assert result is None + downloader.orchestrator.cancel.assert_called_once() + + @pytest.mark.asyncio + async def test_pause_current_download_success(self): + """Test successful pausing of current download.""" + downloader = GitHubDownloader() + + # Mock the orchestrator's pause method + mock_result = Mock(spec=DownloadResult) + mock_result.status = DownloadStatus.PAUSED + downloader.orchestrator.pause = AsyncMock(return_value=mock_result) + + # Call pause + result = await downloader.pause_current_download() + + # Verify + assert result == mock_result + assert result.status == DownloadStatus.PAUSED + downloader.orchestrator.pause.assert_called_once() + + @pytest.mark.asyncio + async def test_pause_current_download_no_active(self): + """Test pausing when no download is active.""" + downloader = GitHubDownloader() + downloader.orchestrator.pause = AsyncMock(return_value=None) + + result = await downloader.pause_current_download() + + assert result is None + downloader.orchestrator.pause.assert_called_once() + + @pytest.mark.asyncio + async def test_resume_current_download_success(self): + """Test successful resuming of paused download.""" + downloader = GitHubDownloader() + + # Mock the orchestrator's resume method + mock_result = Mock(spec=DownloadResult) + mock_result.status = DownloadStatus.IN_PROGRESS + downloader.orchestrator.resume = AsyncMock(return_value=mock_result) + + # Call resume + result = await downloader.resume_current_download() + + # Verify + assert result == mock_result + assert result.status == DownloadStatus.IN_PROGRESS + downloader.orchestrator.resume.assert_called_once() + + @pytest.mark.asyncio + async def test_resume_current_download_no_paused(self): + """Test resuming when no download is paused.""" + downloader = GitHubDownloader() + downloader.orchestrator.resume = AsyncMock(return_value=None) + + result = await downloader.resume_current_download() + + assert result is None + downloader.orchestrator.resume.assert_called_once() + + def test_get_download_progress_with_active_download(self): + """Test getting progress when download is active.""" + downloader = GitHubDownloader() + + # Mock progress info + mock_progress = Mock(spec=ProgressInfo) + mock_progress.total_files = 100 + mock_progress.downloaded_files = 50 + mock_progress.progress_percentage = 50.0 + + downloader.orchestrator.get_current_progress = Mock(return_value=mock_progress) + + # Call get_progress + result = downloader.get_download_progress() + + # Verify + assert result == mock_progress + assert result.progress_percentage == 50.0 + downloader.orchestrator.get_current_progress.assert_called_once() + + def test_get_download_progress_no_active_download(self): + """Test getting progress when no download is active.""" + downloader = GitHubDownloader() + downloader.orchestrator.get_current_progress = Mock(return_value=None) + + result = downloader.get_download_progress() + + assert result is None + downloader.orchestrator.get_current_progress.assert_called_once() + + @pytest.mark.asyncio + async def test_download_control_workflow(self): + """Test complete workflow: start -> pause -> resume -> cancel.""" + downloader = GitHubDownloader() + + # Mock orchestrator methods + paused_result = Mock(spec=DownloadResult) + paused_result.status = DownloadStatus.PAUSED + + resumed_result = Mock(spec=DownloadResult) + resumed_result.status = DownloadStatus.IN_PROGRESS + + cancelled_result = Mock(spec=DownloadResult) + cancelled_result.status = DownloadStatus.CANCELLED + + downloader.orchestrator.pause = AsyncMock(return_value=paused_result) + downloader.orchestrator.resume = AsyncMock(return_value=resumed_result) + downloader.orchestrator.cancel = Mock(return_value=cancelled_result) + + # Test workflow + # 1. Pause + result = await downloader.pause_current_download() + assert result.status == DownloadStatus.PAUSED + + # 2. Resume + result = await downloader.resume_current_download() + assert result.status == DownloadStatus.IN_PROGRESS + + # 3. Cancel + result = downloader.cancel_current_download() + assert result.status == DownloadStatus.CANCELLED + + # Verify all methods were called + downloader.orchestrator.pause.assert_called_once() + downloader.orchestrator.resume.assert_called_once() + downloader.orchestrator.cancel.assert_called_once() + +