Skip to content

Commit b5a6c86

Browse files
Add --dry-run flag to CLI and dry_run option to API (#61)
* Add dry-run Feature * Add verbose file-path listing and remove redundant main guard * Remove unncessary block
1 parent e8fc031 commit b5a6c86

File tree

5 files changed

+143
-15
lines changed

5 files changed

+143
-15
lines changed

forklet/__main__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def cli(ctx, verbose: bool, token: Optional[str]):
4747
help='Download strategy')
4848
@click.option('--concurrent', '-c', default=5, help='Concurrent downloads')
4949
@click.option('--overwrite', '-f', is_flag=True, help='Overwrite existing files')
50+
@click.option('--dry-run', '-n', is_flag=True, help='Preview files to download without writing')
5051
@click.pass_context
5152
def download(
5253
ctx,
@@ -65,7 +66,8 @@ def download(
6566
strategy: str,
6667
concurrent: int,
6768
overwrite: bool,
68-
no_progress: bool
69+
no_progress: bool,
70+
dry_run: bool
6971
):
7072
"""
7173
Download files from a GitHub repository.
@@ -104,7 +106,9 @@ async def run_download():
104106
token = token,
105107
concurrent = concurrent,
106108
overwrite = overwrite,
107-
progress = not no_progress
109+
progress = not no_progress,
110+
dry_run = dry_run,
111+
verbose = ctx.obj.get('verbose', False)
108112
)
109113

110114
asyncio.run(run_download())
@@ -192,3 +196,5 @@ def version():
192196
#### MAIN ENTRYPOINT FOR THE FORKLET CLI
193197
def main():
194198
cli()
199+
200+

forklet/core/orchestrator.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,43 @@ async def execute_download(self, request: DownloadRequest) -> DownloadResult:
144144
f"Filtered {filter_result.filtered_files}/{filter_result.total_files} "
145145
"files for download"
146146
)
147-
148-
# Prepare destination
149-
if request.create_destination:
150-
await self.download_service.ensure_directory(request.destination)
151-
152-
# Create download result and set as current
147+
148+
# Create download result and set as current (so control operations can act)
153149
result = DownloadResult(
154150
request=request,
155151
status=DownloadStatus.IN_PROGRESS,
156152
progress=progress,
157153
started_at=datetime.now()
158154
)
155+
# Expose matched file paths for verbose reporting
156+
result.matched_files = [f.path for f in target_files]
159157
self._current_result = result
158+
159+
# If dry-run is explicitly requested, prepare a summary and return without writing files
160+
if getattr(request, 'dry_run', None) is True:
161+
# Determine which files would be skipped due to existing local files
162+
skipped = []
163+
for f in target_files:
164+
if request.preserve_structure:
165+
target_path = request.destination / f.path
166+
else:
167+
target_path = request.destination / Path(f.path).name
168+
if target_path.exists() and not request.overwrite_existing:
169+
skipped.append(f.path)
170+
171+
# Update and return the result summarizing what would happen
172+
result.status = DownloadStatus.COMPLETED
173+
result.downloaded_files = []
174+
result.skipped_files = skipped
175+
result.failed_files = {}
176+
result.completed_at = datetime.now()
177+
# matched_files already set above; keep it for verbose output
178+
logger.info(f"Dry-run: {len(target_files)} files matched, {len(skipped)} would be skipped")
179+
return result
180+
181+
# Prepare destination
182+
if request.create_destination:
183+
await self.download_service.ensure_directory(request.destination)
160184

161185
# Reset state tracking
162186
self._completed_files.clear()
@@ -173,7 +197,7 @@ async def execute_download(self, request: DownloadRequest) -> DownloadResult:
173197
result.failed_files = failed_files
174198
result.cache_hits = stats.cache_hits
175199
result.api_calls_made = stats.api_calls
176-
200+
177201
# Mark as completed
178202
stats.end_time = datetime.now()
179203
result.mark_completed()

forklet/interfaces/cli.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ async def execute_download(
126126
token: Optional[str],
127127
concurrent: int,
128128
overwrite: bool,
129-
progress: bool = True
129+
progress: bool = True,
130+
dry_run: bool = False,
131+
verbose: bool = False,
130132
) -> None:
131133
"""
132134
Execute the download operation.
@@ -168,16 +170,17 @@ async def execute_download(
168170
max_concurrent_downloads = concurrent,
169171
overwrite_existing = overwrite,
170172
show_progress_bars = progress
173+
,dry_run = dry_run
171174
)
172175

173176
# Execute download
174177
click.echo(
175178
f"🚀 Starting download with {concurrent} concurrent workers..."
176179
)
177180
result = await self.orchestrator.execute_download(request)
178-
179-
# Display results
180-
self.display_results(result)
181+
182+
# Display results (pass through verbose flag)
183+
self.display_results(result, verbose=verbose)
181184

182185
except (
183186
RateLimitError, AuthenticationError,
@@ -191,7 +194,7 @@ async def execute_download(
191194
logger.exception("Unexpected error in download operation")
192195
sys.exit(1)
193196

194-
def display_results(self, result: DownloadResult) -> None:
197+
def display_results(self, result: DownloadResult, verbose: bool = False) -> None:
195198
"""
196199
Display download results in a user-friendly format.
197200
@@ -206,9 +209,28 @@ def display_results(self, result: DownloadResult) -> None:
206209

207210
if result.average_speed is not None:
208211
click.echo(f" ⚡ Speed: {result.average_speed:.2f} bytes/sec")
209-
212+
210213
if result.skipped_files:
211214
click.echo(f" ⏭️ Skipped: {len(result.skipped_files)} files")
215+
216+
# When verbose, display file paths (matched / downloaded / skipped)
217+
if verbose:
218+
# Matched files (available in dry-run and set by orchestrator)
219+
if hasattr(result, 'matched_files') and result.matched_files:
220+
click.echo(" 🔎 Matched files:")
221+
for p in result.matched_files:
222+
click.echo(f" {p}")
223+
224+
# For completed runs, show downloaded and skipped paths
225+
if result.downloaded_files:
226+
click.echo(" 📥 Downloaded paths:")
227+
for p in result.downloaded_files:
228+
click.echo(f" {p}")
229+
230+
if result.skipped_files:
231+
click.echo(" ⏭️ Skipped paths:")
232+
for p in result.skipped_files:
233+
click.echo(f" {p}")
212234

213235
elif hasattr(result, 'failed_files') and result.failed_files:
214236
click.echo("⚠️ Download completed with errors:")

forklet/models/download.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class DownloadRequest:
106106
# Authentication
107107
token: Optional[str] = None
108108

109+
# Dry-run preview mode (do not write files)
110+
dry_run: bool = False
111+
109112
# Metadata
110113
request_id: str = field(default_factory=lambda: f"req_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
111114
created_at: datetime = field(default_factory=datetime.now)
@@ -189,6 +192,8 @@ class DownloadResult:
189192
downloaded_files: List[str] = field(default_factory=list)
190193
skipped_files: List[str] = field(default_factory=list)
191194
failed_files: Dict[str, str] = field(default_factory=dict)
195+
# Matched file paths (populated by orchestrator for verbose reporting)
196+
matched_files: List[str] = field(default_factory=list)
192197

193198
# Metadata
194199
started_at: datetime = field(default_factory=datetime.now)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import asyncio
2+
import pytest
3+
from pathlib import Path
4+
from datetime import datetime
5+
6+
from forklet.core.orchestrator import DownloadOrchestrator
7+
from forklet.services.github_api import GitHubAPIService
8+
from forklet.services.download import DownloadService
9+
from forklet.infrastructure.retry_manager import RetryManager
10+
from forklet.infrastructure.rate_limiter import RateLimiter
11+
from forklet.models.github import GitHubFile
12+
from forklet.models.download import DownloadRequest, FilterCriteria
13+
from forklet.models.github import RepositoryInfo, GitReference, RepositoryType
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_orchestrator_dry_run(tmp_path, monkeypatch):
18+
# Arrange: create mock files returned by GitHub API
19+
files = [
20+
GitHubFile(path="src/main.py", type="blob", size=100, download_url="https://api.github.com/file1"),
21+
GitHubFile(path="README.md", type="blob", size=50, download_url="https://api.github.com/file2"),
22+
]
23+
24+
async def mock_get_repository_tree(owner, repo, ref):
25+
return files
26+
27+
# Setup services
28+
rate_limiter = RateLimiter()
29+
retry_manager = RetryManager()
30+
github_service = GitHubAPIService(rate_limiter, retry_manager)
31+
download_service = DownloadService(retry_manager)
32+
33+
# Monkeypatch the github_service.get_repository_tree to return our files
34+
monkeypatch.setattr(github_service, 'get_repository_tree', mock_get_repository_tree)
35+
36+
orchestrator = DownloadOrchestrator(github_service, download_service)
37+
38+
# Create a fake repository and ref
39+
repo = RepositoryInfo(
40+
owner='test', name='repo', full_name='test/repo', url='https://github.com/test/repo',
41+
default_branch='main', repo_type=RepositoryType.PUBLIC, size=1,
42+
is_private=False, is_fork=False, created_at=datetime.now(), updated_at=datetime.now()
43+
)
44+
ref = GitReference(name='main', ref_type='branch', sha='abc')
45+
46+
# Create destination and create one existing file to test skipped detection
47+
dest = tmp_path / "out"
48+
dest.mkdir()
49+
existing = dest / "README.md"
50+
existing.write_text("existing")
51+
52+
request = DownloadRequest(
53+
repository=repo,
54+
git_ref=ref,
55+
destination=dest,
56+
strategy=None,
57+
filters=FilterCriteria(),
58+
dry_run=True
59+
)
60+
61+
# Act
62+
result = await orchestrator.execute_download(request)
63+
64+
# Assert
65+
assert result is not None
66+
assert result.progress.total_files == 2
67+
assert result.progress.total_bytes == 150
68+
# No files should be downloaded in dry-run
69+
assert result.downloaded_files == []
70+
# README.md should be reported as skipped
71+
assert "README.md" in result.skipped_files

0 commit comments

Comments
 (0)