encode-toolkit 0.3.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ """File download manager for ENCODE data files.
2
+
3
+ Downloads are always to user-specified local directories.
4
+ Supports MD5 verification, concurrent downloads with rate limiting,
5
+ and organization by experiment or file format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from encode_connector.client.auth import CredentialManager
19
+ from encode_connector.client.constants import (
20
+ BASE_URL,
21
+ DOWNLOAD_CONCURRENCY,
22
+ DOWNLOAD_TIMEOUT,
23
+ USER_AGENT,
24
+ )
25
+ from encode_connector.client.models import DownloadResult, FileSummary, _human_size
26
+ from encode_connector.client.validation import (
27
+ safe_path_component,
28
+ validate_download_url,
29
+ validate_organize_by,
30
+ validate_redirect_url,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class FileDownloader:
37
+ """Manages file downloads from ENCODE with concurrency and verification."""
38
+
39
+ def __init__(
40
+ self,
41
+ credential_manager: CredentialManager | None = None,
42
+ base_url: str = BASE_URL,
43
+ max_concurrent: int = DOWNLOAD_CONCURRENCY,
44
+ ) -> None:
45
+ self.base_url = base_url.rstrip("/")
46
+ self._credential_manager = credential_manager or CredentialManager()
47
+ self._max_concurrent = max_concurrent
48
+ self._semaphore = asyncio.Semaphore(max_concurrent)
49
+
50
+ def _get_headers(self) -> dict[str, str]:
51
+ """Build request headers including optional auth."""
52
+ headers = {
53
+ "User-Agent": USER_AGENT,
54
+ }
55
+ auth = self._credential_manager.get_auth_header()
56
+ if auth:
57
+ headers.update(auth)
58
+ return headers
59
+
60
+ def _resolve_path(
61
+ self,
62
+ download_dir: str | Path,
63
+ file_info: FileSummary,
64
+ organize_by: str = "flat",
65
+ ) -> Path:
66
+ """Determine the local file path for a download.
67
+
68
+ Args:
69
+ download_dir: Base download directory.
70
+ file_info: File metadata.
71
+ organize_by: How to organize files:
72
+ - "flat": all files in download_dir
73
+ - "experiment": download_dir/ENCSR.../filename
74
+ - "format": download_dir/bed/filename
75
+ - "experiment_format": download_dir/ENCSR.../bed/filename
76
+ """
77
+ validate_organize_by(organize_by)
78
+ base = Path(download_dir).resolve()
79
+
80
+ # Determine filename from the download URL (sanitize to prevent path traversal)
81
+ if file_info.download_url:
82
+ url_path = Path(file_info.download_url.split("?")[0]).name
83
+ else:
84
+ url_path = ""
85
+ filename = url_path or f"{file_info.accession}.{file_info.file_format}"
86
+ # Extra safety: strip any remaining path separators
87
+ filename = Path(filename).name
88
+
89
+ # Sanitize subdirectory components to prevent path traversal
90
+ if organize_by == "experiment" and file_info.experiment_accession:
91
+ subdir = safe_path_component(file_info.experiment_accession)
92
+ return base / subdir / filename
93
+ elif organize_by == "format" and file_info.file_format:
94
+ subdir = safe_path_component(file_info.file_format)
95
+ return base / subdir / filename
96
+ elif organize_by == "experiment_format" and file_info.experiment_accession:
97
+ exp_dir = safe_path_component(file_info.experiment_accession)
98
+ fmt_dir = safe_path_component(file_info.file_format)
99
+ return base / exp_dir / fmt_dir / filename
100
+ else:
101
+ return base / filename
102
+
103
+ async def download_file(
104
+ self,
105
+ file_info: FileSummary,
106
+ download_dir: str | Path,
107
+ organize_by: str = "flat",
108
+ verify_md5: bool = True,
109
+ ) -> DownloadResult:
110
+ """Download a single file with rate limiting and optional MD5 verification."""
111
+ result = DownloadResult(accession=file_info.accession)
112
+
113
+ if not file_info.download_url:
114
+ result.error = "No download URL available"
115
+ return result
116
+
117
+ file_path = self._resolve_path(download_dir, file_info, organize_by)
118
+ result.file_path = str(file_path)
119
+
120
+ # Skip if already downloaded and MD5 matches
121
+ if file_path.exists() and verify_md5 and file_info.md5sum:
122
+ existing_md5 = await self._compute_md5(file_path)
123
+ if existing_md5 == file_info.md5sum:
124
+ result.success = True
125
+ result.md5_verified = True
126
+ result.file_size = file_path.stat().st_size
127
+ result.file_size_human = _human_size(result.file_size)
128
+ logger.info("Skipping %s (already downloaded, MD5 verified)", file_info.accession)
129
+ return result
130
+
131
+ # Create parent directories
132
+ file_path.parent.mkdir(parents=True, exist_ok=True)
133
+
134
+ async with self._semaphore:
135
+ try:
136
+ await self._stream_download(file_info.download_url, file_path)
137
+
138
+ result.file_size = file_path.stat().st_size
139
+ result.file_size_human = _human_size(result.file_size)
140
+ result.success = True
141
+
142
+ # Verify MD5 if available
143
+ if verify_md5 and file_info.md5sum:
144
+ computed_md5 = await self._compute_md5(file_path)
145
+ if computed_md5 == file_info.md5sum:
146
+ result.md5_verified = True
147
+ else:
148
+ result.success = False
149
+ result.error = f"MD5 mismatch: expected {file_info.md5sum}, got {computed_md5}"
150
+ # Remove corrupted file
151
+ file_path.unlink(missing_ok=True)
152
+
153
+ if result.success:
154
+ logger.info(
155
+ "Downloaded %s (%s) -> %s",
156
+ file_info.accession,
157
+ result.file_size_human,
158
+ file_path,
159
+ )
160
+
161
+ except httpx.HTTPStatusError as e:
162
+ result.error = f"HTTP {e.response.status_code}: {e.response.reason_phrase}"
163
+ logger.error("Failed to download %s: %s", file_info.accession, result.error)
164
+ file_path.unlink(missing_ok=True)
165
+ except httpx.TimeoutException:
166
+ result.error = "Download timed out"
167
+ logger.error("Timeout downloading %s", file_info.accession)
168
+ file_path.unlink(missing_ok=True)
169
+ except BaseException as e:
170
+ # Catch BaseException (including asyncio.CancelledError) to ensure
171
+ # partial files are cleaned up on cancellation or any other error
172
+ result.error = str(e)
173
+ logger.error("Error downloading %s: %s", file_info.accession, e)
174
+ file_path.unlink(missing_ok=True)
175
+ if isinstance(e, (KeyboardInterrupt, asyncio.CancelledError)):
176
+ raise
177
+
178
+ return result
179
+
180
+ async def _stream_download(self, url: str, file_path: Path) -> None:
181
+ """Stream download a file to disk.
182
+
183
+ Validates download URL against allowed hosts. Uses a HEAD request to
184
+ detect redirects, then streams the response in all cases to avoid
185
+ loading large files (BAM/FASTQ can be 50+ GB) into memory.
186
+ """
187
+ # Ensure absolute URL
188
+ if url.startswith("/"):
189
+ url = f"{self.base_url}{url}"
190
+
191
+ # Validate the URL is to an allowed ENCODE host
192
+ validate_download_url(url)
193
+
194
+ headers = self._get_headers()
195
+
196
+ async with httpx.AsyncClient(
197
+ follow_redirects=False,
198
+ timeout=DOWNLOAD_TIMEOUT,
199
+ ) as client:
200
+ # Use HEAD to detect redirects without downloading the body
201
+ head_response = await client.head(url, headers=headers)
202
+
203
+ if head_response.is_redirect:
204
+ redirect_url = str(head_response.headers.get("location", ""))
205
+ if redirect_url:
206
+ validate_redirect_url(redirect_url)
207
+ # For S3/CDN redirects, strip auth headers
208
+ safe_headers = {"User-Agent": headers.get("User-Agent", USER_AGENT)}
209
+ stream_url = redirect_url
210
+ stream_headers = safe_headers
211
+ else:
212
+ stream_url = url
213
+ stream_headers = headers
214
+ else:
215
+ head_response.raise_for_status()
216
+ stream_url = url
217
+ stream_headers = headers
218
+
219
+ # Belt-and-suspenders: re-validate stream_url before the GET
220
+ # to guard against any code path that might set it without validation
221
+ if stream_url != url:
222
+ validate_redirect_url(stream_url)
223
+ else:
224
+ validate_download_url(stream_url)
225
+
226
+ # Always stream the download to avoid loading entire file into memory
227
+ async with client.stream("GET", stream_url, headers=stream_headers) as response:
228
+ response.raise_for_status()
229
+ with open(file_path, "wb") as f:
230
+ async for chunk in response.aiter_bytes(chunk_size=65536):
231
+ f.write(chunk)
232
+
233
+ async def _compute_md5(self, file_path: Path) -> str:
234
+ """Compute MD5 hash of a file asynchronously."""
235
+ loop = asyncio.get_running_loop()
236
+ return await loop.run_in_executor(None, self._md5_sync, file_path)
237
+
238
+ @staticmethod
239
+ def _md5_sync(file_path: Path) -> str:
240
+ """Compute MD5 hash synchronously."""
241
+ md5 = hashlib.md5()
242
+ with open(file_path, "rb") as f:
243
+ for chunk in iter(lambda: f.read(65536), b""):
244
+ md5.update(chunk)
245
+ return md5.hexdigest()
246
+
247
+ async def download_batch(
248
+ self,
249
+ files: list[FileSummary],
250
+ download_dir: str | Path,
251
+ organize_by: str = "flat",
252
+ verify_md5: bool = True,
253
+ ) -> list[DownloadResult]:
254
+ """Download multiple files concurrently.
255
+
256
+ Returns list of DownloadResult for each file.
257
+ """
258
+ tasks = [self.download_file(f, download_dir, organize_by, verify_md5) for f in files]
259
+ # Use return_exceptions=True so one failed download doesn't cancel the rest
260
+ results = await asyncio.gather(*tasks, return_exceptions=True)
261
+ # Convert any unexpected exceptions into failed DownloadResult objects
262
+ final: list[DownloadResult] = []
263
+ for i, result in enumerate(results):
264
+ if isinstance(result, BaseException):
265
+ dr = DownloadResult(accession=files[i].accession)
266
+ dr.error = f"Unexpected error: {result}"
267
+ final.append(dr)
268
+ else:
269
+ final.append(result)
270
+ return final
271
+
272
+ def preview_downloads(
273
+ self,
274
+ files: list[FileSummary],
275
+ download_dir: str | Path,
276
+ organize_by: str = "flat",
277
+ ) -> dict[str, Any]:
278
+ """Preview what would be downloaded without actually downloading.
279
+
280
+ Returns summary with file list, total size, and paths.
281
+ """
282
+ previews = []
283
+ total_size = 0
284
+
285
+ for f in files:
286
+ path = self._resolve_path(download_dir, f, organize_by)
287
+ total_size += f.file_size
288
+ previews.append(
289
+ {
290
+ "accession": f.accession,
291
+ "file_format": f.file_format,
292
+ "output_type": f.output_type,
293
+ "file_size": f.file_size,
294
+ "file_size_human": f.file_size_human,
295
+ "target_path": str(path),
296
+ "already_exists": path.exists(),
297
+ }
298
+ )
299
+
300
+ return {
301
+ "file_count": len(files),
302
+ "total_size": total_size,
303
+ "total_size_human": _human_size(total_size),
304
+ "files": previews,
305
+ }