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.
- encode_connector/__init__.py +4 -0
- encode_connector/__main__.py +5 -0
- encode_connector/client/__init__.py +6 -0
- encode_connector/client/auth.py +262 -0
- encode_connector/client/constants.py +348 -0
- encode_connector/client/downloader.py +305 -0
- encode_connector/client/encode_client.py +585 -0
- encode_connector/client/models.py +332 -0
- encode_connector/client/tracker.py +1129 -0
- encode_connector/client/validation.py +188 -0
- encode_connector/server/__init__.py +1 -0
- encode_connector/server/__main__.py +5 -0
- encode_connector/server/main.py +1495 -0
- encode_toolkit-0.3.0b1.dist-info/METADATA +810 -0
- encode_toolkit-0.3.0b1.dist-info/RECORD +18 -0
- encode_toolkit-0.3.0b1.dist-info/WHEEL +4 -0
- encode_toolkit-0.3.0b1.dist-info/entry_points.txt +2 -0
- encode_toolkit-0.3.0b1.dist-info/licenses/LICENSE +144 -0
|
@@ -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
|
+
}
|