altar-opentargets-e2g 0.1.0__tar.gz

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,32 @@
1
+ *.pyc
2
+ *~
3
+ **/__pycache__/*
4
+ *.swp
5
+ .vscode/
6
+ .idea/
7
+ .DS_Store
8
+ .env*
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+ .coverage
13
+ htmlcov/
14
+ coverage.xml
15
+ pytest.xml
16
+ .hypothesis/
17
+ .python-version
18
+ .venv/
19
+ .venv-*/
20
+ *.egg-info/
21
+ build/
22
+ dist/
23
+ site/
24
+ *.sqlite3
25
+ *.log
26
+
27
+ # Generated variant indexes and datasets. The small canonical gene table is tracked.
28
+ altar/altar/variants/data/ccres.dnatree
29
+ altar/altar/variants/data/region_annotations.parquet
30
+ altar/altar/variants/data/variants.pkl.gz
31
+ altar/altar/variants/data/raw/
32
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Riya Sinha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: altar-opentargets-e2g
3
+ Version: 0.1.0
4
+ Summary: Open Targets ENCODE-rE2G binding for Altar's variant-gene link contract
5
+ Project-URL: Documentation, https://kundajelab.github.io/altar/
6
+ Project-URL: Issues, https://github.com/kundajelab/altar/issues
7
+ Project-URL: Repository, https://github.com/kundajelab/altar
8
+ Author: Riya Sinha
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: altar<0.2,>=0.1
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
23
+ Requires-Dist: pytest>=8; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # altar-opentargets-e2g
27
+
28
+ This binding normalizes the Open Targets Platform `enhancer_to_gene` dataset (ENCODE-rE2G evidence) into
29
+ Altar's public `VariantGeneLinkSource` relation.
30
+
31
+ The package owns Open Targets schema and provenance semantics, but not storage. Convert source rows to
32
+ `OpenTargetsE2GRow`, stream them through `OpenTargetsE2GImporter`, and query the published generation with
33
+ `OpenTargetsE2GSource`. Both operate on Altar's generic `VariantGeneLinkStore`; no Open Targets × PostgreSQL,
34
+ Parquet, DuckDB, or service-specific class is required.
35
+
36
+ ```python
37
+ from altar.sources import InMemoryVariantGeneLinkStore
38
+ from altar_opentargets_e2g import (
39
+ OpenTargetsE2GImportConfig,
40
+ OpenTargetsE2GImporter,
41
+ OpenTargetsE2GSource,
42
+ )
43
+
44
+ store = InMemoryVariantGeneLinkStore()
45
+ await OpenTargetsE2GImporter(store).ingest(
46
+ rows,
47
+ OpenTargetsE2GImportConfig(
48
+ data_release="25.12",
49
+ score_threshold=0.6,
50
+ threshold_applied=True,
51
+ ),
52
+ generation_id="sha256-of-the-source-asset",
53
+ )
54
+ source = OpenTargetsE2GSource(store)
55
+ ```
56
+
57
+ The importer accepts an iterable so operators can read large upstream Parquet datasets in bounded batches.
58
+ `OpenTargetsE2GRow.from_source_mapping()` accepts the official camelCase `enhancer_to_gene` Parquet fields;
59
+ the binding intentionally does not require a particular Parquet engine. See the
60
+ [Open Targets dataset documentation](https://platform-docs.opentargets.org/data-access/datasets) and
61
+ [rE2G documentation](https://platform-docs.opentargets.org/gentropy/enhancer-to-gene) for acquisition and
62
+ upstream transformations.
63
+
64
+ The Open Targets pipeline currently filters released rE2G output at `score >= 0.6`. Pass the threshold and
65
+ whether it was applied in `OpenTargetsE2GImportConfig`; Altar retains those semantics in every `LinkScore`
66
+ rather than treating the released table as unthresholded raw predictions.
67
+
68
+ The caller supplies a generation identifier for the exact extraction; partial generations stay hidden,
69
+ exact replay is idempotent, and publication happens only after the iterable completes. Use
70
+ `VariantGeneLinkQuery.data_releases` when a store contains more than one Open Targets release.
71
+
72
+ The contract is imported from `altar`; the binding is independently versioned and tested against the public
73
+ Altar conformance suite.
@@ -0,0 +1,48 @@
1
+ # altar-opentargets-e2g
2
+
3
+ This binding normalizes the Open Targets Platform `enhancer_to_gene` dataset (ENCODE-rE2G evidence) into
4
+ Altar's public `VariantGeneLinkSource` relation.
5
+
6
+ The package owns Open Targets schema and provenance semantics, but not storage. Convert source rows to
7
+ `OpenTargetsE2GRow`, stream them through `OpenTargetsE2GImporter`, and query the published generation with
8
+ `OpenTargetsE2GSource`. Both operate on Altar's generic `VariantGeneLinkStore`; no Open Targets × PostgreSQL,
9
+ Parquet, DuckDB, or service-specific class is required.
10
+
11
+ ```python
12
+ from altar.sources import InMemoryVariantGeneLinkStore
13
+ from altar_opentargets_e2g import (
14
+ OpenTargetsE2GImportConfig,
15
+ OpenTargetsE2GImporter,
16
+ OpenTargetsE2GSource,
17
+ )
18
+
19
+ store = InMemoryVariantGeneLinkStore()
20
+ await OpenTargetsE2GImporter(store).ingest(
21
+ rows,
22
+ OpenTargetsE2GImportConfig(
23
+ data_release="25.12",
24
+ score_threshold=0.6,
25
+ threshold_applied=True,
26
+ ),
27
+ generation_id="sha256-of-the-source-asset",
28
+ )
29
+ source = OpenTargetsE2GSource(store)
30
+ ```
31
+
32
+ The importer accepts an iterable so operators can read large upstream Parquet datasets in bounded batches.
33
+ `OpenTargetsE2GRow.from_source_mapping()` accepts the official camelCase `enhancer_to_gene` Parquet fields;
34
+ the binding intentionally does not require a particular Parquet engine. See the
35
+ [Open Targets dataset documentation](https://platform-docs.opentargets.org/data-access/datasets) and
36
+ [rE2G documentation](https://platform-docs.opentargets.org/gentropy/enhancer-to-gene) for acquisition and
37
+ upstream transformations.
38
+
39
+ The Open Targets pipeline currently filters released rE2G output at `score >= 0.6`. Pass the threshold and
40
+ whether it was applied in `OpenTargetsE2GImportConfig`; Altar retains those semantics in every `LinkScore`
41
+ rather than treating the released table as unthresholded raw predictions.
42
+
43
+ The caller supplies a generation identifier for the exact extraction; partial generations stay hidden,
44
+ exact replay is idempotent, and publication happens only after the iterable completes. Use
45
+ `VariantGeneLinkQuery.data_releases` when a store contains more than one Open Targets release.
46
+
47
+ The contract is imported from `altar`; the binding is independently versioned and tested against the public
48
+ Altar conformance suite.
@@ -0,0 +1,22 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Open Targets ENCODE-rE2G binding for Altar's variant-gene link contract."""
3
+
4
+ from altar_opentargets_e2g.ingest import OpenTargetsE2GImporter, OpenTargetsE2GImportReport
5
+ from altar_opentargets_e2g.schema import (
6
+ OPEN_TARGETS_E2G_DATASET,
7
+ OpenTargetsE2GImportConfig,
8
+ OpenTargetsE2GRow,
9
+ OpenTargetsResourceScore,
10
+ )
11
+ from altar_opentargets_e2g.source import OpenTargetsE2GSource
12
+
13
+
14
+ __all__ = [
15
+ "OPEN_TARGETS_E2G_DATASET",
16
+ "OpenTargetsE2GImportConfig",
17
+ "OpenTargetsE2GImportReport",
18
+ "OpenTargetsE2GImporter",
19
+ "OpenTargetsE2GRow",
20
+ "OpenTargetsE2GSource",
21
+ "OpenTargetsResourceScore",
22
+ ]
@@ -0,0 +1,99 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Streaming Open Targets normalization into a generic variant-gene link store."""
3
+
4
+ from __future__ import annotations
5
+ from dataclasses import dataclass, replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from altar.sources import VariantGeneLinkDatasetRef, VariantGeneLinkStore
9
+
10
+ from altar_opentargets_e2g.schema import (
11
+ OPEN_TARGETS_E2G_DATASET,
12
+ OpenTargetsE2GImportConfig,
13
+ )
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable
18
+
19
+ from altar.sources import VariantGeneEvidenceRecord
20
+
21
+ from altar_opentargets_e2g.schema import OpenTargetsE2GRow
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class OpenTargetsE2GImportReport:
26
+ """Counts for one atomically published streaming import."""
27
+
28
+ generation_id: str
29
+ processed_rows: int
30
+ added_records: int
31
+ skipped_records: int
32
+
33
+
34
+ class OpenTargetsE2GImporter:
35
+ """Normalize an iterable of Open Targets rows without owning its transport or database."""
36
+
37
+ def __init__(
38
+ self,
39
+ store: VariantGeneLinkStore,
40
+ *,
41
+ dataset: VariantGeneLinkDatasetRef = OPEN_TARGETS_E2G_DATASET,
42
+ batch_size: int = 10_000,
43
+ ) -> None:
44
+ if batch_size < 1:
45
+ msg = "batch_size must be positive"
46
+ raise ValueError(msg)
47
+ self._store = store
48
+ self._dataset = dataset
49
+ self._batch_size = batch_size
50
+
51
+ async def ingest(
52
+ self,
53
+ rows: Iterable[OpenTargetsE2GRow],
54
+ config: OpenTargetsE2GImportConfig,
55
+ *,
56
+ generation_id: str,
57
+ ) -> OpenTargetsE2GImportReport:
58
+ """Stage bounded batches and publish only after the complete iterable succeeds.
59
+
60
+ ``generation_id`` should identify the exact upstream asset or extraction. Replaying the same rows with
61
+ the same identifier is idempotent; changed records are rejected by the store rather than overwritten.
62
+ """
63
+ if not generation_id.strip():
64
+ msg = "generation_id must not be empty"
65
+ raise ValueError(msg)
66
+ processed = 0
67
+ added = 0
68
+ skipped = 0
69
+ batch: list[VariantGeneEvidenceRecord] = []
70
+ # Establish an empty staged generation so a valid empty release can still be published.
71
+ await self._store.write_records(self._dataset, generation_id, ())
72
+ for row in rows:
73
+ record = row.to_evidence_record(config)
74
+ provenance_metadata = dict(record.provenance.metadata)
75
+ provenance_metadata["import_generation_id"] = generation_id
76
+ batch.append(
77
+ replace(
78
+ record,
79
+ provenance=replace(record.provenance, metadata=provenance_metadata),
80
+ metadata={**record.metadata, "import_generation_id": generation_id},
81
+ )
82
+ )
83
+ processed += 1
84
+ if len(batch) >= self._batch_size:
85
+ result = await self._store.write_records(self._dataset, generation_id, batch)
86
+ added += result.added
87
+ skipped += result.skipped
88
+ batch.clear()
89
+ if batch:
90
+ result = await self._store.write_records(self._dataset, generation_id, batch)
91
+ added += result.added
92
+ skipped += result.skipped
93
+ await self._store.publish_generation(self._dataset, generation_id)
94
+ return OpenTargetsE2GImportReport(
95
+ generation_id=generation_id,
96
+ processed_rows=processed,
97
+ added_records=added,
98
+ skipped_records=skipped,
99
+ )
@@ -0,0 +1 @@
1
+ # PEP 561 marker
@@ -0,0 +1,314 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Open Targets ``enhancer_to_gene`` rows and canonical normalization."""
3
+
4
+ from __future__ import annotations
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+
9
+ from altar.sources import (
10
+ BiosampleContext,
11
+ GenomeBuild,
12
+ GenomicInterval,
13
+ LinkDistances,
14
+ LinkProvenance,
15
+ LinkScore,
16
+ RegulatoryElement,
17
+ ScoreDirection,
18
+ TargetGene,
19
+ VariantGeneEvidenceRecord,
20
+ VariantGeneLinkDatasetRef,
21
+ normalize_chromosome,
22
+ )
23
+
24
+
25
+ OPEN_TARGETS_E2G_DATASET = VariantGeneLinkDatasetRef(
26
+ namespace="org.opentargets.platform",
27
+ name="enhancer-to-gene",
28
+ version="1",
29
+ )
30
+
31
+ _SCORE_INTERPRETATION = (
32
+ "Open Targets ENCODE-rE2G confidence in the regulatory-element-to-gene assignment; "
33
+ "higher values indicate stronger evidence"
34
+ )
35
+ _DISTANCE_INTERPRETATION = (
36
+ "element_to_gene_bp is Open Targets distanceToTss: the unsigned minimum distance from the "
37
+ "regulatory interval boundary to the target gene TSS (zero for promoters or a TSS inside the interval); "
38
+ "variant_to_element_bp is zero because this source answers overlap queries"
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class OpenTargetsE2GImportConfig:
44
+ """Release metadata required to interpret Open Targets rows."""
45
+
46
+ data_release: str
47
+ genome_build: GenomeBuild = GenomeBuild.GRCH38
48
+ gene_namespace_version: str | None = None
49
+ dataset_url: str | None = None
50
+ score_threshold: float | None = None
51
+ threshold_applied: bool | None = None
52
+
53
+ def __post_init__(self) -> None:
54
+ release = self.data_release.strip()
55
+ if not release:
56
+ msg = "Open Targets data_release must not be empty"
57
+ raise ValueError(msg)
58
+ object.__setattr__(self, "data_release", release)
59
+ if self.score_threshold is None:
60
+ if self.threshold_applied is not None:
61
+ msg = "threshold_applied requires score_threshold"
62
+ raise ValueError(msg)
63
+ elif not math.isfinite(self.score_threshold) or not 0 <= self.score_threshold <= 1:
64
+ msg = "score_threshold must be finite and between zero and one"
65
+ raise ValueError(msg)
66
+ elif self.threshold_applied is None:
67
+ msg = "threshold_applied is required with score_threshold"
68
+ raise ValueError(msg)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class OpenTargetsResourceScore:
73
+ """One named component score retained from the Open Targets interval schema."""
74
+
75
+ name: str
76
+ value: float | None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class OpenTargetsE2GRow:
81
+ """The public subset of one Open Targets ``enhancer_to_gene`` Parquet/table row."""
82
+
83
+ interval_id: str
84
+ chromosome: str
85
+ start: int
86
+ end: int
87
+ gene_id: str
88
+ score: float | None
89
+ biosample_name: str | None
90
+ biosample_id: str | None
91
+ interval_type: str
92
+ distance_to_tss: int | None
93
+ datasource_id: str
94
+ pmid: str | None = None
95
+ study_id: str | None = None
96
+ gene_symbol: str | None = None
97
+ biosample_from_source_id: str | None = None
98
+ resource_scores: tuple[OpenTargetsResourceScore, ...] = ()
99
+ quality_controls: tuple[str, ...] = ()
100
+ evidence_metadata: Mapping[str, object] = field(default_factory=dict)
101
+
102
+ @classmethod
103
+ def from_source_mapping(cls, row: Mapping[str, object]) -> OpenTargetsE2GRow:
104
+ """Parse one row using the official Open Targets camelCase Parquet schema."""
105
+ known_fields = {
106
+ "chromosome",
107
+ "start",
108
+ "end",
109
+ "geneId",
110
+ "score",
111
+ "distanceToTss",
112
+ "resourceScore",
113
+ "datasourceId",
114
+ "intervalType",
115
+ "pmid",
116
+ "biosampleName",
117
+ "biosampleFromSourceId",
118
+ "biosampleId",
119
+ "studyId",
120
+ "intervalId",
121
+ "qualityControls",
122
+ }
123
+ return cls(
124
+ interval_id=_required_string(row, "intervalId"),
125
+ chromosome=_required_string(row, "chromosome"),
126
+ start=_required_integer(row, "start"),
127
+ end=_required_integer(row, "end"),
128
+ gene_id=_required_string(row, "geneId"),
129
+ score=_optional_number(row, "score"),
130
+ biosample_name=_optional_string(row, "biosampleName"),
131
+ biosample_id=_optional_string(row, "biosampleId"),
132
+ interval_type=_required_string(row, "intervalType"),
133
+ distance_to_tss=_optional_integer(row, "distanceToTss"),
134
+ datasource_id=_required_string(row, "datasourceId"),
135
+ pmid=_optional_string(row, "pmid"),
136
+ study_id=_optional_string(row, "studyId"),
137
+ biosample_from_source_id=_optional_string(row, "biosampleFromSourceId"),
138
+ resource_scores=_resource_scores(row.get("resourceScore")),
139
+ quality_controls=_string_sequence(row.get("qualityControls"), field_name="qualityControls"),
140
+ evidence_metadata={key: value for key, value in row.items() if key not in known_fields},
141
+ )
142
+
143
+ def to_evidence_record(self, config: OpenTargetsE2GImportConfig) -> VariantGeneEvidenceRecord:
144
+ """Normalize this source row into Altar's query-independent storage record."""
145
+ if not self.interval_id.strip():
146
+ msg = "Open Targets interval_id must not be empty"
147
+ raise ValueError(msg)
148
+ chromosome = normalize_chromosome(self.chromosome)
149
+ interval = GenomicInterval(
150
+ chromosome=chromosome,
151
+ start=self.start,
152
+ end=self.end,
153
+ genome_build=config.genome_build,
154
+ )
155
+ normalized_biosample_id = _normalize_ontology_id(self.biosample_id)
156
+ context = None
157
+ if self.biosample_name is not None or normalized_biosample_id is not None:
158
+ context = BiosampleContext(
159
+ id=normalized_biosample_id,
160
+ name=self.biosample_name or normalized_biosample_id or "unknown",
161
+ namespace=_biosample_namespace(normalized_biosample_id),
162
+ )
163
+ publications = (f"PMID:{self.pmid}",) if self.pmid else ()
164
+ return VariantGeneEvidenceRecord(
165
+ record_id=f"opentargets:{config.data_release}:{self.interval_id}",
166
+ evidence_interval=interval,
167
+ regulatory_element=RegulatoryElement(
168
+ id=f"{config.genome_build.value}:{chromosome}:{self.start}-{self.end}",
169
+ interval=interval,
170
+ element_type=self.interval_type,
171
+ ),
172
+ target_gene=TargetGene(
173
+ id=self.gene_id,
174
+ namespace="ensembl_gene",
175
+ namespace_version=config.gene_namespace_version,
176
+ symbol=self.gene_symbol,
177
+ ),
178
+ context=context,
179
+ source_id=self.datasource_id,
180
+ source_name="Open Targets Platform",
181
+ method="ENCODE-rE2G",
182
+ data_release=config.data_release,
183
+ score=LinkScore(
184
+ value=self.score,
185
+ direction=ScoreDirection.HIGHER_IS_STRONGER,
186
+ interpretation=_SCORE_INTERPRETATION,
187
+ threshold=config.score_threshold,
188
+ threshold_rule="greater-than-or-equal" if config.score_threshold is not None else None,
189
+ threshold_applied=config.threshold_applied,
190
+ threshold_interpretation=(
191
+ "The Open Targets released dataset applies its configured minimum valid score during "
192
+ "pipeline quality control."
193
+ if config.score_threshold is not None
194
+ else None
195
+ ),
196
+ ),
197
+ distances=LinkDistances(
198
+ element_to_gene_bp=self.distance_to_tss,
199
+ variant_to_gene_bp=None,
200
+ variant_to_element_bp=0,
201
+ interpretation=_DISTANCE_INTERPRETATION,
202
+ ),
203
+ provenance=LinkProvenance(
204
+ source_record_id=self.interval_id,
205
+ source_dataset="enhancer_to_gene",
206
+ source_url=config.dataset_url,
207
+ study_id=self.study_id,
208
+ publication_ids=publications,
209
+ metadata={
210
+ "interval_type": self.interval_type,
211
+ "biosample_from_source_id": self.biosample_from_source_id,
212
+ "resource_scores": [
213
+ {"name": resource_score.name, "value": resource_score.value}
214
+ for resource_score in self.resource_scores
215
+ ],
216
+ "quality_controls": list(self.quality_controls),
217
+ "additional": dict(self.evidence_metadata),
218
+ },
219
+ ),
220
+ metadata={"source_schema": "opentargets.enhancer_to_gene.v1"},
221
+ )
222
+
223
+
224
+ def _normalize_ontology_id(value: str | None) -> str | None:
225
+ if value is None:
226
+ return None
227
+ for prefix in ("CL_", "UBERON_", "EFO_"):
228
+ if value.startswith(prefix):
229
+ return value.replace("_", ":", 1)
230
+ return value
231
+
232
+
233
+ def _biosample_namespace(value: str | None) -> str | None:
234
+ if value is None or ":" not in value:
235
+ return None
236
+ return value.split(":", 1)[0]
237
+
238
+
239
+ def _required_string(row: Mapping[str, object], field_name: str) -> str:
240
+ value = row.get(field_name)
241
+ if not isinstance(value, str) or not value.strip():
242
+ msg = f"Open Targets {field_name} must be a non-empty string"
243
+ raise ValueError(msg)
244
+ return value
245
+
246
+
247
+ def _optional_string(row: Mapping[str, object], field_name: str) -> str | None:
248
+ value = row.get(field_name)
249
+ if value is None:
250
+ return None
251
+ if not isinstance(value, str):
252
+ msg = f"Open Targets {field_name} must be a string or null"
253
+ raise TypeError(msg)
254
+ return value
255
+
256
+
257
+ def _required_integer(row: Mapping[str, object], field_name: str) -> int:
258
+ value = row.get(field_name)
259
+ if isinstance(value, bool) or not isinstance(value, int):
260
+ msg = f"Open Targets {field_name} must be an integer"
261
+ raise TypeError(msg)
262
+ return value
263
+
264
+
265
+ def _optional_integer(row: Mapping[str, object], field_name: str) -> int | None:
266
+ value = row.get(field_name)
267
+ if value is None:
268
+ return None
269
+ if isinstance(value, bool) or not isinstance(value, int):
270
+ msg = f"Open Targets {field_name} must be an integer or null"
271
+ raise TypeError(msg)
272
+ return value
273
+
274
+
275
+ def _optional_number(row: Mapping[str, object], field_name: str) -> float | None:
276
+ value = row.get(field_name)
277
+ if value is None:
278
+ return None
279
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
280
+ msg = f"Open Targets {field_name} must be numeric or null"
281
+ raise TypeError(msg)
282
+ return float(value)
283
+
284
+
285
+ def _resource_scores(value: object) -> tuple[OpenTargetsResourceScore, ...]:
286
+ if value is None:
287
+ return ()
288
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
289
+ msg = "Open Targets resourceScore must be a sequence or null"
290
+ raise TypeError(msg)
291
+ scores = []
292
+ for item in value:
293
+ if not isinstance(item, Mapping):
294
+ msg = "Open Targets resourceScore entries must be mappings"
295
+ raise TypeError(msg)
296
+ scores.append(
297
+ OpenTargetsResourceScore(
298
+ name=_required_string(item, "name"),
299
+ value=_optional_number(item, "value"),
300
+ )
301
+ )
302
+ return tuple(scores)
303
+
304
+
305
+ def _string_sequence(value: object, *, field_name: str) -> tuple[str, ...]:
306
+ if value is None:
307
+ return ()
308
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
309
+ msg = f"Open Targets {field_name} must be a sequence or null"
310
+ raise TypeError(msg)
311
+ if not all(isinstance(item, str) for item in value):
312
+ msg = f"Open Targets {field_name} entries must be strings"
313
+ raise TypeError(msg)
314
+ return tuple(value)
@@ -0,0 +1,61 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Open Targets E2G source over Altar's generic canonical evidence store."""
3
+
4
+ from __future__ import annotations
5
+ from dataclasses import replace
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ from altar.sources import (
9
+ StoredVariantGeneLinkSource,
10
+ VariantGeneEvidenceRecord,
11
+ VariantGeneLink,
12
+ VariantGeneLinkDatasetRef,
13
+ VariantGeneLinkStore,
14
+ VariantLocus,
15
+ )
16
+
17
+ from altar_opentargets_e2g.schema import OPEN_TARGETS_E2G_DATASET
18
+
19
+
20
+ try:
21
+ _BINDING_VERSION = version("altar-opentargets-e2g")
22
+ except PackageNotFoundError: # pragma: no cover - editable installs provide package metadata
23
+ _BINDING_VERSION = "0.1.0"
24
+
25
+
26
+ class OpenTargetsE2GSource(StoredVariantGeneLinkSource):
27
+ """Query normalized Open Targets evidence from any compliant link store."""
28
+
29
+ name = "opentargets_e2g"
30
+ binding_id = "org.kundajelab.bindings.opentargets-e2g"
31
+ binding_version = _BINDING_VERSION
32
+
33
+ def __init__(
34
+ self,
35
+ store: VariantGeneLinkStore,
36
+ dataset: VariantGeneLinkDatasetRef = OPEN_TARGETS_E2G_DATASET,
37
+ ) -> None:
38
+ super().__init__(
39
+ store,
40
+ dataset,
41
+ name=self.name,
42
+ binding_id=self.binding_id,
43
+ binding_version=self.binding_version,
44
+ failure_label="Open Targets E2G store",
45
+ )
46
+
47
+ def decode_record(self, record: VariantGeneEvidenceRecord, locus: VariantLocus) -> VariantGeneLink:
48
+ """Restore stable tuple shapes used by the binding's public provenance metadata."""
49
+ link = record.to_link(locus)
50
+ metadata = dict(link.provenance.metadata)
51
+ resource_scores = metadata.get("resource_scores")
52
+ quality_controls = metadata.get("quality_controls")
53
+ if not isinstance(resource_scores, list) or not all(isinstance(value, dict) for value in resource_scores):
54
+ msg = f"record {record.record_id!r} has invalid resource_scores metadata"
55
+ raise TypeError(msg)
56
+ if not isinstance(quality_controls, list) or not all(isinstance(value, str) for value in quality_controls):
57
+ msg = f"record {record.record_id!r} has invalid quality_controls metadata"
58
+ raise TypeError(msg)
59
+ metadata["resource_scores"] = tuple(resource_scores)
60
+ metadata["quality_controls"] = tuple(quality_controls)
61
+ return replace(link, provenance=replace(link.provenance, metadata=metadata))
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "altar-opentargets-e2g"
3
+ version = "0.1.0"
4
+ description = "Open Targets ENCODE-rE2G binding for Altar's variant-gene link contract"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [{ name = "Riya Sinha" }]
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Science/Research",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = ["altar>=0.1,<0.2"]
21
+
22
+ [project.urls]
23
+ Documentation = "https://kundajelab.github.io/altar/"
24
+ Issues = "https://github.com/kundajelab/altar/issues"
25
+ Repository = "https://github.com/kundajelab/altar"
26
+
27
+ [project.entry-points."altar.variant_gene_link_sources"]
28
+ opentargets_e2g = "altar_opentargets_e2g:OpenTargetsE2GSource"
29
+
30
+ [project.optional-dependencies]
31
+ test = [
32
+ "pytest>=8",
33
+ "pytest-asyncio>=0.24",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["altar_opentargets_e2g"]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ asyncio_default_fixture_loop_scope = "function"
46
+ testpaths = ["tests"]
47
+
48
+ [tool.mypy]
49
+ python_version = "3.12"
50
+ strict = true
51
+ ignore_missing_imports = true
@@ -0,0 +1,98 @@
1
+ [
2
+ {
3
+ "interval_id": "ot-001",
4
+ "chromosome": "chr7",
5
+ "start": 140753330,
6
+ "end": 140753350,
7
+ "gene_id": "ENSG00000157764",
8
+ "gene_symbol": "BRAF",
9
+ "score": 0.81,
10
+ "biosample_name": "T cell",
11
+ "biosample_id": "CL_0000084",
12
+ "interval_type": "enhancer",
13
+ "distance_to_tss": 3200,
14
+ "datasource_id": "E2G",
15
+ "pmid": "38014075",
16
+ "study_id": "ENCODE-rE2G-T-cell"
17
+ },
18
+ {
19
+ "interval_id": "ot-002",
20
+ "chromosome": "7",
21
+ "start": 140753330,
22
+ "end": 140753350,
23
+ "gene_id": "ENSG00000146648",
24
+ "gene_symbol": "EGFR",
25
+ "score": 0.72,
26
+ "biosample_name": "T cell",
27
+ "biosample_id": "CL_0000084",
28
+ "interval_type": "enhancer",
29
+ "distance_to_tss": 4100,
30
+ "datasource_id": "E2G",
31
+ "pmid": "38014075",
32
+ "study_id": "ENCODE-rE2G-T-cell"
33
+ },
34
+ {
35
+ "interval_id": "ot-003",
36
+ "chromosome": "7",
37
+ "start": 140753330,
38
+ "end": 140753350,
39
+ "gene_id": "ENSG00000157764",
40
+ "gene_symbol": "BRAF",
41
+ "score": 0.64,
42
+ "biosample_name": "liver",
43
+ "biosample_id": "UBERON_0002107",
44
+ "interval_type": "enhancer",
45
+ "distance_to_tss": 3200,
46
+ "datasource_id": "E2G",
47
+ "pmid": "38014075",
48
+ "study_id": "ENCODE-rE2G-liver"
49
+ },
50
+ {
51
+ "interval_id": "ot-004",
52
+ "chromosome": "7",
53
+ "start": 140753335,
54
+ "end": 140753345,
55
+ "gene_id": "ENSG00000157764",
56
+ "gene_symbol": "BRAF",
57
+ "score": 0.0,
58
+ "biosample_name": "T cell",
59
+ "biosample_id": "CL_0000084",
60
+ "interval_type": "enhancer",
61
+ "distance_to_tss": 3195,
62
+ "datasource_id": "E2G",
63
+ "pmid": null,
64
+ "study_id": "fixture-zero-score"
65
+ },
66
+ {
67
+ "interval_id": "ot-005",
68
+ "chromosome": "7",
69
+ "start": 140753335,
70
+ "end": 140753345,
71
+ "gene_id": "ENSG00000157764",
72
+ "gene_symbol": "BRAF",
73
+ "score": null,
74
+ "biosample_name": "T cell",
75
+ "biosample_id": "CL_0000084",
76
+ "interval_type": "enhancer",
77
+ "distance_to_tss": null,
78
+ "datasource_id": "E2G",
79
+ "pmid": null,
80
+ "study_id": "fixture-missing-score"
81
+ },
82
+ {
83
+ "interval_id": "ot-006",
84
+ "chromosome": "7",
85
+ "start": 140800000,
86
+ "end": 140800100,
87
+ "gene_id": "ENSG00000157764",
88
+ "gene_symbol": "BRAF",
89
+ "score": 0.9,
90
+ "biosample_name": "T cell",
91
+ "biosample_id": "CL_0000084",
92
+ "interval_type": "enhancer",
93
+ "distance_to_tss": 100,
94
+ "datasource_id": "E2G",
95
+ "pmid": "38014075",
96
+ "study_id": "non-overlapping"
97
+ }
98
+ ]
@@ -0,0 +1,335 @@
1
+ # Copyright 2026 Riya Sinha
2
+ # Assertions are the pytest API; fixed-size fixture expectations are intentional.
3
+ # ruff: noqa: PLR2004
4
+ from __future__ import annotations
5
+ import json
6
+ from dataclasses import replace
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import pytest
12
+ from altar.sources import (
13
+ GenomeBuild,
14
+ GenomeBuildMismatchError,
15
+ GenomicInterval,
16
+ InMemoryVariantGeneLinkStore,
17
+ InvalidCursorError,
18
+ SqliteVariantGeneLinkStore,
19
+ VariantGeneLinkQuery,
20
+ VariantGeneLinkStore,
21
+ VariantLocus,
22
+ get_variant_gene_link_source_registry,
23
+ )
24
+ from altar.testing import VariantGeneLinkSourceContract
25
+ from altar_opentargets_e2g import (
26
+ OPEN_TARGETS_E2G_DATASET,
27
+ OpenTargetsE2GImportConfig,
28
+ OpenTargetsE2GImporter,
29
+ OpenTargetsE2GRow,
30
+ OpenTargetsE2GSource,
31
+ OpenTargetsResourceScore,
32
+ )
33
+
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import AsyncIterator, Iterator
37
+
38
+
39
+ FIXTURE_PATH = Path(__file__).parent / "fixtures" / "opentargets_e2g_links.json"
40
+ KNOWN_IDS = tuple(f"opentargets:25.12:ot-00{number}" for number in range(1, 6))
41
+
42
+
43
+ @pytest.fixture
44
+ def fixture_rows() -> list[OpenTargetsE2GRow]:
45
+ values = json.loads(FIXTURE_PATH.read_text())
46
+ return [OpenTargetsE2GRow(**value) for value in values]
47
+
48
+
49
+ def _config(**changes: object) -> OpenTargetsE2GImportConfig:
50
+ config = OpenTargetsE2GImportConfig(
51
+ data_release="25.12",
52
+ dataset_url="https://ftp.ebi.ac.uk/pub/databases/opentargets/platform/25.12/output/enhancer_to_gene",
53
+ )
54
+ return replace(config, **changes)
55
+
56
+
57
+ @pytest.fixture(params=("memory", "sqlite"))
58
+ async def store(
59
+ request: pytest.FixtureRequest,
60
+ tmp_path: Path,
61
+ fixture_rows: list[OpenTargetsE2GRow],
62
+ ) -> AsyncIterator[VariantGeneLinkStore]:
63
+ if request.param == "memory":
64
+ backend = InMemoryVariantGeneLinkStore()
65
+ await OpenTargetsE2GImporter(backend, batch_size=2).ingest(
66
+ fixture_rows,
67
+ _config(),
68
+ generation_id="25.12-fixture",
69
+ )
70
+ yield backend
71
+ return
72
+ backend = SqliteVariantGeneLinkStore(tmp_path / "opentargets.sqlite")
73
+ await OpenTargetsE2GImporter(backend, batch_size=2).ingest(
74
+ fixture_rows,
75
+ _config(),
76
+ generation_id="25.12-fixture",
77
+ )
78
+ try:
79
+ yield backend
80
+ finally:
81
+ backend.close()
82
+
83
+
84
+ @pytest.fixture
85
+ def known_query() -> VariantGeneLinkQuery:
86
+ return VariantGeneLinkQuery(
87
+ locus=VariantLocus.from_vcf(
88
+ chromosome="chr7",
89
+ position=140753336,
90
+ reference_allele="A",
91
+ alternate_allele="T",
92
+ genome_build=GenomeBuild.GRCH38,
93
+ ),
94
+ page_size=2,
95
+ )
96
+
97
+
98
+ @pytest.fixture
99
+ def empty_query() -> VariantGeneLinkQuery:
100
+ return VariantGeneLinkQuery(
101
+ locus=VariantLocus(
102
+ interval=GenomicInterval(
103
+ chromosome="7",
104
+ start=1,
105
+ end=2,
106
+ genome_build=GenomeBuild.GRCH38,
107
+ )
108
+ )
109
+ )
110
+
111
+
112
+ @pytest.fixture
113
+ def source(store: VariantGeneLinkStore) -> OpenTargetsE2GSource:
114
+ return OpenTargetsE2GSource(store)
115
+
116
+
117
+ class TestOpenTargetsE2GSourceConformance(VariantGeneLinkSourceContract):
118
+ @pytest.fixture
119
+ def source(self, source: OpenTargetsE2GSource) -> OpenTargetsE2GSource:
120
+ return source
121
+
122
+ @pytest.fixture
123
+ def known_query(self, known_query: VariantGeneLinkQuery) -> VariantGeneLinkQuery:
124
+ return known_query
125
+
126
+ @pytest.fixture
127
+ def expected_record_ids(self) -> tuple[str, ...]:
128
+ return KNOWN_IDS
129
+
130
+ @pytest.fixture
131
+ def empty_query(self, empty_query: VariantGeneLinkQuery) -> VariantGeneLinkQuery:
132
+ return empty_query
133
+
134
+
135
+ def test_registry_and_distribution_identity() -> None:
136
+ registry = get_variant_gene_link_source_registry()
137
+ assert registry.get("opentargets_e2g") is OpenTargetsE2GSource
138
+ assert OpenTargetsE2GSource.binding_version == version("altar-opentargets-e2g")
139
+
140
+
141
+ async def test_one_element_can_link_multiple_genes_and_contexts(
142
+ source: OpenTargetsE2GSource, known_query: VariantGeneLinkQuery
143
+ ) -> None:
144
+ links = [link async for link in source.stream_links(known_query)]
145
+ shared = [
146
+ link
147
+ for link in links
148
+ if link.regulatory_element is not None and link.regulatory_element.id.endswith("140753330-140753350")
149
+ ]
150
+ assert {link.target_gene.symbol for link in shared} == {"BRAF", "EGFR"}
151
+ assert {link.context.name for link in shared if link.context is not None} == {"T cell", "liver"}
152
+ assert len(shared) == 3
153
+
154
+
155
+ async def test_normalized_filters_and_pagination(
156
+ source: OpenTargetsE2GSource, known_query: VariantGeneLinkQuery
157
+ ) -> None:
158
+ filtered = replace(known_query, context_ids=("CL:0000084",), source_ids=("E2G",), page_size=1)
159
+ links = [link async for link in source.stream_links(filtered)]
160
+ assert [link.record_id for link in links] == [KNOWN_IDS[0], KNOWN_IDS[1], KNOWN_IDS[3], KNOWN_IDS[4]]
161
+ assert all(link.context is not None and link.context.id == "CL:0000084" for link in links)
162
+
163
+ union_filtered = replace(
164
+ known_query,
165
+ context_ids=("not-present",),
166
+ context_names=("liver",),
167
+ data_releases=("25.12",),
168
+ )
169
+ union_links = [link async for link in source.stream_links(union_filtered)]
170
+ assert [link.record_id for link in union_links] == [KNOWN_IDS[2]]
171
+
172
+
173
+ async def test_missing_score_is_not_zero(source: OpenTargetsE2GSource, known_query: VariantGeneLinkQuery) -> None:
174
+ links = {link.record_id: link async for link in source.stream_links(known_query)}
175
+ assert links[KNOWN_IDS[3]].score.value == 0.0
176
+ assert links[KNOWN_IDS[4]].score.value is None
177
+
178
+
179
+ def test_missing_biosample_context_is_preserved_as_missing(fixture_rows: list[OpenTargetsE2GRow]) -> None:
180
+ row = replace(fixture_rows[0], biosample_name=None, biosample_id=None)
181
+ assert row.to_evidence_record(_config()).context is None
182
+
183
+
184
+ @pytest.mark.parametrize("store_kind", ["memory", "sqlite"])
185
+ async def test_upstream_evidence_fields_survive_both_stores(
186
+ store_kind: str,
187
+ tmp_path: Path,
188
+ fixture_rows: list[OpenTargetsE2GRow],
189
+ known_query: VariantGeneLinkQuery,
190
+ ) -> None:
191
+ row = replace(
192
+ fixture_rows[0],
193
+ biosample_from_source_id="source-cell-1",
194
+ resource_scores=(OpenTargetsResourceScore(name="DNase", value=0.4),),
195
+ quality_controls=("fixture-qc",),
196
+ evidence_metadata={"partition": "part-0001"},
197
+ )
198
+ backend: VariantGeneLinkStore
199
+ if store_kind == "memory":
200
+ backend = InMemoryVariantGeneLinkStore()
201
+ else:
202
+ backend = SqliteVariantGeneLinkStore(tmp_path / "metadata.sqlite")
203
+ try:
204
+ await OpenTargetsE2GImporter(backend).ingest([row], _config(), generation_id="metadata")
205
+ link = (await OpenTargetsE2GSource(backend).query_links(known_query)).links[0]
206
+ assert link.provenance.metadata == {
207
+ "interval_type": "enhancer",
208
+ "biosample_from_source_id": "source-cell-1",
209
+ "resource_scores": ({"name": "DNase", "value": 0.4},),
210
+ "quality_controls": ("fixture-qc",),
211
+ "additional": {"partition": "part-0001"},
212
+ "import_generation_id": "metadata",
213
+ }
214
+ finally:
215
+ if isinstance(backend, SqliteVariantGeneLinkStore):
216
+ backend.close()
217
+
218
+
219
+ async def test_import_is_bounded_idempotent_and_published_atomically(
220
+ fixture_rows: list[OpenTargetsE2GRow], known_query: VariantGeneLinkQuery
221
+ ) -> None:
222
+ backend = InMemoryVariantGeneLinkStore()
223
+ importer = OpenTargetsE2GImporter(backend, batch_size=1)
224
+
225
+ def failing_rows() -> Iterator[OpenTargetsE2GRow]:
226
+ yield fixture_rows[0]
227
+ yield replace(fixture_rows[1], interval_id="")
228
+
229
+ with pytest.raises(ValueError, match="interval_id"):
230
+ await importer.ingest(failing_rows(), _config(), generation_id="failed")
231
+ assert (await OpenTargetsE2GSource(backend).query_links(known_query)).links == ()
232
+
233
+ first = await importer.ingest(fixture_rows, _config(), generation_id="failed")
234
+ repeated = await importer.ingest(fixture_rows, _config(), generation_id="failed")
235
+ assert first.processed_rows == len(fixture_rows)
236
+ assert first.added_records == len(fixture_rows) - 1
237
+ assert first.skipped_records == 1
238
+ assert repeated.added_records == 0
239
+ assert repeated.skipped_records == len(fixture_rows)
240
+ assert await backend.generation_size(OPEN_TARGETS_E2G_DATASET, "failed") == len(fixture_rows)
241
+
242
+
243
+ def test_official_parquet_mapping_is_parsed_without_a_storage_dependency() -> None:
244
+ row = OpenTargetsE2GRow.from_source_mapping(
245
+ {
246
+ "chromosome": "7",
247
+ "start": 140753330,
248
+ "end": 140753350,
249
+ "geneId": "ENSG00000157764",
250
+ "score": 0.81,
251
+ "distanceToTss": 3200,
252
+ "resourceScore": [{"name": "DNase", "value": 0.4}],
253
+ "datasourceId": "E2G",
254
+ "intervalType": "enhancer",
255
+ "pmid": "38014075",
256
+ "biosampleName": "T cell",
257
+ "biosampleFromSourceId": None,
258
+ "biosampleId": "CL_0000084",
259
+ "studyId": "ENCODE-rE2G-T-cell",
260
+ "intervalId": "ot-001",
261
+ "qualityControls": [],
262
+ "futureField": {"retained": True},
263
+ }
264
+ )
265
+ assert row.resource_scores == (OpenTargetsResourceScore(name="DNase", value=0.4),)
266
+ assert row.evidence_metadata == {"futureField": {"retained": True}}
267
+
268
+
269
+ def test_official_mapping_rejects_malformed_required_fields() -> None:
270
+ with pytest.raises(ValueError, match="geneId"):
271
+ OpenTargetsE2GRow.from_source_mapping(
272
+ {
273
+ "chromosome": "7",
274
+ "start": 1,
275
+ "end": 2,
276
+ "geneId": None,
277
+ "datasourceId": "E2G",
278
+ "intervalType": "enhancer",
279
+ "intervalId": "bad",
280
+ }
281
+ )
282
+
283
+
284
+ def test_release_threshold_semantics_are_explicit(fixture_rows: list[OpenTargetsE2GRow]) -> None:
285
+ record = fixture_rows[0].to_evidence_record(_config(score_threshold=0.6, threshold_applied=True))
286
+ assert record.score.threshold == 0.6
287
+ assert record.score.threshold_rule == "greater-than-or-equal"
288
+ assert record.score.threshold_applied is True
289
+
290
+ with pytest.raises(ValueError, match="threshold_applied"):
291
+ _config(score_threshold=0.6)
292
+
293
+
294
+ async def test_empty_release_is_publishable_and_release_rows_are_retained(
295
+ fixture_rows: list[OpenTargetsE2GRow], known_query: VariantGeneLinkQuery
296
+ ) -> None:
297
+ backend = InMemoryVariantGeneLinkStore()
298
+ empty = await OpenTargetsE2GImporter(backend).ingest([], _config(), generation_id="empty")
299
+ await OpenTargetsE2GImporter(backend).ingest(
300
+ fixture_rows[:1],
301
+ _config(data_release="26.01"),
302
+ generation_id="26.01-fixture",
303
+ )
304
+ await OpenTargetsE2GImporter(backend).ingest(
305
+ fixture_rows[:1],
306
+ _config(),
307
+ generation_id="25.12-fixture",
308
+ )
309
+ assert empty.processed_rows == empty.added_records == empty.skipped_records == 0
310
+ links = [link async for link in OpenTargetsE2GSource(backend).stream_links(known_query)]
311
+ assert {link.data_release for link in links} == {"25.12", "26.01"}
312
+ selected = replace(known_query, data_releases=("26.01",))
313
+ assert [link.data_release async for link in OpenTargetsE2GSource(backend).stream_links(selected)] == ["26.01"]
314
+
315
+
316
+ async def test_build_mismatch_is_a_query_error(source: OpenTargetsE2GSource, known_query: VariantGeneLinkQuery) -> None:
317
+ mismatched = replace(
318
+ known_query,
319
+ locus=replace(
320
+ known_query.locus,
321
+ interval=replace(known_query.locus.interval, genome_build=GenomeBuild.GRCH37),
322
+ ),
323
+ )
324
+ with pytest.raises(GenomeBuildMismatchError, match="liftover must be explicit"):
325
+ await source.query_links(mismatched)
326
+
327
+
328
+ async def test_cursor_is_bound_to_locus_and_filters(
329
+ source: OpenTargetsE2GSource, known_query: VariantGeneLinkQuery
330
+ ) -> None:
331
+ page = await source.query_links(replace(known_query, page_size=1))
332
+ assert page.next_cursor is not None
333
+ changed = replace(known_query, page_size=1, context_names=("liver",), cursor=page.next_cursor)
334
+ with pytest.raises(InvalidCursorError, match="does not belong"):
335
+ await source.query_links(changed)