encode-toolkit 0.3.0__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 +450 -0
- encode_connector/client/downloader.py +305 -0
- encode_connector/client/encode_client.py +586 -0
- encode_connector/client/models.py +367 -0
- encode_connector/client/tracker.py +1129 -0
- encode_connector/client/validation.py +226 -0
- encode_connector/server/__init__.py +1 -0
- encode_connector/server/__main__.py +5 -0
- encode_connector/server/main.py +1544 -0
- encode_toolkit-0.3.0.dist-info/METADATA +843 -0
- encode_toolkit-0.3.0.dist-info/RECORD +18 -0
- encode_toolkit-0.3.0.dist-info/WHEEL +4 -0
- encode_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
- encode_toolkit-0.3.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Input validation utilities for security.
|
|
2
|
+
|
|
3
|
+
Centralizes all input validation to prevent injection, SSRF, and path traversal.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
# ENCODE accession format: ENC + 2-4 letter type + 3-8 alphanumeric
|
|
11
|
+
ACCESSION_RE = re.compile(r"^ENC[A-Z]{2,4}[A-Z0-9]{3,8}$")
|
|
12
|
+
|
|
13
|
+
# Date format: YYYY-MM-DD
|
|
14
|
+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
15
|
+
|
|
16
|
+
# ENCODE API relative path: /type/id/ or /type/
|
|
17
|
+
ENCODE_PATH_RE = re.compile(r"^/[a-zA-Z0-9][a-zA-Z0-9/_@.+-]*/?$")
|
|
18
|
+
|
|
19
|
+
# Lucene special characters that need escaping
|
|
20
|
+
_LUCENE_SPECIAL = re.compile(r'([+\-&|!(){}\[\]^"~*?:\\/])')
|
|
21
|
+
|
|
22
|
+
# Valid organize_by values
|
|
23
|
+
VALID_ORGANIZE_BY = frozenset({"flat", "experiment", "format", "experiment_format"})
|
|
24
|
+
|
|
25
|
+
# Valid export formats
|
|
26
|
+
VALID_EXPORT_FORMATS = frozenset({"json", "bibtex", "ris"})
|
|
27
|
+
|
|
28
|
+
# Allowed download hosts
|
|
29
|
+
ALLOWED_DOWNLOAD_HOSTS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"www.encodeproject.org",
|
|
32
|
+
"encodeproject.org",
|
|
33
|
+
"encode-public.s3.amazonaws.com",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Allowed redirect hosts (includes S3 regions used by ENCODE)
|
|
38
|
+
ALLOWED_REDIRECT_HOSTS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"www.encodeproject.org",
|
|
41
|
+
"encodeproject.org",
|
|
42
|
+
"encode-public.s3.amazonaws.com",
|
|
43
|
+
"encode-files.s3.amazonaws.com",
|
|
44
|
+
"s3.amazonaws.com",
|
|
45
|
+
"encode-public.s3.us-west-2.amazonaws.com",
|
|
46
|
+
"encode-files.s3.us-west-2.amazonaws.com",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Maximum limit for API queries
|
|
51
|
+
MAX_QUERY_LIMIT = 1000
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Valid external reference types for cross-server integration
|
|
55
|
+
VALID_REFERENCE_TYPES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"pmid",
|
|
58
|
+
"doi",
|
|
59
|
+
"nct_id",
|
|
60
|
+
"preprint_doi",
|
|
61
|
+
"geo_accession",
|
|
62
|
+
"other",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Valid data export formats
|
|
67
|
+
VALID_DATA_EXPORT_FORMATS = frozenset({"csv", "tsv", "json"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_accession(accession: str) -> str:
|
|
71
|
+
"""Validate ENCODE accession format. Raises ValueError if invalid."""
|
|
72
|
+
if not ACCESSION_RE.match(accession):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Invalid ENCODE accession format: {accession!r}. "
|
|
75
|
+
"Expected format like ENCSR133RZO (experiments) or ENCFF635JIA (files). "
|
|
76
|
+
"Accessions start with ENC followed by 2-4 uppercase letters "
|
|
77
|
+
"and 3-8 alphanumeric characters."
|
|
78
|
+
)
|
|
79
|
+
return accession
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_date(date_str: str) -> str:
|
|
83
|
+
"""Validate date format (YYYY-MM-DD) and calendar correctness.
|
|
84
|
+
|
|
85
|
+
Raises ValueError if format is wrong or date doesn't exist
|
|
86
|
+
(e.g., 2024-02-30, 2024-13-01).
|
|
87
|
+
"""
|
|
88
|
+
if not DATE_RE.match(date_str):
|
|
89
|
+
raise ValueError(f"Invalid date format: {date_str!r}. Use YYYY-MM-DD.")
|
|
90
|
+
# Validate that the date is a real calendar date
|
|
91
|
+
import datetime
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
datetime.date.fromisoformat(date_str)
|
|
95
|
+
except ValueError:
|
|
96
|
+
raise ValueError(f"Invalid calendar date: {date_str!r}. Date does not exist.")
|
|
97
|
+
return date_str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_encode_path(path: str) -> str:
|
|
101
|
+
"""Validate that a path is a relative ENCODE API path, never a full URL."""
|
|
102
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
103
|
+
raise ValueError(f"Refusing to follow absolute URL from API response: {path!r}")
|
|
104
|
+
if not ENCODE_PATH_RE.match(path):
|
|
105
|
+
raise ValueError(f"Invalid ENCODE API path: {path!r}")
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def escape_lucene(value: str) -> str:
|
|
110
|
+
"""Escape Lucene special characters in a query value."""
|
|
111
|
+
return _LUCENE_SPECIAL.sub(r"\\\1", value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_organize_by(value: str) -> str:
|
|
115
|
+
"""Validate organize_by parameter."""
|
|
116
|
+
if value not in VALID_ORGANIZE_BY:
|
|
117
|
+
raise ValueError(f"organize_by must be one of {sorted(VALID_ORGANIZE_BY)}, got {value!r}")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_export_format(value: str) -> str:
|
|
122
|
+
"""Validate export format parameter."""
|
|
123
|
+
if value not in VALID_EXPORT_FORMATS:
|
|
124
|
+
raise ValueError(f"export_format must be one of {sorted(VALID_EXPORT_FORMATS)}, got {value!r}")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_download_url(url: str) -> str:
|
|
129
|
+
"""Validate that a download URL points to an allowed host."""
|
|
130
|
+
from urllib.parse import urlparse
|
|
131
|
+
|
|
132
|
+
parsed = urlparse(url)
|
|
133
|
+
if parsed.scheme and parsed.scheme != "https":
|
|
134
|
+
raise ValueError(f"Only HTTPS downloads allowed, got: {parsed.scheme}")
|
|
135
|
+
if parsed.netloc and parsed.netloc not in ALLOWED_DOWNLOAD_HOSTS:
|
|
136
|
+
raise ValueError(f"Download host not allowed: {parsed.netloc}")
|
|
137
|
+
return url
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def safe_path_component(value: str, max_len: int = 64) -> str:
|
|
141
|
+
"""Sanitize a string for use as a filesystem path component."""
|
|
142
|
+
cleaned = re.sub(r"[^a-zA-Z0-9._-]", "_", value)[:max_len]
|
|
143
|
+
if not cleaned or cleaned in (".", ".."):
|
|
144
|
+
raise ValueError(f"Unsafe path component: {value!r}")
|
|
145
|
+
return cleaned
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def clamp_limit(limit: int) -> int:
|
|
149
|
+
"""Clamp a query limit to a safe maximum."""
|
|
150
|
+
return max(1, min(limit, MAX_QUERY_LIMIT))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def escape_like(value: str) -> str:
|
|
154
|
+
"""Escape SQL LIKE wildcard characters."""
|
|
155
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate_reference_type(value: str) -> str:
|
|
159
|
+
"""Validate external reference type for cross-server linking."""
|
|
160
|
+
if value not in VALID_REFERENCE_TYPES:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"reference_type must be one of {sorted(VALID_REFERENCE_TYPES)}, got {value!r}. "
|
|
163
|
+
"Use 'pmid' for PubMed IDs, 'doi' for DOIs, 'nct_id' for ClinicalTrials.gov, "
|
|
164
|
+
"'preprint_doi' for bioRxiv/medRxiv, 'geo_accession' for GEO, or 'other'."
|
|
165
|
+
)
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def validate_data_export_format(value: str) -> str:
|
|
170
|
+
"""Validate data export format parameter."""
|
|
171
|
+
if value not in VALID_DATA_EXPORT_FORMATS:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"format must be one of {sorted(VALID_DATA_EXPORT_FORMATS)}, got {value!r}. "
|
|
174
|
+
"Use 'csv' for spreadsheets, 'tsv' for tab-separated, or 'json' for programmatic use."
|
|
175
|
+
)
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Pre-built case-insensitive lookup maps (keyed by tuple for stable identity)
|
|
180
|
+
_LOWER_MAPS: dict[tuple[str, ...], dict[str, str]] = {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get_lower_map(valid_values: list[str]) -> dict[str, str]:
|
|
184
|
+
"""Get or build a cached lowercase→original map for a constants list."""
|
|
185
|
+
key = tuple(valid_values)
|
|
186
|
+
if key not in _LOWER_MAPS:
|
|
187
|
+
_LOWER_MAPS[key] = {v.lower(): v for v in valid_values}
|
|
188
|
+
return _LOWER_MAPS[key]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_filter_value(value: str, valid_values: list[str], filter_name: str) -> str | None:
|
|
192
|
+
"""Check if a filter value is in the known valid values.
|
|
193
|
+
|
|
194
|
+
Returns a warning message if the value doesn't match any known value,
|
|
195
|
+
or None if it's valid. Does NOT raise — unknown values may be valid
|
|
196
|
+
if ENCODE has added new values since the constants were last updated.
|
|
197
|
+
"""
|
|
198
|
+
if not isinstance(value, str) or not value:
|
|
199
|
+
return None
|
|
200
|
+
if value in valid_values:
|
|
201
|
+
return None
|
|
202
|
+
# Case-insensitive check using cached map
|
|
203
|
+
lower_map = _get_lower_map(valid_values)
|
|
204
|
+
lower_value = value.lower()
|
|
205
|
+
if lower_value in lower_map:
|
|
206
|
+
correct = lower_map[lower_value]
|
|
207
|
+
return (
|
|
208
|
+
f"Filter '{filter_name}' value '{value}' has wrong case. "
|
|
209
|
+
f"ENCODE API is case-sensitive — use '{correct}' instead."
|
|
210
|
+
)
|
|
211
|
+
return (
|
|
212
|
+
f"Filter '{filter_name}' value '{value}' not found in known ENCODE values. "
|
|
213
|
+
f"This may cause empty results. Check encode_get_metadata for valid values."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_redirect_url(url: str) -> str:
|
|
218
|
+
"""Validate that a redirect URL points to an allowed ENCODE/S3 host."""
|
|
219
|
+
from urllib.parse import urlparse
|
|
220
|
+
|
|
221
|
+
parsed = urlparse(url)
|
|
222
|
+
if parsed.scheme and parsed.scheme != "https":
|
|
223
|
+
raise ValueError(f"Only HTTPS redirects allowed, got: {parsed.scheme}")
|
|
224
|
+
if parsed.netloc and parsed.netloc not in ALLOWED_REDIRECT_HOSTS:
|
|
225
|
+
raise ValueError(f"Redirect host not allowed: {parsed.netloc}")
|
|
226
|
+
return url
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ENCODE Project MCP server."""
|