dnakit 0.1.0.dev0__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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,874 @@
1
+ """Ensembl REST adapters for coordinates, annotation, variants, and compara."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from typing import Literal, cast
8
+ from urllib.parse import quote
9
+
10
+ from dnakit.exceptions import ConfigurationError, QueryError
11
+
12
+ from ._http import (
13
+ build_url,
14
+ limited_records,
15
+ mapping_records,
16
+ redact_url,
17
+ request_json,
18
+ require_text,
19
+ )
20
+ from ._shared import adapter_provenance, query_values, resolved_config
21
+ from .models import QueryProgress, QueryResult, SearchConfig
22
+
23
+ DEFAULT_ENSEMBL_REST_URL = "https://rest.ensembl.org"
24
+ _REGION = re.compile(r"^(?P<name>[^:\s]+):(?P<start>\d+)-(?P<end>\d+)$")
25
+ _ENSEMBL_ID = re.compile(r"^ENS[A-Z0-9]*[GTP]\d+(?:\.\d+)?$", flags=re.IGNORECASE)
26
+ _FEATURES = frozenset(
27
+ {
28
+ "band",
29
+ "cds",
30
+ "constrained",
31
+ "exon",
32
+ "gene",
33
+ "mane",
34
+ "misc",
35
+ "motif",
36
+ "regulatory",
37
+ "repeat",
38
+ "simple",
39
+ "somatic_structural_variation",
40
+ "somatic_variation",
41
+ "structural_variation",
42
+ "transcript",
43
+ "variation",
44
+ }
45
+ )
46
+
47
+
48
+ def _species(value: str) -> str:
49
+ return require_text(value, "species", max_length=256)
50
+
51
+
52
+ def _bounded_non_negative(value: int, name: str, *, maximum: int = 5_000_000) -> int:
53
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
54
+ raise ConfigurationError(
55
+ f"{name} must be an integer in [0, {maximum}].", code="INVALID_QUERY"
56
+ )
57
+ return value
58
+
59
+
60
+ def _parse_region(region: str, *, max_span: int) -> tuple[str, int, int]:
61
+ value = require_text(region, "region", max_length=512)
62
+ match = _REGION.fullmatch(value)
63
+ if match is None:
64
+ raise ConfigurationError(
65
+ "region must use 'chromosome:start-end' with 0-based half-open coordinates.",
66
+ code="INVALID_REGION_QUERY",
67
+ )
68
+ name = match.group("name")
69
+ start = int(match.group("start"))
70
+ end = int(match.group("end"))
71
+ if end <= start:
72
+ raise ConfigurationError(
73
+ "region end must be greater than start.", code="INVALID_REGION_QUERY"
74
+ )
75
+ if end - start > max_span:
76
+ raise ConfigurationError(
77
+ "region exceeds this Ensembl endpoint's span limit.",
78
+ code="REGION_QUERY_LIMIT",
79
+ context={"span": end - start, "max_span": max_span},
80
+ )
81
+ return name, start, end
82
+
83
+
84
+ def _ensembl_region(region: str, *, strand: int, max_span: int) -> tuple[str, int, int, str]:
85
+ if strand not in {-1, 1}:
86
+ raise ConfigurationError("strand must be 1 or -1.", code="INVALID_REGION_QUERY")
87
+ name, start, end = _parse_region(region, max_span=max_span)
88
+ provider_region = f"{name}:{start + 1}..{end}:{strand}"
89
+ return name, start, end, provider_region
90
+
91
+
92
+ def _response_records(
93
+ payload: object, *, keys: Sequence[str] = ()
94
+ ) -> tuple[dict[str, object], ...]:
95
+ if isinstance(payload, list):
96
+ return mapping_records(payload)
97
+ if not isinstance(payload, Mapping):
98
+ return ()
99
+ for key in keys:
100
+ records = mapping_records(payload, key=key)
101
+ if records:
102
+ return records
103
+ return (dict(payload),)
104
+
105
+
106
+ def _with_zero_based(record: Mapping[str, object]) -> dict[str, object]:
107
+ normalized = dict(record)
108
+ start = record.get("start")
109
+ end = record.get("end")
110
+ if (
111
+ isinstance(start, int)
112
+ and not isinstance(start, bool)
113
+ and isinstance(end, int)
114
+ and not isinstance(end, bool)
115
+ and start >= 1
116
+ and end >= start
117
+ ):
118
+ normalized["start_0based"] = start - 1
119
+ normalized["end_0based"] = end
120
+ for key in ("mappings", "data", "Transcript", "Exon", "UTR"):
121
+ nested = record.get(key)
122
+ if isinstance(nested, list):
123
+ normalized[key] = tuple(
124
+ _with_zero_based(item) if isinstance(item, Mapping) else item for item in nested
125
+ )
126
+ for key in ("mapped", "original", "Translation"):
127
+ nested_mapping = record.get(key)
128
+ if isinstance(nested_mapping, Mapping):
129
+ normalized[key] = _with_zero_based(nested_mapping)
130
+ return normalized
131
+
132
+
133
+ def _result(
134
+ query_type: str,
135
+ url: str,
136
+ payload: object,
137
+ config: SearchConfig,
138
+ *,
139
+ record_keys: Sequence[str] = (),
140
+ filters: Mapping[str, object] | None = None,
141
+ metadata: Mapping[str, object] | None = None,
142
+ normalize_coordinates: bool = False,
143
+ ) -> QueryResult:
144
+ records = _response_records(payload, keys=record_keys)
145
+ if normalize_coordinates:
146
+ records = tuple(_with_zero_based(record) for record in records)
147
+ records = limited_records(records, config)
148
+ return QueryResult(
149
+ query_type,
150
+ "Ensembl",
151
+ redact_url(url),
152
+ records,
153
+ adapter_provenance(
154
+ "Ensembl REST",
155
+ citation_url="https://rest.ensembl.org/",
156
+ filters=filters,
157
+ ),
158
+ total_count=len(records),
159
+ metadata=metadata,
160
+ )
161
+
162
+
163
+ def sequence(
164
+ species: str,
165
+ region: str | Sequence[str],
166
+ *,
167
+ strand: Literal[-1, 1] = 1,
168
+ upstream: int = 0,
169
+ downstream: int = 0,
170
+ mask: Literal["hard", "soft"] | None = None,
171
+ progress: Callable[[QueryProgress], None] | None = None,
172
+ config: SearchConfig | None = None,
173
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
174
+ ) -> QueryResult:
175
+ """Fetch one or more coordinate sequences using 0-based half-open regions."""
176
+
177
+ resolved = resolved_config(config)
178
+ organism = _species(species)
179
+ regions = query_values(region, name="region", maximum=min(100, resolved.max_records))
180
+ flank_5 = _bounded_non_negative(upstream, "upstream")
181
+ flank_3 = _bounded_non_negative(downstream, "downstream")
182
+ if mask not in {None, "hard", "soft"}:
183
+ raise ConfigurationError("mask must be None, hard, or soft.", code="INVALID_QUERY")
184
+ if progress is not None and not callable(progress):
185
+ raise ConfigurationError("progress must be callable or None.", code="INVALID_QUERY")
186
+ records: list[dict[str, object]] = []
187
+ urls: list[str] = []
188
+ for index, item in enumerate(regions, start=1):
189
+ _, start, end, provider_region = _ensembl_region(item, strand=strand, max_span=10_000_000)
190
+ url = build_url(
191
+ api_base_url,
192
+ f"/sequence/region/{quote(organism, safe='')}/{quote(provider_region, safe=':.-_')}",
193
+ (("expand_5prime", flank_5), ("expand_3prime", flank_3), ("mask", mask)),
194
+ )
195
+ payload = request_json(url, resolved, provider="Ensembl")
196
+ if not isinstance(payload, Mapping):
197
+ raise QueryError(
198
+ "Ensembl sequence returned an unexpected response.", code="QUERY_RESPONSE_ERROR"
199
+ )
200
+ record = dict(payload)
201
+ record.update(
202
+ {
203
+ "requested_region": item,
204
+ "requested_start_0based": start,
205
+ "requested_end_0based": end,
206
+ "requested_strand": strand,
207
+ }
208
+ )
209
+ records.append(record)
210
+ urls.append(redact_url(url))
211
+ if progress is not None:
212
+ progress(QueryProgress(index, len(regions), item))
213
+ records_tuple = limited_records(records, resolved)
214
+ return QueryResult(
215
+ "sequence",
216
+ "Ensembl",
217
+ urls[0] if len(urls) == 1 else f"{api_base_url.rstrip('/')}/sequence/region/[batch]",
218
+ records_tuple,
219
+ adapter_provenance(
220
+ "Ensembl REST",
221
+ citation_url="https://rest.ensembl.org/documentation/info/sequence_region",
222
+ filters={
223
+ "species": organism,
224
+ "regions": regions,
225
+ "strand": strand,
226
+ "upstream": flank_5,
227
+ "downstream": flank_3,
228
+ "mask": mask,
229
+ },
230
+ ),
231
+ total_count=len(records_tuple),
232
+ metadata={
233
+ "input_coordinate_system": "0-based half-open",
234
+ "provider_coordinate_system": "1-based inclusive",
235
+ "request_urls": tuple(urls),
236
+ },
237
+ )
238
+
239
+
240
+ def sequence_by_id(
241
+ identifier: str,
242
+ *,
243
+ sequence_type: Literal["genomic", "cds", "cdna", "protein"] = "genomic",
244
+ species: str | None = None,
245
+ mask: Literal["hard", "soft"] | None = None,
246
+ config: SearchConfig | None = None,
247
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
248
+ ) -> QueryResult:
249
+ """Fetch genomic, cDNA, CDS, or protein sequence for an Ensembl stable ID."""
250
+
251
+ resolved = resolved_config(config)
252
+ stable_id = require_text(identifier, "identifier", max_length=256)
253
+ if sequence_type not in {"genomic", "cds", "cdna", "protein"}:
254
+ raise ConfigurationError("Unsupported sequence_type.", code="INVALID_QUERY")
255
+ if mask not in {None, "hard", "soft"}:
256
+ raise ConfigurationError("mask must be None, hard, or soft.", code="INVALID_QUERY")
257
+ url = build_url(
258
+ api_base_url,
259
+ f"/sequence/id/{quote(stable_id, safe='._-')}",
260
+ (
261
+ ("type", sequence_type),
262
+ ("species", None if species is None else _species(species)),
263
+ ("mask", mask),
264
+ ),
265
+ )
266
+ payload = request_json(url, resolved, provider="Ensembl")
267
+ return _result(
268
+ "sequence",
269
+ url,
270
+ payload,
271
+ resolved,
272
+ filters={"identifier": stable_id, "sequence_type": sequence_type, "species": species},
273
+ metadata={"sequence_type": sequence_type},
274
+ )
275
+
276
+
277
+ def lookup(
278
+ identifier: str,
279
+ *,
280
+ species: str | None = None,
281
+ expand: bool = True,
282
+ mane: bool = True,
283
+ phenotypes: bool = False,
284
+ utr: bool = True,
285
+ config: SearchConfig | None = None,
286
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
287
+ ) -> QueryResult:
288
+ """Look up one Ensembl gene/transcript/protein ID and connected features."""
289
+
290
+ resolved = resolved_config(config)
291
+ stable_id = require_text(identifier, "identifier", max_length=256)
292
+ for name, value in (
293
+ ("expand", expand),
294
+ ("mane", mane),
295
+ ("phenotypes", phenotypes),
296
+ ("utr", utr),
297
+ ):
298
+ if not isinstance(value, bool):
299
+ raise ConfigurationError(f"{name} must be boolean.", code="INVALID_QUERY")
300
+ if not expand and (mane or utr):
301
+ raise ConfigurationError("mane and utr require expand=True.", code="INVALID_QUERY")
302
+ organism = None if species is None else _species(species)
303
+ url = build_url(
304
+ api_base_url,
305
+ f"/lookup/id/{quote(stable_id, safe='._-')}",
306
+ (
307
+ ("species", organism),
308
+ ("expand", expand),
309
+ ("format", "full"),
310
+ ("mane", mane),
311
+ ("phenotypes", phenotypes),
312
+ ("utr", utr),
313
+ ),
314
+ )
315
+ payload = request_json(url, resolved, provider="Ensembl")
316
+ return _result(
317
+ "feature",
318
+ url,
319
+ payload,
320
+ resolved,
321
+ filters={
322
+ "identifier": stable_id,
323
+ "species": organism,
324
+ "expand": expand,
325
+ "mane": mane,
326
+ "phenotypes": phenotypes,
327
+ "utr": utr,
328
+ },
329
+ normalize_coordinates=True,
330
+ )
331
+
332
+
333
+ def transcripts(
334
+ query: str,
335
+ *,
336
+ species: str = "human",
337
+ by: Literal["auto", "id", "symbol"] = "auto",
338
+ expand: bool = True,
339
+ config: SearchConfig | None = None,
340
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
341
+ ) -> QueryResult:
342
+ """Query transcripts, exons, translations, and canonical status by ID or symbol."""
343
+
344
+ resolved = resolved_config(config)
345
+ value = require_text(query, "query", max_length=256)
346
+ organism = _species(species)
347
+ if not isinstance(expand, bool):
348
+ raise ConfigurationError("expand must be boolean.", code="INVALID_QUERY")
349
+ if by == "auto":
350
+ by = "id" if _ENSEMBL_ID.fullmatch(value) else "symbol"
351
+ if by == "id":
352
+ result = lookup(
353
+ value,
354
+ species=organism,
355
+ expand=expand,
356
+ mane=expand,
357
+ utr=expand,
358
+ config=resolved,
359
+ api_base_url=api_base_url,
360
+ )
361
+ elif by == "symbol":
362
+ url = build_url(
363
+ api_base_url,
364
+ f"/lookup/symbol/{quote(organism, safe='')}/{quote(value, safe='._-')}",
365
+ (("expand", expand), ("format", "full")),
366
+ )
367
+ payload = request_json(url, resolved, provider="Ensembl")
368
+ result = _result(
369
+ "feature",
370
+ url,
371
+ payload,
372
+ resolved,
373
+ filters={"query": value, "species": organism, "by": by, "expand": expand},
374
+ normalize_coordinates=True,
375
+ )
376
+ else:
377
+ raise ConfigurationError(
378
+ "transcripts by must be auto, id, or symbol.", code="INVALID_QUERY"
379
+ )
380
+ return QueryResult(
381
+ "transcripts",
382
+ result.provider,
383
+ result.request_url,
384
+ result.records,
385
+ result.provenance,
386
+ total_count=result.total_count,
387
+ metadata={**dict(result.metadata), "query": value, "by": by},
388
+ )
389
+
390
+
391
+ def annotation(
392
+ species: str,
393
+ region: str,
394
+ *,
395
+ features: Sequence[str] = ("gene", "transcript", "exon"),
396
+ strand: Literal[-1, 1] = 1,
397
+ biotype: str | None = None,
398
+ config: SearchConfig | None = None,
399
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
400
+ ) -> QueryResult:
401
+ """Query genes, transcripts, repeats, variants, and regulation over a region."""
402
+
403
+ resolved = resolved_config(config)
404
+ organism = _species(species)
405
+ _, start, end, provider_region = _ensembl_region(region, strand=strand, max_span=5_000_000)
406
+ feature_values = tuple(require_text(item, "feature", max_length=64) for item in features)
407
+ if not feature_values or any(item not in _FEATURES for item in feature_values):
408
+ raise ConfigurationError(
409
+ "features contains an unsupported Ensembl overlap feature.",
410
+ code="INVALID_QUERY",
411
+ context={"allowed": sorted(_FEATURES)},
412
+ )
413
+ url = build_url(
414
+ api_base_url,
415
+ f"/overlap/region/{quote(organism, safe='')}/{quote(provider_region, safe=':.-_')}",
416
+ (("feature", feature_values), ("biotype", biotype)),
417
+ )
418
+ payload = request_json(url, resolved, provider="Ensembl")
419
+ return _result(
420
+ "annotation",
421
+ url,
422
+ payload,
423
+ resolved,
424
+ filters={"species": organism, "region": region, "features": feature_values},
425
+ metadata={
426
+ "input_coordinate_system": "0-based half-open",
427
+ "provider_coordinate_system": "1-based inclusive",
428
+ "requested_start_0based": start,
429
+ "requested_end_0based": end,
430
+ },
431
+ normalize_coordinates=True,
432
+ )
433
+
434
+
435
+ def nearby(
436
+ species: str,
437
+ region: str,
438
+ *,
439
+ feature: Literal["gene", "transcript", "repeat", "variation", "regulatory"] = "gene",
440
+ distance: int = 100_000,
441
+ limit: int = 20,
442
+ config: SearchConfig | None = None,
443
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
444
+ ) -> QueryResult:
445
+ """Find nearest Ensembl features within a bounded flanking window."""
446
+
447
+ resolved = resolved_config(config)
448
+ flank = _bounded_non_negative(distance, "distance", maximum=2_499_999)
449
+ if (
450
+ isinstance(limit, bool)
451
+ or not isinstance(limit, int)
452
+ or not 1 <= limit <= resolved.max_records
453
+ ):
454
+ raise ConfigurationError(
455
+ "limit exceeds SearchConfig max_records.", code="QUERY_RECORD_LIMIT"
456
+ )
457
+ chromosome, start, end = _parse_region(region, max_span=5_000_000)
458
+ expanded_start = max(0, start - flank)
459
+ expanded_end = end + flank
460
+ if expanded_end - expanded_start > 5_000_000:
461
+ raise ConfigurationError(
462
+ "region plus distance exceeds Ensembl's 5 Mb overlap limit.",
463
+ code="REGION_QUERY_LIMIT",
464
+ )
465
+ overlap = annotation(
466
+ species,
467
+ f"{chromosome}:{expanded_start}-{expanded_end}",
468
+ features=(feature,),
469
+ config=resolved,
470
+ api_base_url=api_base_url,
471
+ )
472
+ ranked: list[dict[str, object]] = []
473
+ for record in overlap.records:
474
+ item = cast(dict[str, object], dict(record))
475
+ item_start = item.get("start_0based")
476
+ item_end = item.get("end_0based")
477
+ if not isinstance(item_start, int) or not isinstance(item_end, int):
478
+ continue
479
+ if item_end <= start:
480
+ gap = start - item_end
481
+ elif item_start >= end:
482
+ gap = item_start - end
483
+ else:
484
+ gap = 0
485
+ item["distance_bp"] = gap
486
+ ranked.append(item)
487
+ ranked.sort(
488
+ key=lambda item: (
489
+ item["distance_bp"] if isinstance(item.get("distance_bp"), int) else 2**63,
490
+ str(item.get("id", "")),
491
+ )
492
+ )
493
+ selected = tuple(ranked[:limit])
494
+ return QueryResult(
495
+ "nearby",
496
+ "Ensembl",
497
+ overlap.request_url,
498
+ selected,
499
+ overlap.provenance,
500
+ total_count=len(ranked),
501
+ metadata={
502
+ "query_region": region,
503
+ "distance": flank,
504
+ "feature": feature,
505
+ "input_coordinate_system": "0-based half-open",
506
+ },
507
+ )
508
+
509
+
510
+ def variant(
511
+ query: str,
512
+ *,
513
+ species: str = "human",
514
+ mode: Literal["auto", "id", "region", "hgvs"] = "auto",
515
+ include_frequencies: bool = True,
516
+ include_genotypes: bool = False,
517
+ include_phenotypes: bool = True,
518
+ config: SearchConfig | None = None,
519
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
520
+ ) -> QueryResult:
521
+ """Query variant records, population frequencies, or VEP consequences."""
522
+
523
+ resolved = resolved_config(config)
524
+ organism = _species(species)
525
+ value = require_text(query, "query", max_length=2_048)
526
+ if mode == "auto":
527
+ if _REGION.fullmatch(value):
528
+ mode = "region"
529
+ elif re.search(r":[cgmnpr]\.\S+", value, flags=re.IGNORECASE):
530
+ mode = "hgvs"
531
+ else:
532
+ mode = "id"
533
+ if mode == "region":
534
+ _, _, _, provider_region = _ensembl_region(value, strand=1, max_span=5_000_000)
535
+ url = build_url(
536
+ api_base_url,
537
+ f"/overlap/region/{quote(organism, safe='')}/{quote(provider_region, safe=':.-_')}",
538
+ (("feature", ("variation", "structural_variation")),),
539
+ )
540
+ keys: Sequence[str] = ()
541
+ elif mode == "hgvs":
542
+ url = build_url(
543
+ api_base_url,
544
+ f"/vep/{quote(organism, safe='')}/hgvs/{quote(value, safe='._:-')}",
545
+ (("hgvs", True), ("mane", True), ("variant_class", True)),
546
+ )
547
+ keys = ()
548
+ elif mode == "id":
549
+ url = build_url(
550
+ api_base_url,
551
+ f"/variation/{quote(organism, safe='')}/{quote(value, safe='._:-')}",
552
+ (
553
+ ("pops", include_frequencies),
554
+ ("population_genotypes", include_frequencies),
555
+ ("genotypes", include_genotypes),
556
+ ("phenotypes", include_phenotypes),
557
+ ),
558
+ )
559
+ keys = ()
560
+ else:
561
+ raise ConfigurationError("Unsupported variant query mode.", code="INVALID_QUERY")
562
+ payload = request_json(url, resolved, provider="Ensembl")
563
+ return _result(
564
+ "variant",
565
+ url,
566
+ payload,
567
+ resolved,
568
+ record_keys=keys,
569
+ filters={"query": value, "species": organism, "mode": mode},
570
+ metadata={"mode": mode},
571
+ normalize_coordinates=True,
572
+ )
573
+
574
+
575
+ def regulation(
576
+ species: str,
577
+ region: str,
578
+ *,
579
+ include_motifs: bool = True,
580
+ include_constrained: bool = False,
581
+ config: SearchConfig | None = None,
582
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
583
+ ) -> QueryResult:
584
+ """Query Ensembl regulatory features, motifs, and optional constrained elements."""
585
+
586
+ features = ["regulatory"]
587
+ if include_motifs:
588
+ features.append("motif")
589
+ if include_constrained:
590
+ features.append("constrained")
591
+ result = annotation(
592
+ species,
593
+ region,
594
+ features=tuple(features),
595
+ config=config,
596
+ api_base_url=api_base_url,
597
+ )
598
+ return QueryResult(
599
+ "regulation",
600
+ result.provider,
601
+ result.request_url,
602
+ result.records,
603
+ result.provenance,
604
+ total_count=result.total_count,
605
+ metadata=dict(result.metadata),
606
+ )
607
+
608
+
609
+ def homology(
610
+ identifier: str,
611
+ *,
612
+ species: str = "human",
613
+ by: Literal["auto", "id", "symbol"] = "auto",
614
+ homology_type: Literal["orthologues", "paralogues", "projections", "all"] = "all",
615
+ target_species: str | None = None,
616
+ sequence_type: Literal["none", "cdna", "protein"] = "none",
617
+ aligned: bool = False,
618
+ config: SearchConfig | None = None,
619
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
620
+ ) -> QueryResult:
621
+ """Query orthologs, paralogs, projections, and gene-tree alignments."""
622
+
623
+ resolved = resolved_config(config)
624
+ value = require_text(identifier, "identifier", max_length=256)
625
+ organism = _species(species)
626
+ if by == "auto":
627
+ by = "id" if _ENSEMBL_ID.fullmatch(value) else "symbol"
628
+ if by == "id":
629
+ path = f"/homology/id/{quote(organism, safe='')}/{quote(value, safe='._-')}"
630
+ elif by == "symbol":
631
+ path = f"/homology/symbol/{quote(organism, safe='')}/{quote(value, safe='._-')}"
632
+ else:
633
+ raise ConfigurationError("homology by must be auto, id, or symbol.", code="INVALID_QUERY")
634
+ url = build_url(
635
+ api_base_url,
636
+ path,
637
+ (
638
+ ("type", homology_type),
639
+ ("target_species", target_species),
640
+ ("sequence", sequence_type),
641
+ ("aligned", aligned),
642
+ ("cigar_line", aligned),
643
+ ),
644
+ )
645
+ payload = request_json(url, resolved, provider="Ensembl")
646
+ return _result(
647
+ "homology",
648
+ url,
649
+ payload,
650
+ resolved,
651
+ record_keys=("data",),
652
+ filters={
653
+ "identifier": value,
654
+ "species": organism,
655
+ "by": by,
656
+ "type": homology_type,
657
+ "target_species": target_species,
658
+ },
659
+ )
660
+
661
+
662
+ def id_convert(
663
+ identifier: str,
664
+ *,
665
+ species: str = "human",
666
+ by: Literal["auto", "id", "symbol"] = "auto",
667
+ all_levels: bool = True,
668
+ external_db: str | None = None,
669
+ config: SearchConfig | None = None,
670
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
671
+ ) -> QueryResult:
672
+ """Convert Ensembl IDs/symbols through Ensembl external cross-references."""
673
+
674
+ resolved = resolved_config(config)
675
+ value = require_text(identifier, "identifier", max_length=256)
676
+ if by == "auto":
677
+ by = "id" if value.upper().startswith("ENS") else "symbol"
678
+ if by == "id":
679
+ path = f"/xrefs/id/{quote(value, safe='._-')}"
680
+ params: tuple[tuple[str, object], ...] = (
681
+ ("all_levels", all_levels),
682
+ ("external_db", external_db),
683
+ )
684
+ elif by == "symbol":
685
+ path = f"/xrefs/symbol/{quote(_species(species), safe='')}/{quote(value, safe='._-')}"
686
+ params = (("external_db", external_db),)
687
+ else:
688
+ raise ConfigurationError("id_convert by must be auto, id, or symbol.", code="INVALID_QUERY")
689
+ url = build_url(api_base_url, path, params)
690
+ payload = request_json(url, resolved, provider="Ensembl")
691
+ return _result(
692
+ "id_conversion",
693
+ url,
694
+ payload,
695
+ resolved,
696
+ filters={"identifier": value, "species": species, "by": by, "external_db": external_db},
697
+ )
698
+
699
+
700
+ def map_coordinates(
701
+ species: str,
702
+ region: str,
703
+ *,
704
+ source_assembly: str,
705
+ target_assembly: str,
706
+ strand: Literal[-1, 1] = 1,
707
+ config: SearchConfig | None = None,
708
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
709
+ ) -> QueryResult:
710
+ """Map a 0-based half-open region between two assemblies."""
711
+
712
+ resolved = resolved_config(config)
713
+ organism = _species(species)
714
+ _, start, end, provider_region = _ensembl_region(region, strand=strand, max_span=5_000_000)
715
+ source = require_text(source_assembly, "source_assembly", max_length=128)
716
+ target = require_text(target_assembly, "target_assembly", max_length=128)
717
+ url = build_url(
718
+ api_base_url,
719
+ (
720
+ f"/map/{quote(organism, safe='')}/{quote(source, safe='._-')}/"
721
+ f"{quote(provider_region, safe=':.-_')}/{quote(target, safe='._-')}"
722
+ ),
723
+ )
724
+ payload = request_json(url, resolved, provider="Ensembl")
725
+ return _result(
726
+ "coordinate_mapping",
727
+ url,
728
+ payload,
729
+ resolved,
730
+ record_keys=("mappings",),
731
+ filters={
732
+ "species": organism,
733
+ "region": region,
734
+ "source_assembly": source,
735
+ "target_assembly": target,
736
+ },
737
+ metadata={
738
+ "input_coordinate_system": "0-based half-open",
739
+ "provider_coordinate_system": "1-based inclusive",
740
+ "requested_start_0based": start,
741
+ "requested_end_0based": end,
742
+ },
743
+ normalize_coordinates=True,
744
+ )
745
+
746
+
747
+ def comparative_alignment(
748
+ species: str,
749
+ region: str,
750
+ *,
751
+ strand: Literal[-1, 1] = 1,
752
+ method: str = "EPO",
753
+ species_set_group: str | None = "mammals",
754
+ display_species: Sequence[str] = (),
755
+ aligned: bool = True,
756
+ mask: Literal["hard", "soft"] | None = None,
757
+ config: SearchConfig | None = None,
758
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
759
+ ) -> QueryResult:
760
+ """Retrieve bounded Ensembl Compara genomic alignment blocks."""
761
+
762
+ resolved = resolved_config(config)
763
+ organism = _species(species)
764
+ _, start, end, provider_region = _ensembl_region(region, strand=strand, max_span=10_000_000)
765
+ method_value = require_text(method, "method", max_length=64)
766
+ displays = tuple(_species(item) for item in display_species)
767
+ if mask not in {None, "hard", "soft"}:
768
+ raise ConfigurationError("mask must be None, hard, or soft.", code="INVALID_QUERY")
769
+ url = build_url(
770
+ api_base_url,
771
+ f"/alignment/region/{quote(organism, safe='')}/{quote(provider_region, safe=':.-_')}",
772
+ (
773
+ ("method", method_value),
774
+ ("species_set_group", species_set_group),
775
+ ("display_species_set", displays),
776
+ ("aligned", aligned),
777
+ ("mask", mask),
778
+ ),
779
+ )
780
+ payload = request_json(url, resolved, provider="Ensembl")
781
+ return _result(
782
+ "comparative_alignment",
783
+ url,
784
+ payload,
785
+ resolved,
786
+ filters={
787
+ "species": organism,
788
+ "region": region,
789
+ "method": method_value,
790
+ "species_set_group": species_set_group,
791
+ "display_species": displays,
792
+ },
793
+ metadata={
794
+ "input_coordinate_system": "0-based half-open",
795
+ "provider_coordinate_system": "1-based inclusive",
796
+ "requested_start_0based": start,
797
+ "requested_end_0based": end,
798
+ },
799
+ normalize_coordinates=True,
800
+ )
801
+
802
+
803
+ def genome_info(
804
+ species: str,
805
+ *,
806
+ expand_sequences: bool = False,
807
+ config: SearchConfig | None = None,
808
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
809
+ ) -> QueryResult:
810
+ """Return Ensembl assembly, genebuild, release, and sequence metadata."""
811
+
812
+ resolved = resolved_config(config)
813
+ organism = _species(species)
814
+ url = build_url(
815
+ api_base_url,
816
+ f"/info/genomes/{quote(organism, safe='._-')}",
817
+ (("expand", expand_sequences),),
818
+ )
819
+ payload = request_json(url, resolved, provider="Ensembl")
820
+ return _result(
821
+ "database_version",
822
+ url,
823
+ payload,
824
+ resolved,
825
+ filters={"species": organism, "expand_sequences": expand_sequences},
826
+ )
827
+
828
+
829
+ def chromosome(
830
+ species: str,
831
+ name: str,
832
+ *,
833
+ include_bands: bool = False,
834
+ include_synonyms: bool = True,
835
+ config: SearchConfig | None = None,
836
+ api_base_url: str = DEFAULT_ENSEMBL_REST_URL,
837
+ ) -> QueryResult:
838
+ """Query chromosome, organelle, plasmid, contig, or scaffold metadata."""
839
+
840
+ resolved = resolved_config(config)
841
+ organism = _species(species)
842
+ sequence_name = require_text(name, "name", max_length=256)
843
+ url = build_url(
844
+ api_base_url,
845
+ f"/info/assembly/{quote(organism, safe='')}/{quote(sequence_name, safe='._-')}",
846
+ (("bands", include_bands), ("synonyms", include_synonyms)),
847
+ )
848
+ payload = request_json(url, resolved, provider="Ensembl")
849
+ return _result(
850
+ "chromosome",
851
+ url,
852
+ payload,
853
+ resolved,
854
+ filters={"species": organism, "name": sequence_name},
855
+ )
856
+
857
+
858
+ __all__ = [
859
+ "DEFAULT_ENSEMBL_REST_URL",
860
+ "annotation",
861
+ "chromosome",
862
+ "comparative_alignment",
863
+ "genome_info",
864
+ "homology",
865
+ "id_convert",
866
+ "lookup",
867
+ "map_coordinates",
868
+ "nearby",
869
+ "regulation",
870
+ "sequence",
871
+ "sequence_by_id",
872
+ "transcripts",
873
+ "variant",
874
+ ]