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.
- altar_opentargets_e2g-0.1.0/.gitignore +32 -0
- altar_opentargets_e2g-0.1.0/LICENSE +21 -0
- altar_opentargets_e2g-0.1.0/PKG-INFO +73 -0
- altar_opentargets_e2g-0.1.0/README.md +48 -0
- altar_opentargets_e2g-0.1.0/altar_opentargets_e2g/__init__.py +22 -0
- altar_opentargets_e2g-0.1.0/altar_opentargets_e2g/ingest.py +99 -0
- altar_opentargets_e2g-0.1.0/altar_opentargets_e2g/py.typed +1 -0
- altar_opentargets_e2g-0.1.0/altar_opentargets_e2g/schema.py +314 -0
- altar_opentargets_e2g-0.1.0/altar_opentargets_e2g/source.py +61 -0
- altar_opentargets_e2g-0.1.0/pyproject.toml +51 -0
- altar_opentargets_e2g-0.1.0/tests/fixtures/opentargets_e2g_links.json +98 -0
- altar_opentargets_e2g-0.1.0/tests/test_source.py +335 -0
|
@@ -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)
|