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,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