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,585 @@
|
|
|
1
|
+
"""Async HTTP client for the ENCODE Project REST API.
|
|
2
|
+
|
|
3
|
+
All requests go over HTTPS to encodeproject.org only.
|
|
4
|
+
No data is sent to any other server. No telemetry or analytics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from encode_connector.client.auth import CredentialManager
|
|
17
|
+
from encode_connector.client.constants import (
|
|
18
|
+
BASE_URL,
|
|
19
|
+
DEFAULT_LIMIT,
|
|
20
|
+
DEFAULT_TIMEOUT,
|
|
21
|
+
EXPERIMENT_FILTER_MAP,
|
|
22
|
+
FILE_FILTER_MAP,
|
|
23
|
+
MAX_REQUESTS_PER_SECOND,
|
|
24
|
+
METADATA_MAP,
|
|
25
|
+
USER_AGENT,
|
|
26
|
+
)
|
|
27
|
+
from encode_connector.client.models import (
|
|
28
|
+
ExperimentDetail,
|
|
29
|
+
ExperimentSummary,
|
|
30
|
+
FileSummary,
|
|
31
|
+
)
|
|
32
|
+
from encode_connector.client.validation import (
|
|
33
|
+
clamp_limit,
|
|
34
|
+
validate_accession,
|
|
35
|
+
validate_date,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# Retry configuration for transient failures
|
|
41
|
+
MAX_RETRIES = 3
|
|
42
|
+
RETRY_BACKOFF = [1.0, 2.0, 4.0]
|
|
43
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EncodeClient:
|
|
47
|
+
"""Async client for the ENCODE Project REST API.
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
async with EncodeClient() as client:
|
|
51
|
+
experiments = await client.search_experiments(
|
|
52
|
+
assay_title="Histone ChIP-seq",
|
|
53
|
+
organism="Homo sapiens",
|
|
54
|
+
organ="pancreas",
|
|
55
|
+
biosample_type="tissue",
|
|
56
|
+
)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
access_key: str | None = None,
|
|
62
|
+
secret_key: str | None = None,
|
|
63
|
+
base_url: str = BASE_URL,
|
|
64
|
+
credential_manager: CredentialManager | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.base_url = base_url.rstrip("/")
|
|
67
|
+
self._credential_manager = credential_manager or CredentialManager()
|
|
68
|
+
|
|
69
|
+
# If explicit credentials provided, store them
|
|
70
|
+
if access_key and secret_key:
|
|
71
|
+
self._credential_manager.store_credentials(access_key, secret_key)
|
|
72
|
+
|
|
73
|
+
self._client: httpx.AsyncClient | None = None
|
|
74
|
+
self._client_lock = asyncio.Lock()
|
|
75
|
+
|
|
76
|
+
# TTL cache for metadata and facets (avoids repeated identical API calls)
|
|
77
|
+
self._cache: dict[str, tuple[Any, float]] = {}
|
|
78
|
+
self._cache_ttl = 3600 # 1 hour
|
|
79
|
+
|
|
80
|
+
# Rate limiter: token bucket
|
|
81
|
+
self._rate_limit = MAX_REQUESTS_PER_SECOND
|
|
82
|
+
self._semaphore = asyncio.Semaphore(MAX_REQUESTS_PER_SECOND)
|
|
83
|
+
self._last_request_time = 0.0
|
|
84
|
+
self._rate_lock = asyncio.Lock()
|
|
85
|
+
|
|
86
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
87
|
+
"""Get or create the HTTP client.
|
|
88
|
+
|
|
89
|
+
Uses an asyncio.Lock to prevent concurrent coroutines from
|
|
90
|
+
creating duplicate clients (race condition on _client is None check).
|
|
91
|
+
"""
|
|
92
|
+
async with self._client_lock:
|
|
93
|
+
if self._client is None or self._client.is_closed:
|
|
94
|
+
headers = {
|
|
95
|
+
"Accept": "application/json",
|
|
96
|
+
"User-Agent": USER_AGENT,
|
|
97
|
+
}
|
|
98
|
+
# Add auth headers if credentials available
|
|
99
|
+
auth_headers = self._credential_manager.get_auth_header()
|
|
100
|
+
if auth_headers:
|
|
101
|
+
headers.update(auth_headers)
|
|
102
|
+
|
|
103
|
+
self._client = httpx.AsyncClient(
|
|
104
|
+
base_url=self.base_url,
|
|
105
|
+
headers=headers,
|
|
106
|
+
timeout=DEFAULT_TIMEOUT,
|
|
107
|
+
follow_redirects=True,
|
|
108
|
+
# HTTPS certificate verification enforced (default)
|
|
109
|
+
# Note: httpx strips auth headers on cross-origin redirects by default
|
|
110
|
+
)
|
|
111
|
+
return self._client
|
|
112
|
+
|
|
113
|
+
async def _request(self, path: str, params: dict[str, Any] | None = None) -> dict:
|
|
114
|
+
"""Make a rate-limited GET request to the ENCODE API with retry on transient failures."""
|
|
115
|
+
last_exception: Exception | None = None
|
|
116
|
+
|
|
117
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
118
|
+
try:
|
|
119
|
+
async with self._semaphore:
|
|
120
|
+
# Enforce rate limiting with lock to prevent race conditions
|
|
121
|
+
async with self._rate_lock:
|
|
122
|
+
now = time.monotonic()
|
|
123
|
+
min_interval = 1.0 / self._rate_limit
|
|
124
|
+
elapsed = now - self._last_request_time
|
|
125
|
+
if elapsed < min_interval:
|
|
126
|
+
await asyncio.sleep(min_interval - elapsed)
|
|
127
|
+
self._last_request_time = time.monotonic()
|
|
128
|
+
|
|
129
|
+
client = await self._ensure_client()
|
|
130
|
+
response = await client.get(path, params=params)
|
|
131
|
+
|
|
132
|
+
# Retry on transient server errors
|
|
133
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
134
|
+
if attempt < MAX_RETRIES:
|
|
135
|
+
delay = RETRY_BACKOFF[attempt]
|
|
136
|
+
logger.warning(
|
|
137
|
+
"ENCODE API returned %d for %s, retrying in %.1fs (attempt %d/%d)",
|
|
138
|
+
response.status_code,
|
|
139
|
+
path,
|
|
140
|
+
delay,
|
|
141
|
+
attempt + 1,
|
|
142
|
+
MAX_RETRIES,
|
|
143
|
+
)
|
|
144
|
+
await asyncio.sleep(delay)
|
|
145
|
+
continue
|
|
146
|
+
# Final attempt — let raise_for_status handle it
|
|
147
|
+
response.raise_for_status()
|
|
148
|
+
return response.json()
|
|
149
|
+
|
|
150
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
|
151
|
+
last_exception = e
|
|
152
|
+
if attempt < MAX_RETRIES:
|
|
153
|
+
delay = RETRY_BACKOFF[attempt]
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Connection error for %s: %s, retrying in %.1fs (attempt %d/%d)",
|
|
156
|
+
path,
|
|
157
|
+
type(e).__name__,
|
|
158
|
+
delay,
|
|
159
|
+
attempt + 1,
|
|
160
|
+
MAX_RETRIES,
|
|
161
|
+
)
|
|
162
|
+
await asyncio.sleep(delay)
|
|
163
|
+
continue
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
# Should not reach here, but just in case
|
|
167
|
+
if last_exception:
|
|
168
|
+
raise last_exception
|
|
169
|
+
raise RuntimeError("Unexpected retry exhaustion")
|
|
170
|
+
|
|
171
|
+
async def get_json(self, path: str, params: dict[str, Any] | None = None) -> dict:
|
|
172
|
+
"""Public method to fetch JSON from an ENCODE API path.
|
|
173
|
+
|
|
174
|
+
Use this instead of calling _request directly from outside the client.
|
|
175
|
+
"""
|
|
176
|
+
return await self._request(path, params)
|
|
177
|
+
|
|
178
|
+
async def close(self) -> None:
|
|
179
|
+
"""Close the HTTP client."""
|
|
180
|
+
if self._client and not self._client.is_closed:
|
|
181
|
+
await self._client.aclose()
|
|
182
|
+
self._client = None
|
|
183
|
+
|
|
184
|
+
async def __aenter__(self) -> EncodeClient:
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
188
|
+
await self.close()
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# TTL cache helpers
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _get_cached(self, key: str) -> Any | None:
|
|
195
|
+
"""Return cached value if still within TTL, else None."""
|
|
196
|
+
if key in self._cache:
|
|
197
|
+
value, timestamp = self._cache[key]
|
|
198
|
+
if time.time() - timestamp < self._cache_ttl:
|
|
199
|
+
return value
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def _set_cached(self, key: str, value: Any) -> None:
|
|
203
|
+
"""Store a value in the TTL cache."""
|
|
204
|
+
self._cache[key] = (value, time.time())
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Experiment queries
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
async def search_experiments(
|
|
211
|
+
self,
|
|
212
|
+
assay_title: str | None = None,
|
|
213
|
+
organism: str | None = None,
|
|
214
|
+
organ: str | None = None,
|
|
215
|
+
biosample_type: str | None = None,
|
|
216
|
+
biosample_term_name: str | None = None,
|
|
217
|
+
target: str | None = None,
|
|
218
|
+
status: str | None = None,
|
|
219
|
+
lab: str | None = None,
|
|
220
|
+
award: str | None = None,
|
|
221
|
+
assembly: str | None = None,
|
|
222
|
+
replication_type: str | None = None,
|
|
223
|
+
life_stage: str | None = None,
|
|
224
|
+
sex: str | None = None,
|
|
225
|
+
treatment: str | None = None,
|
|
226
|
+
genetic_modification: str | None = None,
|
|
227
|
+
perturbed: bool | None = None,
|
|
228
|
+
search_term: str | None = None,
|
|
229
|
+
date_released_from: str | None = None,
|
|
230
|
+
date_released_to: str | None = None,
|
|
231
|
+
limit: int = DEFAULT_LIMIT,
|
|
232
|
+
offset: int = 0,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Search ENCODE experiments with comprehensive filters.
|
|
235
|
+
|
|
236
|
+
Returns dict with 'results' (list of ExperimentSummary) and 'total' count.
|
|
237
|
+
"""
|
|
238
|
+
limit = clamp_limit(limit)
|
|
239
|
+
params: dict[str, Any] = {
|
|
240
|
+
"type": "Experiment",
|
|
241
|
+
"format": "json",
|
|
242
|
+
"frame": "object",
|
|
243
|
+
"limit": limit,
|
|
244
|
+
}
|
|
245
|
+
if offset > 0:
|
|
246
|
+
params["from"] = offset
|
|
247
|
+
|
|
248
|
+
# Map user-friendly params to API params
|
|
249
|
+
filter_values = {
|
|
250
|
+
"assay_title": assay_title,
|
|
251
|
+
"organism": organism,
|
|
252
|
+
"organ": organ,
|
|
253
|
+
"biosample_type": biosample_type,
|
|
254
|
+
"biosample_term_name": biosample_term_name,
|
|
255
|
+
"target": target,
|
|
256
|
+
"status": status,
|
|
257
|
+
"lab": lab,
|
|
258
|
+
"award": award,
|
|
259
|
+
"assembly": assembly,
|
|
260
|
+
"replication_type": replication_type,
|
|
261
|
+
"life_stage": life_stage,
|
|
262
|
+
"sex": sex,
|
|
263
|
+
"treatment": treatment,
|
|
264
|
+
"genetic_modification": genetic_modification,
|
|
265
|
+
"searchTerm": search_term,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for key, value in filter_values.items():
|
|
269
|
+
if value is not None:
|
|
270
|
+
api_param = EXPERIMENT_FILTER_MAP.get(key, key)
|
|
271
|
+
params[api_param] = value
|
|
272
|
+
|
|
273
|
+
if perturbed is not None:
|
|
274
|
+
params[EXPERIMENT_FILTER_MAP["perturbed"]] = str(perturbed).lower()
|
|
275
|
+
|
|
276
|
+
# Date range filtering (sanitize inputs to prevent Lucene injection)
|
|
277
|
+
if date_released_from:
|
|
278
|
+
validate_date(date_released_from)
|
|
279
|
+
if date_released_to:
|
|
280
|
+
validate_date(date_released_to)
|
|
281
|
+
if date_released_from or date_released_to:
|
|
282
|
+
from_date = date_released_from or "*"
|
|
283
|
+
to_date = date_released_to or "*"
|
|
284
|
+
existing = params.get("advancedQuery", "").strip()
|
|
285
|
+
date_clause = f"date_released:[{from_date} TO {to_date}]"
|
|
286
|
+
params["advancedQuery"] = f"{existing} {date_clause}".strip()
|
|
287
|
+
|
|
288
|
+
data = await self._request("/search/", params)
|
|
289
|
+
|
|
290
|
+
results = [ExperimentSummary.from_api(exp) for exp in data.get("@graph", [])]
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"results": results,
|
|
294
|
+
"total": data.get("total", len(results)),
|
|
295
|
+
"limit": limit,
|
|
296
|
+
"offset": offset,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async def get_experiment_raw(self, accession: str) -> dict:
|
|
300
|
+
"""Get raw experiment data with embedded frame."""
|
|
301
|
+
validate_accession(accession)
|
|
302
|
+
return await self._request(
|
|
303
|
+
f"/experiments/{accession}/",
|
|
304
|
+
{"format": "json", "frame": "embedded"},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def get_experiment(self, accession: str) -> ExperimentDetail:
|
|
308
|
+
"""Get full details for a single experiment including its files."""
|
|
309
|
+
validate_accession(accession)
|
|
310
|
+
# Get experiment data
|
|
311
|
+
exp_data = await self._request(
|
|
312
|
+
f"/experiments/{accession}/",
|
|
313
|
+
{"format": "json", "frame": "embedded"},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Get files for this experiment
|
|
317
|
+
files_data = await self._request(
|
|
318
|
+
"/search/",
|
|
319
|
+
{
|
|
320
|
+
"type": "File",
|
|
321
|
+
"dataset": f"/experiments/{accession}/",
|
|
322
|
+
"format": "json",
|
|
323
|
+
"frame": "object",
|
|
324
|
+
"limit": "all",
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
files = files_data.get("@graph", [])
|
|
329
|
+
return ExperimentDetail.from_api(exp_data, files)
|
|
330
|
+
|
|
331
|
+
# ------------------------------------------------------------------
|
|
332
|
+
# File queries
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
async def list_files(
|
|
336
|
+
self,
|
|
337
|
+
experiment_accession: str,
|
|
338
|
+
file_format: str | None = None,
|
|
339
|
+
file_type: str | None = None,
|
|
340
|
+
output_type: str | None = None,
|
|
341
|
+
output_category: str | None = None,
|
|
342
|
+
assembly: str | None = None,
|
|
343
|
+
status: str | None = None,
|
|
344
|
+
preferred_default: bool | None = None,
|
|
345
|
+
limit: int = 200,
|
|
346
|
+
) -> list[FileSummary]:
|
|
347
|
+
"""List files for a specific experiment with optional filters."""
|
|
348
|
+
validate_accession(experiment_accession)
|
|
349
|
+
limit = clamp_limit(limit)
|
|
350
|
+
params: dict[str, Any] = {
|
|
351
|
+
"type": "File",
|
|
352
|
+
"dataset": f"/experiments/{experiment_accession}/",
|
|
353
|
+
"format": "json",
|
|
354
|
+
"frame": "object",
|
|
355
|
+
"limit": limit,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
filter_values = {
|
|
359
|
+
"file_format": file_format,
|
|
360
|
+
"file_type": file_type,
|
|
361
|
+
"output_type": output_type,
|
|
362
|
+
"output_category": output_category,
|
|
363
|
+
"assembly": assembly,
|
|
364
|
+
"status": status,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for key, value in filter_values.items():
|
|
368
|
+
if value is not None:
|
|
369
|
+
api_param = FILE_FILTER_MAP.get(key, key)
|
|
370
|
+
params[api_param] = value
|
|
371
|
+
|
|
372
|
+
if preferred_default is not None:
|
|
373
|
+
params["preferred_default"] = str(preferred_default).lower()
|
|
374
|
+
|
|
375
|
+
data = await self._request("/search/", params)
|
|
376
|
+
return [FileSummary.from_api(f) for f in data.get("@graph", [])]
|
|
377
|
+
|
|
378
|
+
async def search_files(
|
|
379
|
+
self,
|
|
380
|
+
file_format: str | None = None,
|
|
381
|
+
file_type: str | None = None,
|
|
382
|
+
output_type: str | None = None,
|
|
383
|
+
output_category: str | None = None,
|
|
384
|
+
assembly: str | None = None,
|
|
385
|
+
assay_title: str | None = None,
|
|
386
|
+
organism: str | None = None,
|
|
387
|
+
organ: str | None = None,
|
|
388
|
+
biosample_type: str | None = None,
|
|
389
|
+
target: str | None = None,
|
|
390
|
+
status: str | None = None,
|
|
391
|
+
preferred_default: bool | None = None,
|
|
392
|
+
search_term: str | None = None,
|
|
393
|
+
limit: int = DEFAULT_LIMIT,
|
|
394
|
+
offset: int = 0,
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
"""Search files across all experiments with combined filters."""
|
|
397
|
+
limit = clamp_limit(limit)
|
|
398
|
+
params: dict[str, Any] = {
|
|
399
|
+
"type": "File",
|
|
400
|
+
"format": "json",
|
|
401
|
+
"frame": "object",
|
|
402
|
+
"limit": limit,
|
|
403
|
+
}
|
|
404
|
+
if offset > 0:
|
|
405
|
+
params["from"] = offset
|
|
406
|
+
|
|
407
|
+
# File-level filters
|
|
408
|
+
file_filters = {
|
|
409
|
+
"file_format": file_format,
|
|
410
|
+
"file_type": file_type,
|
|
411
|
+
"output_type": output_type,
|
|
412
|
+
"output_category": output_category,
|
|
413
|
+
"assembly": assembly,
|
|
414
|
+
"status": status,
|
|
415
|
+
}
|
|
416
|
+
for key, value in file_filters.items():
|
|
417
|
+
if value is not None:
|
|
418
|
+
api_param = FILE_FILTER_MAP.get(key, key)
|
|
419
|
+
params[api_param] = value
|
|
420
|
+
|
|
421
|
+
if preferred_default is not None:
|
|
422
|
+
params["preferred_default"] = str(preferred_default).lower()
|
|
423
|
+
|
|
424
|
+
# Experiment-level filters available on File search
|
|
425
|
+
if assay_title:
|
|
426
|
+
params["assay_title"] = assay_title
|
|
427
|
+
if organ:
|
|
428
|
+
params["biosample_ontology.organ_slims"] = organ
|
|
429
|
+
if biosample_type:
|
|
430
|
+
params["biosample_ontology.classification"] = biosample_type
|
|
431
|
+
if target:
|
|
432
|
+
params["target.label"] = target
|
|
433
|
+
|
|
434
|
+
# For organism filtering on files, we need a two-step approach:
|
|
435
|
+
# search experiments first, then get files from matching experiments
|
|
436
|
+
if organism:
|
|
437
|
+
# For non-human organisms, do a two-step search
|
|
438
|
+
exp_result = await self.search_experiments(
|
|
439
|
+
assay_title=assay_title,
|
|
440
|
+
organism=organism,
|
|
441
|
+
organ=organ,
|
|
442
|
+
biosample_type=biosample_type,
|
|
443
|
+
target=target,
|
|
444
|
+
status=status or "released",
|
|
445
|
+
limit=200,
|
|
446
|
+
)
|
|
447
|
+
if not exp_result["results"]:
|
|
448
|
+
return {"results": [], "total": 0, "limit": limit, "offset": offset}
|
|
449
|
+
|
|
450
|
+
# Get files from matching experiments
|
|
451
|
+
all_files = []
|
|
452
|
+
for exp in exp_result["results"]:
|
|
453
|
+
exp_files = await self.list_files(
|
|
454
|
+
experiment_accession=exp.accession,
|
|
455
|
+
file_format=file_format,
|
|
456
|
+
file_type=file_type,
|
|
457
|
+
output_type=output_type,
|
|
458
|
+
output_category=output_category,
|
|
459
|
+
assembly=assembly,
|
|
460
|
+
status=status,
|
|
461
|
+
preferred_default=preferred_default,
|
|
462
|
+
)
|
|
463
|
+
all_files.extend(exp_files)
|
|
464
|
+
if len(all_files) >= limit:
|
|
465
|
+
break
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
"results": all_files[:limit],
|
|
469
|
+
"total": len(all_files),
|
|
470
|
+
"total_note": "Approximate — based on files collected from matching experiments",
|
|
471
|
+
"limit": limit,
|
|
472
|
+
"offset": offset,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if search_term:
|
|
476
|
+
params["searchTerm"] = search_term
|
|
477
|
+
|
|
478
|
+
data = await self._request("/search/", params)
|
|
479
|
+
results = [FileSummary.from_api(f) for f in data.get("@graph", [])]
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
"results": results,
|
|
483
|
+
"total": data.get("total", len(results)),
|
|
484
|
+
"limit": limit,
|
|
485
|
+
"offset": offset,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async def get_file_info(self, accession: str) -> FileSummary:
|
|
489
|
+
"""Get details for a single file by accession."""
|
|
490
|
+
validate_accession(accession)
|
|
491
|
+
data = await self._request(
|
|
492
|
+
f"/files/{accession}/",
|
|
493
|
+
{"format": "json", "frame": "object"},
|
|
494
|
+
)
|
|
495
|
+
return FileSummary.from_api(data)
|
|
496
|
+
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
# Metadata / schema queries
|
|
499
|
+
# ------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def get_metadata(self, metadata_type: str) -> list[str]:
|
|
502
|
+
"""Get known values for a filter type (cached, no API call).
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
metadata_type: One of: assays, organisms, organs, biosample_types,
|
|
506
|
+
file_formats, output_types, output_categories, assemblies,
|
|
507
|
+
life_stages, replication_types, statuses, file_statuses
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
List of valid filter values.
|
|
511
|
+
"""
|
|
512
|
+
cache_key = f"metadata:{metadata_type}"
|
|
513
|
+
cached = self._get_cached(cache_key)
|
|
514
|
+
if cached is not None:
|
|
515
|
+
return cached
|
|
516
|
+
|
|
517
|
+
values = METADATA_MAP.get(metadata_type)
|
|
518
|
+
if values is None:
|
|
519
|
+
available = ", ".join(sorted(METADATA_MAP.keys()))
|
|
520
|
+
raise ValueError(f"Unknown metadata type: {metadata_type}. Available: {available}")
|
|
521
|
+
result = list(values)
|
|
522
|
+
self._set_cached(cache_key, result)
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
async def search_facets(
|
|
526
|
+
self,
|
|
527
|
+
search_type: str = "Experiment",
|
|
528
|
+
**filters: str,
|
|
529
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
530
|
+
"""Get live facet counts from ENCODE for dynamic filter discovery.
|
|
531
|
+
|
|
532
|
+
Returns facet name -> list of {term, count} for each available filter.
|
|
533
|
+
Results are cached for 1 hour to reduce redundant API calls.
|
|
534
|
+
"""
|
|
535
|
+
# Build a stable cache key from search_type + sorted filters
|
|
536
|
+
filter_key = "|".join(f"{k}={v}" for k, v in sorted(filters.items()))
|
|
537
|
+
cache_key = f"facets:{search_type}:{filter_key}"
|
|
538
|
+
cached = self._get_cached(cache_key)
|
|
539
|
+
if cached is not None:
|
|
540
|
+
return cached
|
|
541
|
+
|
|
542
|
+
params: dict[str, Any] = {
|
|
543
|
+
"type": search_type,
|
|
544
|
+
"format": "json",
|
|
545
|
+
"limit": 0, # We only want facets, not results
|
|
546
|
+
}
|
|
547
|
+
params.update(filters)
|
|
548
|
+
|
|
549
|
+
data = await self._request("/search/", params)
|
|
550
|
+
|
|
551
|
+
facets = {}
|
|
552
|
+
for facet in data.get("facets", []):
|
|
553
|
+
field = facet.get("field", "")
|
|
554
|
+
terms = [
|
|
555
|
+
{"term": t.get("key", ""), "count": t.get("doc_count", 0)}
|
|
556
|
+
for t in facet.get("terms", [])
|
|
557
|
+
if t.get("doc_count", 0) > 0
|
|
558
|
+
]
|
|
559
|
+
if terms:
|
|
560
|
+
facets[field] = terms
|
|
561
|
+
|
|
562
|
+
self._set_cached(cache_key, facets)
|
|
563
|
+
return facets
|
|
564
|
+
|
|
565
|
+
# ------------------------------------------------------------------
|
|
566
|
+
# Utility
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def has_credentials(self) -> bool:
|
|
571
|
+
"""Check if authentication credentials are configured."""
|
|
572
|
+
return self._credential_manager.has_credentials
|
|
573
|
+
|
|
574
|
+
def store_credentials(self, access_key: str, secret_key: str) -> str:
|
|
575
|
+
"""Store ENCODE credentials securely. Returns storage location description."""
|
|
576
|
+
result = self._credential_manager.store_credentials(access_key, secret_key)
|
|
577
|
+
# Mark client for reset so next request uses new credentials
|
|
578
|
+
self._client = None
|
|
579
|
+
return result
|
|
580
|
+
|
|
581
|
+
def clear_credentials(self) -> None:
|
|
582
|
+
"""Remove stored credentials."""
|
|
583
|
+
self._credential_manager.clear_credentials()
|
|
584
|
+
# Mark client for reset
|
|
585
|
+
self._client = None
|