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,4 @@
1
+ """ENCODE Project connector - MCP server and Python client."""
2
+
3
+ __version__ = "0.2.1"
4
+ __author__ = "Dr. Alex M. Mawla, PhD"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m encode_connector"""
2
+
3
+ from encode_connector.server.main import main
4
+
5
+ main()
@@ -0,0 +1,6 @@
1
+ """ENCODE Project Python client library."""
2
+
3
+ from encode_connector.client.auth import CredentialManager
4
+ from encode_connector.client.encode_client import EncodeClient
5
+
6
+ __all__ = ["EncodeClient", "CredentialManager"]
@@ -0,0 +1,262 @@
1
+ """Secure credential management for ENCODE API authentication.
2
+
3
+ Credentials are stored in the OS keyring (macOS Keychain, Linux Secret Service,
4
+ Windows Credential Locker). Falls back to Fernet-encrypted file storage when
5
+ keyring is unavailable.
6
+
7
+ Credentials never appear in logs, error messages, or are sent anywhere
8
+ except the ENCODE API over HTTPS.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import logging
16
+ import os
17
+ import platform
18
+ from pathlib import Path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _get_machine_key(salt_path: Path | None = None) -> bytes:
24
+ """Derive a machine-specific encryption key for fallback file storage.
25
+
26
+ Uses PBKDF2 with a random salt stored alongside the credentials file.
27
+ The salt is generated once and reused for subsequent key derivations.
28
+ """
29
+ if salt_path is None:
30
+ salt_path = Path.home() / ".encode_connector" / ".salt"
31
+
32
+ # Generate or read the salt
33
+ if salt_path.exists():
34
+ salt = salt_path.read_bytes()
35
+ else:
36
+ salt_path.parent.mkdir(parents=True, exist_ok=True)
37
+ salt = os.urandom(32)
38
+ salt_path.write_bytes(salt)
39
+ salt_path.chmod(0o600)
40
+
41
+ # Combine machine-specific values as key material
42
+ import getpass
43
+
44
+ try:
45
+ login = getpass.getuser()
46
+ except (KeyError, OSError):
47
+ # getpass.getuser() raises KeyError in containers/CI where the user
48
+ # is not in the password database, or OSError in restricted environments
49
+ login = os.environ.get("USER", os.environ.get("USERNAME", "encode-user"))
50
+ material = f"{platform.node()}-{login}-encode-connector"
51
+
52
+ # Use PBKDF2 with 600,000 iterations (OWASP recommendation)
53
+ dk = hashlib.pbkdf2_hmac("sha256", material.encode(), salt, 600_000, dklen=32)
54
+ return base64.urlsafe_b64encode(dk)
55
+
56
+
57
+ class CredentialManager:
58
+ """Manages ENCODE API credentials with secure storage.
59
+
60
+ Storage priority:
61
+ 1. OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Locker)
62
+ 2. Fernet-encrypted file at ~/.encode_connector/credentials.enc
63
+ 3. Environment variables (read-only, for initial setup)
64
+ """
65
+
66
+ SERVICE_NAME = "encode-connector"
67
+ _fallback_dir = Path.home() / ".encode_connector"
68
+ _fallback_file = _fallback_dir / "credentials.enc"
69
+
70
+ def __init__(self) -> None:
71
+ self._access_key: str | None = None
72
+ self._secret_key: str | None = None
73
+ self._keyring_available: bool | None = None
74
+
75
+ def _check_keyring(self) -> bool:
76
+ """Check if OS keyring is available."""
77
+ if self._keyring_available is not None:
78
+ return self._keyring_available
79
+ try:
80
+ import keyring
81
+ from keyring.errors import NoKeyringError
82
+
83
+ try:
84
+ # Test keyring access
85
+ keyring.get_password(self.SERVICE_NAME, "__test__")
86
+ self._keyring_available = True
87
+ except NoKeyringError:
88
+ self._keyring_available = False
89
+ except Exception:
90
+ self._keyring_available = False
91
+ except ImportError:
92
+ self._keyring_available = False
93
+
94
+ return self._keyring_available
95
+
96
+ def _read_from_keyring(self) -> tuple[str | None, str | None]:
97
+ """Read credentials from OS keyring."""
98
+ if not self._check_keyring():
99
+ return None, None
100
+ try:
101
+ import keyring
102
+
103
+ access_key = keyring.get_password(self.SERVICE_NAME, "access_key")
104
+ secret_key = keyring.get_password(self.SERVICE_NAME, "secret_key")
105
+ return access_key, secret_key
106
+ except Exception:
107
+ return None, None
108
+
109
+ def _write_to_keyring(self, access_key: str, secret_key: str) -> bool:
110
+ """Store credentials in OS keyring. Returns True on success."""
111
+ if not self._check_keyring():
112
+ return False
113
+ try:
114
+ import keyring
115
+
116
+ keyring.set_password(self.SERVICE_NAME, "access_key", access_key)
117
+ keyring.set_password(self.SERVICE_NAME, "secret_key", secret_key)
118
+ logger.info("Credentials stored in OS keyring")
119
+ return True
120
+ except Exception as e:
121
+ logger.warning("Failed to store credentials in keyring: %s", type(e).__name__)
122
+ return False
123
+
124
+ def _read_from_encrypted_file(self) -> tuple[str | None, str | None]:
125
+ """Read credentials from Fernet-encrypted file."""
126
+ if not self._fallback_file.exists():
127
+ return None, None
128
+ try:
129
+ from cryptography.fernet import Fernet
130
+
131
+ key = _get_machine_key()
132
+ f = Fernet(key)
133
+ data = f.decrypt(self._fallback_file.read_bytes()).decode()
134
+ parts = data.split("\n", 1)
135
+ if len(parts) == 2:
136
+ return parts[0], parts[1]
137
+ except Exception:
138
+ logger.warning("Failed to decrypt credential file")
139
+ return None, None
140
+
141
+ def _write_to_encrypted_file(self, access_key: str, secret_key: str) -> bool:
142
+ """Store credentials in Fernet-encrypted file."""
143
+ try:
144
+ from cryptography.fernet import Fernet
145
+
146
+ self._fallback_dir.mkdir(parents=True, exist_ok=True)
147
+ # Restrict directory permissions
148
+ self._fallback_dir.chmod(0o700)
149
+
150
+ key = _get_machine_key()
151
+ f = Fernet(key)
152
+ data = f"{access_key}\n{secret_key}"
153
+ encrypted = f.encrypt(data.encode())
154
+ self._fallback_file.write_bytes(encrypted)
155
+ # Restrict file permissions
156
+ self._fallback_file.chmod(0o600)
157
+ logger.info("Credentials stored in encrypted file")
158
+ return True
159
+ except Exception as e:
160
+ logger.warning("Failed to write encrypted credential file: %s", type(e).__name__)
161
+ return False
162
+
163
+ def _read_from_env(self) -> tuple[str | None, str | None]:
164
+ """Read credentials from environment variables."""
165
+ access_key = os.environ.get("ENCODE_ACCESS_KEY")
166
+ secret_key = os.environ.get("ENCODE_SECRET_KEY")
167
+ if access_key and secret_key:
168
+ return access_key, secret_key
169
+ return None, None
170
+
171
+ def get_credentials(self) -> tuple[str | None, str | None]:
172
+ """Get ENCODE API credentials from the most secure available source.
173
+
174
+ Checks in order: cache -> keyring -> encrypted file -> env vars.
175
+ If found in env vars, migrates to keyring/encrypted file for future use.
176
+
177
+ Returns:
178
+ Tuple of (access_key, secret_key), both None if no credentials found.
179
+ """
180
+ # Check cache first
181
+ if self._access_key and self._secret_key:
182
+ return self._access_key, self._secret_key
183
+
184
+ # Try keyring
185
+ access_key, secret_key = self._read_from_keyring()
186
+ if access_key and secret_key:
187
+ self._access_key = access_key
188
+ self._secret_key = secret_key
189
+ return access_key, secret_key
190
+
191
+ # Try encrypted file
192
+ access_key, secret_key = self._read_from_encrypted_file()
193
+ if access_key and secret_key:
194
+ self._access_key = access_key
195
+ self._secret_key = secret_key
196
+ return access_key, secret_key
197
+
198
+ # Try env vars (and migrate to secure storage)
199
+ access_key, secret_key = self._read_from_env()
200
+ if access_key and secret_key:
201
+ self._access_key = access_key
202
+ self._secret_key = secret_key
203
+ # Migrate to secure storage
204
+ self.store_credentials(access_key, secret_key)
205
+ return access_key, secret_key
206
+
207
+ return None, None
208
+
209
+ def store_credentials(self, access_key: str, secret_key: str) -> str:
210
+ """Store credentials in the most secure available storage.
211
+
212
+ Returns:
213
+ Description of where credentials were stored.
214
+ """
215
+ self._access_key = access_key
216
+ self._secret_key = secret_key
217
+
218
+ if self._write_to_keyring(access_key, secret_key):
219
+ return "OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Locker)"
220
+
221
+ if self._write_to_encrypted_file(access_key, secret_key):
222
+ return f"Encrypted file ({self._fallback_file})"
223
+
224
+ return "Memory only (credentials will not persist across sessions)"
225
+
226
+ def clear_credentials(self) -> None:
227
+ """Remove all stored credentials."""
228
+ self._access_key = None
229
+ self._secret_key = None
230
+
231
+ # Clear keyring
232
+ if self._check_keyring():
233
+ try:
234
+ import keyring
235
+
236
+ keyring.delete_password(self.SERVICE_NAME, "access_key")
237
+ keyring.delete_password(self.SERVICE_NAME, "secret_key")
238
+ except Exception:
239
+ pass
240
+
241
+ # Clear encrypted file
242
+ if self._fallback_file.exists():
243
+ self._fallback_file.unlink()
244
+
245
+ @property
246
+ def has_credentials(self) -> bool:
247
+ """Check if credentials are available without revealing them."""
248
+ access_key, secret_key = self.get_credentials()
249
+ return bool(access_key and secret_key)
250
+
251
+ def get_auth_header(self) -> dict[str, str] | None:
252
+ """Get HTTP Basic auth header for ENCODE API.
253
+
254
+ Returns:
255
+ Dict with Authorization header, or None if no credentials.
256
+ """
257
+ access_key, secret_key = self.get_credentials()
258
+ if not access_key or not secret_key:
259
+ return None
260
+
261
+ token = base64.b64encode(f"{access_key}:{secret_key}".encode()).decode()
262
+ return {"Authorization": f"Basic {token}"}
@@ -0,0 +1,348 @@
1
+ """ENCODE API constants, endpoints, and known filter values."""
2
+
3
+ BASE_URL = "https://www.encodeproject.org"
4
+ SEARCH_ENDPOINT = "/search/"
5
+
6
+
7
+ # Rate limiting
8
+ MAX_REQUESTS_PER_SECOND = 10
9
+ DOWNLOAD_CONCURRENCY = 3
10
+
11
+ # Request defaults
12
+ DEFAULT_TIMEOUT = 30.0
13
+ DOWNLOAD_TIMEOUT = 300.0
14
+ DEFAULT_LIMIT = 25
15
+ try:
16
+ import importlib.metadata
17
+
18
+ _version = importlib.metadata.version("encode-toolkit")
19
+ except importlib.metadata.PackageNotFoundError:
20
+ _version = "0.3.0"
21
+ USER_AGENT = f"encode-toolkit/{_version} (MCP; +https://github.com/ammawla/encode-toolkit)"
22
+
23
+ # Keyring service name for credential storage
24
+ KEYRING_SERVICE = "encode-connector"
25
+ KEYRING_ACCESS_KEY = "access_key"
26
+ KEYRING_SECRET_KEY = "secret_key"
27
+
28
+ # -------------------------------------------------------------------
29
+ # Known ENCODE filter values (for metadata/autocomplete)
30
+ # -------------------------------------------------------------------
31
+
32
+ ASSAY_TITLES = [
33
+ "Histone ChIP-seq",
34
+ "TF ChIP-seq",
35
+ "Control ChIP-seq",
36
+ "Mint-ChIP-seq",
37
+ "ATAC-seq",
38
+ "DNase-seq",
39
+ "RNA-seq",
40
+ "total RNA-seq",
41
+ "small RNA-seq",
42
+ "long read RNA-seq",
43
+ "microRNA-seq",
44
+ "polyA plus RNA-seq",
45
+ "polyA minus RNA-seq",
46
+ "single-cell RNA sequencing assay",
47
+ "CAGE",
48
+ "RAMPAGE",
49
+ "RRBS",
50
+ "WGBS",
51
+ "whole-genome shotgun bisulfite sequencing",
52
+ "Hi-C",
53
+ "intact Hi-C",
54
+ "in situ Hi-C",
55
+ "Micro-C",
56
+ "ChIA-PET",
57
+ "HiChIP",
58
+ "PLAC-seq",
59
+ "PRO-seq",
60
+ "GRO-seq",
61
+ "CUT&RUN",
62
+ "CUT&Tag",
63
+ "STARR-seq",
64
+ "MPRA",
65
+ "CRISPR screen",
66
+ "proliferation CRISPR screen",
67
+ "FlowFISH CRISPR screen",
68
+ "eCLIP",
69
+ "iCLIP",
70
+ "shRNA knockdown followed by RNA-seq",
71
+ "siRNA knockdown followed by RNA-seq",
72
+ "CRISPRi followed by RNA-seq",
73
+ "MeDIP-seq",
74
+ "MRE-seq",
75
+ "MNase-seq",
76
+ "5C",
77
+ "BruUV-seq",
78
+ "genetic modification followed by DNase-seq",
79
+ "long read sequencing assay",
80
+ "direct RNA-seq",
81
+ "Parse SPLiT-seq",
82
+ "SHARE-seq",
83
+ "10x multiome",
84
+ "single-nucleus ATAC-seq",
85
+ "single-nucleus RNA-seq",
86
+ "snATAC-seq",
87
+ "Repli-seq",
88
+ "Repli-chip",
89
+ "Switchgear",
90
+ "genotyping HTS",
91
+ "whole genome sequencing assay",
92
+ ]
93
+
94
+ ORGANISMS = [
95
+ "Homo sapiens",
96
+ "Mus musculus",
97
+ "Drosophila melanogaster",
98
+ "Caenorhabditis elegans",
99
+ "Saccharomyces cerevisiae",
100
+ ]
101
+
102
+ BIOSAMPLE_CLASSIFICATIONS = [
103
+ "tissue",
104
+ "cell line",
105
+ "primary cell",
106
+ "in vitro differentiated cells",
107
+ "organoid",
108
+ "whole organisms",
109
+ "single cell",
110
+ "induced pluripotent stem cell line",
111
+ "stem cell",
112
+ ]
113
+
114
+ ORGAN_SLIMS = [
115
+ "adrenal gland",
116
+ "arterial blood vessel",
117
+ "bone element",
118
+ "bone marrow",
119
+ "brain",
120
+ "breast",
121
+ "bronchus",
122
+ "connective tissue",
123
+ "embryo",
124
+ "esophagus",
125
+ "extraembryonic component",
126
+ "eye",
127
+ "gonad",
128
+ "heart",
129
+ "intestine",
130
+ "kidney",
131
+ "large intestine",
132
+ "limb",
133
+ "liver",
134
+ "lung",
135
+ "lymph node",
136
+ "lymphoid tissue",
137
+ "mammary gland",
138
+ "mouth",
139
+ "musculature of body",
140
+ "nerve",
141
+ "nose",
142
+ "ovary",
143
+ "pancreas",
144
+ "penis",
145
+ "placenta",
146
+ "prostate gland",
147
+ "skeleton",
148
+ "skin of body",
149
+ "small intestine",
150
+ "spinal cord",
151
+ "spleen",
152
+ "stomach",
153
+ "testis",
154
+ "thymus",
155
+ "thyroid gland",
156
+ "tongue",
157
+ "tonsil",
158
+ "ureter",
159
+ "urinary bladder",
160
+ "uterus",
161
+ "vagina",
162
+ "vasculature",
163
+ ]
164
+
165
+ FILE_FORMATS = [
166
+ "fastq",
167
+ "bam",
168
+ "bed",
169
+ "bigWig",
170
+ "bigBed",
171
+ "tsv",
172
+ "csv",
173
+ "tar",
174
+ "hic",
175
+ "tagAlign",
176
+ "bedpe",
177
+ "pairs",
178
+ "fasta",
179
+ "gff",
180
+ "gtf",
181
+ "idat",
182
+ "CEL",
183
+ "rcc",
184
+ "sra",
185
+ "csfasta",
186
+ "csqual",
187
+ "2bit",
188
+ "database",
189
+ "vcf",
190
+ "bigInteract",
191
+ "idx",
192
+ "dat",
193
+ "txt",
194
+ ]
195
+
196
+ OUTPUT_TYPES = [
197
+ "reads",
198
+ "alignments",
199
+ "unfiltered alignments",
200
+ "transcriptome alignments",
201
+ "signal",
202
+ "signal of unique reads",
203
+ "signal of all reads",
204
+ "signal p-value",
205
+ "fold change over control",
206
+ "peaks",
207
+ "IDR thresholded peaks",
208
+ "conservative IDR thresholded peaks",
209
+ "optimal IDR thresholded peaks",
210
+ "pseudoreplicated peaks",
211
+ "replicated peaks",
212
+ "stable peaks",
213
+ "hotspots",
214
+ "narrowPeaks",
215
+ "broadPeaks",
216
+ "gappedPeaks",
217
+ "gene quantifications",
218
+ "transcript quantifications",
219
+ "exon quantifications",
220
+ "splice junctions",
221
+ "genome reference",
222
+ "genome index",
223
+ "transcriptome reference",
224
+ "transcriptome index",
225
+ "spike-in sequence",
226
+ "contact matrix",
227
+ "contact domains",
228
+ "topologically associated domains",
229
+ "chromatin interactions",
230
+ "DNA accessibility raw signal",
231
+ "DNA accessibility enrichment signal",
232
+ "methylation state at CpG",
233
+ "methylation state at CHG",
234
+ "methylation state at CHH",
235
+ "enrichment",
236
+ "FDR cut rate",
237
+ "element quantifications",
238
+ "guide quantifications",
239
+ ]
240
+
241
+ OUTPUT_CATEGORIES = [
242
+ "raw data",
243
+ "alignment",
244
+ "signal",
245
+ "annotation",
246
+ "quantification",
247
+ "reference",
248
+ "quality metric",
249
+ ]
250
+
251
+ FILE_STATUSES = [
252
+ "released",
253
+ "archived",
254
+ "in progress",
255
+ "revoked",
256
+ "deleted",
257
+ "content error",
258
+ "upload failed",
259
+ ]
260
+
261
+ EXPERIMENT_STATUSES = [
262
+ "released",
263
+ "archived",
264
+ "revoked",
265
+ "deleted",
266
+ "replaced",
267
+ "in progress",
268
+ "submitted",
269
+ "preliminary",
270
+ ]
271
+
272
+ ASSEMBLIES = [
273
+ "GRCh38",
274
+ "hg19",
275
+ "mm10",
276
+ "mm9",
277
+ "GRCm39",
278
+ "dm6",
279
+ "dm3",
280
+ "ce11",
281
+ "ce10",
282
+ ]
283
+
284
+ LIFE_STAGES = [
285
+ "embryonic",
286
+ "postnatal",
287
+ "newborn",
288
+ "child",
289
+ "adolescent",
290
+ "adult",
291
+ "unknown",
292
+ ]
293
+
294
+ REPLICATION_TYPES = [
295
+ "isogenic",
296
+ "anisogenic",
297
+ "unreplicated",
298
+ ]
299
+
300
+ # Map of metadata_type to its values for the get_metadata tool
301
+ METADATA_MAP = {
302
+ "assays": ASSAY_TITLES,
303
+ "organisms": ORGANISMS,
304
+ "organs": ORGAN_SLIMS,
305
+ "biosample_types": BIOSAMPLE_CLASSIFICATIONS,
306
+ "file_formats": FILE_FORMATS,
307
+ "output_types": OUTPUT_TYPES,
308
+ "output_categories": OUTPUT_CATEGORIES,
309
+ "assemblies": ASSEMBLIES,
310
+ "life_stages": LIFE_STAGES,
311
+ "replication_types": REPLICATION_TYPES,
312
+ "statuses": EXPERIMENT_STATUSES,
313
+ "file_statuses": FILE_STATUSES,
314
+ }
315
+
316
+ # ENCODE API parameter name mapping (user-friendly -> API param)
317
+ EXPERIMENT_FILTER_MAP = {
318
+ "assay_title": "assay_title",
319
+ "organism": "replicates.library.biosample.donor.organism.scientific_name",
320
+ "organ": "biosample_ontology.organ_slims",
321
+ "biosample_type": "biosample_ontology.classification",
322
+ "biosample_term_name": "biosample_ontology.term_name",
323
+ "target": "target.label",
324
+ "status": "status",
325
+ "lab": "lab.title",
326
+ "award": "award.project",
327
+ "assembly": "assembly",
328
+ "replication_type": "replication_type",
329
+ "life_stage": "replicates.library.biosample.life_stage",
330
+ "sex": "replicates.library.biosample.sex",
331
+ "perturbed": "replicates.library.biosample.perturbed",
332
+ "treatment": "replicates.library.biosample.treatments.treatment_term_name",
333
+ "genetic_modification": "replicates.library.biosample.applied_modifications.category",
334
+ "date_released": "date_released",
335
+ "searchTerm": "searchTerm",
336
+ }
337
+
338
+ FILE_FILTER_MAP = {
339
+ "file_format": "file_format",
340
+ "file_type": "file_type",
341
+ "output_type": "output_type",
342
+ "output_category": "output_category",
343
+ "assembly": "assembly",
344
+ "status": "status",
345
+ "biological_replicates": "biological_replicates",
346
+ "preferred_default": "preferred_default",
347
+ "dataset": "dataset",
348
+ }