mapkgsutils 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapkgsutils/__init__.py +1 -0
- mapkgsutils/config/__init__.py +1 -0
- mapkgsutils/config/schema.py +170 -0
- mapkgsutils/context.py +358 -0
- mapkgsutils/diff.py +338 -0
- mapkgsutils/download.py +661 -0
- mapkgsutils/exports.py +180 -0
- mapkgsutils/logging.py +65 -0
- mapkgsutils/parsers/__init__.py +1 -0
- mapkgsutils/parsers/base.py +1899 -0
- mapkgsutils/py.typed +1 -0
- mapkgsutils/version.py +39 -0
- mapkgsutils-0.0.2.dist-info/METADATA +385 -0
- mapkgsutils-0.0.2.dist-info/RECORD +16 -0
- mapkgsutils-0.0.2.dist-info/WHEEL +4 -0
- mapkgsutils-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1899 @@
|
|
|
1
|
+
"""Base parser/downloader framework and mapping-set classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from dataclasses import fields as dataclass_fields
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from importlib import resources as _importlib_resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from sssom_schema import Mapping, MappingCardinalityEnum, MappingSet
|
|
18
|
+
from tqdm import tqdm
|
|
19
|
+
|
|
20
|
+
from mapkgsutils.logging import logger
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import rdflib
|
|
24
|
+
from sssom import sssom_document
|
|
25
|
+
|
|
26
|
+
_T = TypeVar("_T")
|
|
27
|
+
|
|
28
|
+
# Values for withdrawn entries
|
|
29
|
+
|
|
30
|
+
WITHDRAWN_ENTRY = "sssom:NoTermFound"
|
|
31
|
+
WITHDRAWN_ENTRY_LABEL = "Withdrawn Entry"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DistributionEra:
|
|
36
|
+
"""One historical "shape" a datasource's distribution has taken.
|
|
37
|
+
|
|
38
|
+
Lets a config describe multiple eras (different URL templates, formats,
|
|
39
|
+
or archive locations) instead of a single hardcoded threshold. Eras are
|
|
40
|
+
matched by version using from_version/to_version (inclusive, numeric-aware
|
|
41
|
+
comparison so "100" < "245" compares correctly; falls back to lexicographic
|
|
42
|
+
for date-string versions like HGNC's "YYYY-MM-DD").
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
download_urls: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
archive_url: str = ""
|
|
48
|
+
format: str | None = None
|
|
49
|
+
from_version: str | None = None
|
|
50
|
+
to_version: str | None = None
|
|
51
|
+
wayback: bool = False # declarative only for now -- no resolver yet
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class XrefSource:
|
|
56
|
+
"""A suggested cross-reference crosswalk source for a datasource.
|
|
57
|
+
|
|
58
|
+
Passed to :func:`mapkgsutils.context.load_xref_mapping` after downloading
|
|
59
|
+
*url* and renaming *object_id_col*/*object_label_col*/the chosen
|
|
60
|
+
*subject_id_cols* entry to ``object_id``/``object_label``/``subject_id``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
name: str = ""
|
|
65
|
+
url: str = ""
|
|
66
|
+
format: str = "tsv"
|
|
67
|
+
object_id_col: str = "object_id"
|
|
68
|
+
object_label_col: str = "object_label"
|
|
69
|
+
subject_id_cols: dict[str, str] = field(default_factory=dict)
|
|
70
|
+
note: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmp_versions(a: str, b: str) -> int:
|
|
74
|
+
"""Compare two version strings, numerically when possible.
|
|
75
|
+
|
|
76
|
+
Falls back to plain string comparison for non-numeric versions (e.g.
|
|
77
|
+
ISO date strings like ``"2026-04-07"``, which already sort correctly
|
|
78
|
+
lexicographically).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Negative if ``a < b``, zero if equal, positive if ``a > b``.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
ai, bi = int(a), int(b)
|
|
85
|
+
return (ai > bi) - (ai < bi)
|
|
86
|
+
except ValueError:
|
|
87
|
+
return (a > b) - (a < b)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class DatasourceConfig:
|
|
92
|
+
"""Configuration for a biological database datasource loaded from YAML."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
prefix: str
|
|
96
|
+
curie_base_url: str
|
|
97
|
+
# Proper schema fields
|
|
98
|
+
config_id: str = ""
|
|
99
|
+
datasource_id: str = ""
|
|
100
|
+
parser_class: str = ""
|
|
101
|
+
parse_options: dict[str, Any] = field(default_factory=dict)
|
|
102
|
+
mapping_sets: dict[str, Any] = field(default_factory=dict)
|
|
103
|
+
# Old field, remove at some point
|
|
104
|
+
available_outputs: list[str] = field(default_factory=list)
|
|
105
|
+
default_output_filename: str = ""
|
|
106
|
+
download_urls: dict[str, Any] = field(default_factory=dict)
|
|
107
|
+
primary_file_key: str = ""
|
|
108
|
+
id_pattern: str = ""
|
|
109
|
+
archive_url: str = ""
|
|
110
|
+
input_file_types: list[str] = field(default_factory=list)
|
|
111
|
+
source: str = ""
|
|
112
|
+
homepage: str = ""
|
|
113
|
+
data_license: str = ""
|
|
114
|
+
# SPARQL-based datasources (e.g., Wikidata)
|
|
115
|
+
sparql_endpoint: str = ""
|
|
116
|
+
queries: dict[str, str] = field(default_factory=dict)
|
|
117
|
+
# For now, only ChEBI: version threshold for new TSV format.
|
|
118
|
+
# Use if release files change location or serialization.
|
|
119
|
+
new_format_version: int | None = None
|
|
120
|
+
# Historical distribution "shapes" this datasource has had (different URL
|
|
121
|
+
# templates, formats, or archive locations across its lifetime).
|
|
122
|
+
distribution_eras: list[DistributionEra] = field(default_factory=list)
|
|
123
|
+
# Suggested cross-reference crosswalk sources
|
|
124
|
+
xref_sources: list[XrefSource] = field(default_factory=list)
|
|
125
|
+
# Species this datasource publishes
|
|
126
|
+
species: dict[str, Any] = field(default_factory=dict)
|
|
127
|
+
# Genome assembly/build metadata.
|
|
128
|
+
genome_build: dict[str, Any] = field(default_factory=dict)
|
|
129
|
+
# Compound/entry subset this datasource publishes (e.g. ChEBI's
|
|
130
|
+
# 3star/complete). Generic, config-driven counterpart to `species`.
|
|
131
|
+
subset: dict[str, Any] = field(default_factory=dict)
|
|
132
|
+
# Full metadata from YAML
|
|
133
|
+
mappingset_metadata: dict[str, Any] = field(default_factory=dict)
|
|
134
|
+
mapping_metadata: dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
def species_token(self, taxon_id: str | int) -> str:
|
|
137
|
+
"""Resolve a canonical NCBI taxon ID to this datasource's own species token.
|
|
138
|
+
|
|
139
|
+
Reads the ``species.available`` block (see ``ensembl.yaml``), which
|
|
140
|
+
maps each supported taxon ID to the datasource-specific token used to
|
|
141
|
+
build download paths/filters (e.g. Ensembl's ``homo_sapiens``).
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
taxon_id: Canonical NCBI taxon ID, e.g. ``9606`` or ``"9606"``.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The datasource-specific species token.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValueError: If no ``species`` block is configured, or *taxon_id*
|
|
151
|
+
is not one of its declared entries.
|
|
152
|
+
"""
|
|
153
|
+
available = {str(k): v for k, v in ((self.species or {}).get("available") or {}).items()}
|
|
154
|
+
entry = available.get(str(taxon_id))
|
|
155
|
+
if entry is None:
|
|
156
|
+
known = ", ".join(sorted(available)) or "(none configured)"
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Unknown species taxon ID {taxon_id!r} for {self.name!r}. Known: {known}"
|
|
159
|
+
)
|
|
160
|
+
return str(entry["token"])
|
|
161
|
+
|
|
162
|
+
def default_species(self) -> str | int:
|
|
163
|
+
"""Return the configured default species taxon ID (``9606`` if unset)."""
|
|
164
|
+
return cast("str | int", (self.species or {}).get("default", 9606))
|
|
165
|
+
|
|
166
|
+
def default_subset(self) -> str | None:
|
|
167
|
+
"""Return the configured default subset, or ``None`` if this datasource has none."""
|
|
168
|
+
return cast("str | None", (self.subset or {}).get("default"))
|
|
169
|
+
|
|
170
|
+
def xref_source(self, source_id: str) -> XrefSource | None:
|
|
171
|
+
"""Return the configured :class:`XrefSource` with id *source_id*, if any."""
|
|
172
|
+
for src in self.xref_sources:
|
|
173
|
+
if src.id == source_id:
|
|
174
|
+
return src
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def formats_for(self, kind: str) -> Any:
|
|
178
|
+
"""Return the list of supported output formats for a mapping-set kind.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
kind: Mapping-set key, e.g. ``"ids"`` or ``"labels"``.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List of format strings, or an empty list when the kind is absent.
|
|
185
|
+
"""
|
|
186
|
+
return self.mapping_sets.get(kind, {}).get("formats", [])
|
|
187
|
+
|
|
188
|
+
def era_for(self, version: str | None) -> DistributionEra | None:
|
|
189
|
+
"""Return the first configured era whose bounds contain *version*.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
version: Version string to match, or ``None``.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The matching :class:`DistributionEra`, or ``None`` if no eras are
|
|
196
|
+
configured or none match (callers should fall back to the
|
|
197
|
+
top-level ``download_urls``/``new_format_version`` behavior).
|
|
198
|
+
"""
|
|
199
|
+
if not self.distribution_eras or version is None:
|
|
200
|
+
return None
|
|
201
|
+
for era in self.distribution_eras:
|
|
202
|
+
if era.from_version is not None and _cmp_versions(version, era.from_version) < 0:
|
|
203
|
+
continue
|
|
204
|
+
if era.to_version is not None and _cmp_versions(version, era.to_version) > 0:
|
|
205
|
+
continue
|
|
206
|
+
return era
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_config(datasource_name: str, *, config_package: str) -> dict[str, Any]:
|
|
211
|
+
"""Load configuration from a YAML file for a datasource.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
datasource_name: Name of the datasource (e.g., 'chebi', 'hgnc').
|
|
215
|
+
config_package: Importable package holding the datasource's
|
|
216
|
+
``*.yaml`` config files (e.g. ``"pysec2pri.config"``).
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Dictionary with the full YAML configuration.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
FileNotFoundError: If the config file does not exist.
|
|
223
|
+
"""
|
|
224
|
+
from mapkgsutils.config.schema import validate_config_dict
|
|
225
|
+
|
|
226
|
+
config_dir = Path(_importlib_resources.files(config_package)) # type: ignore[arg-type]
|
|
227
|
+
config_path = config_dir / f"{datasource_name.lower()}.yaml"
|
|
228
|
+
if not config_path.exists():
|
|
229
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
230
|
+
|
|
231
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
232
|
+
result: dict[str, Any] = yaml.safe_load(f)
|
|
233
|
+
validate_config_dict(result, config_path.name)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_datasource_config(datasource_name: str, *, config_package: str) -> DatasourceConfig:
|
|
238
|
+
"""Load and parse a DatasourceConfig from YAML.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
datasource_name: Name of the datasource (e.g., 'chebi', 'hgnc').
|
|
242
|
+
config_package: Importable package holding the datasource's
|
|
243
|
+
``*.yaml`` config files (e.g. ``"pysec2pri.config"``).
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
DatasourceConfig object populated from YAML.
|
|
247
|
+
"""
|
|
248
|
+
raw = load_config(datasource_name, config_package=config_package)
|
|
249
|
+
|
|
250
|
+
eras = [
|
|
251
|
+
DistributionEra(
|
|
252
|
+
id=era.get("id", ""),
|
|
253
|
+
download_urls=era.get("download_urls") or {},
|
|
254
|
+
archive_url=era.get("archive_url", ""),
|
|
255
|
+
format=era.get("format"),
|
|
256
|
+
from_version=era.get("from_version"),
|
|
257
|
+
to_version=era.get("to_version"),
|
|
258
|
+
wayback=era.get("wayback", False),
|
|
259
|
+
)
|
|
260
|
+
for era in raw.get("distribution_eras", [])
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
xref_sources = [
|
|
264
|
+
XrefSource(
|
|
265
|
+
id=src.get("id", ""),
|
|
266
|
+
name=src.get("name", ""),
|
|
267
|
+
url=src.get("url", ""),
|
|
268
|
+
format=src.get("format", "tsv"),
|
|
269
|
+
object_id_col=src.get("object_id_col", "object_id"),
|
|
270
|
+
object_label_col=src.get("object_label_col", "object_label"),
|
|
271
|
+
subject_id_cols=src.get("subject_id_cols") or {},
|
|
272
|
+
note=src.get("note", ""),
|
|
273
|
+
)
|
|
274
|
+
for src in raw.get("xref_sources", [])
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
return DatasourceConfig(
|
|
278
|
+
name=raw.get("name", ""),
|
|
279
|
+
prefix=raw.get("prefix", ""),
|
|
280
|
+
curie_base_url=raw.get("curie_base_url", ""),
|
|
281
|
+
config_id=raw.get("config_id", ""),
|
|
282
|
+
datasource_id=raw.get("datasource_id", ""),
|
|
283
|
+
parser_class=raw.get("parser_class", ""),
|
|
284
|
+
parse_options=raw.get("parse_options") or {},
|
|
285
|
+
mapping_sets=raw.get("mapping_sets") or {},
|
|
286
|
+
available_outputs=raw.get("available_outputs", []),
|
|
287
|
+
default_output_filename=raw.get("default_output_filename", ""),
|
|
288
|
+
download_urls=raw.get("download_urls", {}),
|
|
289
|
+
primary_file_key=raw.get("primary_file_key", ""),
|
|
290
|
+
id_pattern=raw.get("id_pattern", ""),
|
|
291
|
+
archive_url=raw.get("archive_url", ""),
|
|
292
|
+
input_file_types=raw.get("input_file_types", []),
|
|
293
|
+
source=raw.get("source", ""),
|
|
294
|
+
homepage=raw.get("homepage", ""),
|
|
295
|
+
data_license=raw.get("data_license", ""),
|
|
296
|
+
sparql_endpoint=raw.get("sparql_endpoint", ""),
|
|
297
|
+
queries=raw.get("queries", {}),
|
|
298
|
+
new_format_version=raw.get("new_format_version"),
|
|
299
|
+
distribution_eras=eras,
|
|
300
|
+
xref_sources=xref_sources,
|
|
301
|
+
species=raw.get("species") or {},
|
|
302
|
+
genome_build=raw.get("genome_build") or {},
|
|
303
|
+
subset=raw.get("subset") or {},
|
|
304
|
+
mappingset_metadata=raw.get("mappingset", {}),
|
|
305
|
+
mapping_metadata=raw.get("mapping", {}),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# Base Downloader Class
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class BaseDownloader(ABC):
|
|
313
|
+
"""Abstract base class for datasource downloaders.
|
|
314
|
+
|
|
315
|
+
Provides shared download logic that can be inherited by datasource-specific
|
|
316
|
+
downloaders. Handles file downloads, URL construction, and version detection.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
datasource_name: str = ""
|
|
320
|
+
#: Importable package holding this datasource family's ``*.yaml``
|
|
321
|
+
#: config files. Set by the concrete framework subclass (e.g.
|
|
322
|
+
#: ``pysec2pri.parsers.base.BaseDownloader`` sets this to
|
|
323
|
+
#: ``"pysec2pri.config"``); a downloader subclass never needs to.
|
|
324
|
+
config_package: ClassVar[str] = ""
|
|
325
|
+
_config: DatasourceConfig | None = None
|
|
326
|
+
|
|
327
|
+
def __init__(
|
|
328
|
+
self,
|
|
329
|
+
version: str | None = None,
|
|
330
|
+
show_progress: bool = True,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Initialize the downloader.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
version: Version/release identifier for the datasource.
|
|
336
|
+
show_progress: Whether to show progress bars during downloads.
|
|
337
|
+
"""
|
|
338
|
+
self.version = version
|
|
339
|
+
self.show_progress = show_progress
|
|
340
|
+
|
|
341
|
+
# Load config from YAML
|
|
342
|
+
if self.datasource_name:
|
|
343
|
+
try:
|
|
344
|
+
self._config = get_datasource_config(
|
|
345
|
+
self.datasource_name.lower(), config_package=self.config_package
|
|
346
|
+
)
|
|
347
|
+
except FileNotFoundError:
|
|
348
|
+
self._config = None
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def config(self) -> DatasourceConfig | None:
|
|
352
|
+
"""Get the loaded configuration."""
|
|
353
|
+
return self._config
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def new_format_version(self) -> int | None:
|
|
357
|
+
"""Get the version threshold for new format (if any)."""
|
|
358
|
+
if self._config:
|
|
359
|
+
return self._config.new_format_version
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
def is_new_format(self, version: str | None = None) -> bool:
|
|
363
|
+
"""Check if a version uses the new format.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
version: Version to check. If None, uses self.version.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if version >= new_format_version threshold.
|
|
370
|
+
"""
|
|
371
|
+
v = version or self.version
|
|
372
|
+
threshold = self.new_format_version
|
|
373
|
+
|
|
374
|
+
if threshold is None:
|
|
375
|
+
return True # No threshold means always "new" format
|
|
376
|
+
|
|
377
|
+
if v is None:
|
|
378
|
+
return True # Default to new format for latest
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
return int(v) >= threshold
|
|
382
|
+
except ValueError:
|
|
383
|
+
return True # Default to new if version is not numeric
|
|
384
|
+
|
|
385
|
+
@abstractmethod
|
|
386
|
+
def get_download_urls(
|
|
387
|
+
self,
|
|
388
|
+
version: str | None = None,
|
|
389
|
+
**kwargs: Any,
|
|
390
|
+
) -> dict[str, str]:
|
|
391
|
+
"""Get download URLs for the datasource.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
version: Specific version to get URLs for.
|
|
395
|
+
**kwargs: Additional options (e.g., subset, force_format).
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Dictionary mapping file keys to URLs.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
@abstractmethod
|
|
402
|
+
def download(
|
|
403
|
+
self,
|
|
404
|
+
output_dir: Path,
|
|
405
|
+
version: str | None = None,
|
|
406
|
+
decompress: bool = True,
|
|
407
|
+
**kwargs: Any,
|
|
408
|
+
) -> dict[str, Path]:
|
|
409
|
+
"""Download files for the datasource.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
output_dir: Directory to save downloaded files.
|
|
413
|
+
version: Specific version to download.
|
|
414
|
+
decompress: Whether to decompress .gz files.
|
|
415
|
+
**kwargs: Additional options.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Dictionary mapping file keys to downloaded paths.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def _download_file(
|
|
422
|
+
self,
|
|
423
|
+
url: str,
|
|
424
|
+
output_path: Path,
|
|
425
|
+
decompress_gz: bool = True,
|
|
426
|
+
timeout: float | None = None,
|
|
427
|
+
description: str | None = None,
|
|
428
|
+
) -> Path:
|
|
429
|
+
"""Download a file from URL to the specified path.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
url: URL to download from.
|
|
433
|
+
output_path: Where to save the file.
|
|
434
|
+
decompress_gz: Whether to decompress .gz files automatically.
|
|
435
|
+
timeout: Request timeout in seconds.
|
|
436
|
+
description: Description for the progress bar.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Path to the downloaded file.
|
|
440
|
+
"""
|
|
441
|
+
from mapkgsutils.download import download_file
|
|
442
|
+
|
|
443
|
+
return download_file(
|
|
444
|
+
url,
|
|
445
|
+
output_path,
|
|
446
|
+
decompress_gz=decompress_gz,
|
|
447
|
+
timeout=timeout,
|
|
448
|
+
show_progress=self.show_progress,
|
|
449
|
+
description=description,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def _download_urls(
|
|
453
|
+
self,
|
|
454
|
+
urls: dict[str, str],
|
|
455
|
+
output_dir: Path,
|
|
456
|
+
decompress: bool = True,
|
|
457
|
+
) -> dict[str, Path]:
|
|
458
|
+
"""Download files from URLs to output directory.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
urls: Dictionary mapping file keys to URLs.
|
|
462
|
+
output_dir: Directory to save files.
|
|
463
|
+
decompress: Whether to decompress .gz files.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Dictionary mapping file keys to downloaded paths.
|
|
467
|
+
"""
|
|
468
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
downloaded: dict[str, Path] = {}
|
|
470
|
+
|
|
471
|
+
for key, url in urls.items():
|
|
472
|
+
filename = url.split("/")[-1]
|
|
473
|
+
|
|
474
|
+
if decompress and filename.endswith(".gz"):
|
|
475
|
+
filename = filename[:-3]
|
|
476
|
+
|
|
477
|
+
output_path = output_dir / filename
|
|
478
|
+
logger.info("Downloading %s: %s", key, url)
|
|
479
|
+
self._download_file(url, output_path, decompress_gz=decompress)
|
|
480
|
+
downloaded[key] = output_path
|
|
481
|
+
logger.info("Saved to: %s", output_path)
|
|
482
|
+
|
|
483
|
+
return downloaded
|
|
484
|
+
|
|
485
|
+
def list_versions(self) -> list[str]:
|
|
486
|
+
"""List all available archive versions for this datasource.
|
|
487
|
+
|
|
488
|
+
Subclasses for datasources that publish versioned archives should
|
|
489
|
+
override this method with source-specific retrieval logic.
|
|
490
|
+
The base implementation raises :class:`ValueError` because most
|
|
491
|
+
datasources only provide the latest release.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Sorted list of version strings available for download.
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
ValueError: Always, override in a subclass to provide versions.
|
|
498
|
+
"""
|
|
499
|
+
name = self.datasource_name or type(self).__name__
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"{name.upper()} does not maintain a versioned archive. "
|
|
502
|
+
"Only the latest release is available for download."
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def pair_hash(pri: str, sec: str) -> str:
|
|
507
|
+
"""Version-independent 16-hex-char digest for a (pri, sec) pair.
|
|
508
|
+
|
|
509
|
+
The same pair always hashes identically, regardless of release/version
|
|
510
|
+
or product (species/subset). This is the join key a cross-release
|
|
511
|
+
consolidation layer uses to match a mapping across releases (to discover
|
|
512
|
+
when it first/last appeared) -- *not* what ends up in the ``record_id``
|
|
513
|
+
field (see :func:`mint_record_id`).
|
|
514
|
+
"""
|
|
515
|
+
return hashlib.sha256(f"{pri}|{sec}".encode()).hexdigest()[:16]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def mint_record_id(pri: str, sec: str, *, namespace: str) -> str:
|
|
519
|
+
"""Mint a row's ``record_id`` -- the row's OWL Axiom IRI in SSSOM's RDF/OWL output.
|
|
520
|
+
|
|
521
|
+
Scoped to *namespace* (typically a release- and product-specific prefix,
|
|
522
|
+
see :meth:`BaseParser._record_namespace`), so the same (pri, sec) pair
|
|
523
|
+
parsed from a different release/product gets a different ``record_id``.
|
|
524
|
+
This matters because each :class:`~sssom_schema.Mapping` row is
|
|
525
|
+
serialised as an ``owl:Axiom``: if record_id didn't vary across
|
|
526
|
+
releases, loading several releases' SSSOM/RDF into one triplestore would
|
|
527
|
+
assert contradictory axioms (different predicate, cardinality,
|
|
528
|
+
confidence, ...) under the same IRI.
|
|
529
|
+
|
|
530
|
+
The trailing 16 hex characters are always :func:`pair_hash`'s
|
|
531
|
+
version-independent digest -- use that function directly (not this one)
|
|
532
|
+
for cross-release matching/lookups.
|
|
533
|
+
"""
|
|
534
|
+
return f"{namespace}{pair_hash(pri, sec)}"
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class BaseMappingSet(MappingSet): # type: ignore[misc]
|
|
538
|
+
"""A MappingSet with helpers for cardinality computation and export.
|
|
539
|
+
|
|
540
|
+
Attributes:
|
|
541
|
+
_primary_ids: Private store for the full primary ID set.
|
|
542
|
+
_primary_labels: Private store for the full primary label set.
|
|
543
|
+
_ambiguity_mode: Which field pair :func:`_find_ambiguous` checks for
|
|
544
|
+
conflicts: ``"id"`` (subject_id/object_id) or ``"label"``
|
|
545
|
+
(subject_label/object_label). Label-based subclasses override
|
|
546
|
+
this to ``"label"``.
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
_ambiguity_mode: ClassVar[str] = "id"
|
|
550
|
+
|
|
551
|
+
# Primaries are private to sssom's schema
|
|
552
|
+
# Populated by parsers that have access to the full primary ID/label list
|
|
553
|
+
# (e.g. an HGNC parser when the complete set file is provided).
|
|
554
|
+
_primary_ids: set[str]
|
|
555
|
+
# Maps label text to set of primary IDs that carry that label.
|
|
556
|
+
_primary_labels: dict[str, set[str]]
|
|
557
|
+
|
|
558
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
559
|
+
"""Initialise the mapping set and the private primary-IDs store."""
|
|
560
|
+
super().__init__(*args, **kwargs)
|
|
561
|
+
object.__setattr__(self, "_primary_ids", set())
|
|
562
|
+
object.__setattr__(self, "_primary_labels", {})
|
|
563
|
+
|
|
564
|
+
# Export helpers
|
|
565
|
+
|
|
566
|
+
def _default_stem(self) -> str:
|
|
567
|
+
"""Derive a base filename stem from mapping set metadata."""
|
|
568
|
+
ms_id: str = str(getattr(self, "mapping_set_id", None) or "") + f"/{self.version}"
|
|
569
|
+
if ms_id:
|
|
570
|
+
stem = ms_id.rstrip("/").rsplit("/", 1)[-1]
|
|
571
|
+
else:
|
|
572
|
+
stem = str(getattr(self, "mapping_set_title", None) or "mapping_set")
|
|
573
|
+
stem = stem.lower().replace(" ", "_")
|
|
574
|
+
version = getattr(self, "mapping_set_version", None)
|
|
575
|
+
if version:
|
|
576
|
+
stem = f"{stem}_{version}"
|
|
577
|
+
return stem
|
|
578
|
+
|
|
579
|
+
def _resolve_path(self, output_path: Path | str | None, suffix: str) -> Path:
|
|
580
|
+
"""Return *output_path* if given, else auto-generate one."""
|
|
581
|
+
if output_path is not None:
|
|
582
|
+
return Path(output_path)
|
|
583
|
+
return Path(f"{self._default_stem()}{suffix}")
|
|
584
|
+
|
|
585
|
+
def to_sssom(self, output_path: Path | str | None = None) -> sssom_document.MappingSetDocument:
|
|
586
|
+
"""Return an SSSOM ``MappingSetDocument``, optionally writing to TSV.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
output_path: If given, the document is also serialised to an SSSOM
|
|
590
|
+
TSV file at this path
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
:class:`sssom.sssom_document.MappingSetDocument` for the mapping set.
|
|
594
|
+
"""
|
|
595
|
+
import curies
|
|
596
|
+
from sssom.sssom_document import MappingSetDocument
|
|
597
|
+
|
|
598
|
+
raw_curie_map: object = self.curie_map or {}
|
|
599
|
+
records: list[curies.Record] = []
|
|
600
|
+
if isinstance(raw_curie_map, dict):
|
|
601
|
+
for k, v in raw_curie_map.items():
|
|
602
|
+
if isinstance(v, str):
|
|
603
|
+
uri_prefix: str = v
|
|
604
|
+
elif hasattr(v, "prefix_url"):
|
|
605
|
+
uri_prefix = cast(str, v.prefix_url)
|
|
606
|
+
else:
|
|
607
|
+
continue
|
|
608
|
+
records.append(curies.Record(prefix=k, uri_prefix=uri_prefix))
|
|
609
|
+
converter = curies.Converter(records=records)
|
|
610
|
+
doc = MappingSetDocument(mapping_set=self, converter=converter)
|
|
611
|
+
|
|
612
|
+
if output_path is not None:
|
|
613
|
+
from mapkgsutils.exports import write_sssom
|
|
614
|
+
|
|
615
|
+
write_sssom(self, self._resolve_path(output_path, "_sssom.tsv"))
|
|
616
|
+
|
|
617
|
+
return doc
|
|
618
|
+
|
|
619
|
+
def to_rdf(
|
|
620
|
+
self,
|
|
621
|
+
output_path: Path | str | None = None,
|
|
622
|
+
serialisation: str = "turtle",
|
|
623
|
+
) -> rdflib.Graph:
|
|
624
|
+
"""Return an RDFLib graph, optionally writing it to a file.
|
|
625
|
+
|
|
626
|
+
When *output_path* is given (or auto-generated via the ``save``
|
|
627
|
+
dispatcher), the graph is also serialised to disk. Either way
|
|
628
|
+
the :class:`rdflib.Graph` is returned so callers can query or
|
|
629
|
+
manipulate it directly.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
output_path: Destination path. Pass a path (or ``None`` to
|
|
633
|
+
auto-generate one) to persist the graph. If you only want
|
|
634
|
+
the in-memory graph without touching the file-system, call
|
|
635
|
+
``to_rdf()`` with no arguments and ignore the path attribute.
|
|
636
|
+
serialisation: RDFLib serialisation format (default: ``"turtle"``).
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
:class:`rdflib.Graph` containing all mappings as RDF triples.
|
|
640
|
+
"""
|
|
641
|
+
import io
|
|
642
|
+
|
|
643
|
+
import rdflib
|
|
644
|
+
from sssom.writers import write_rdf as _sssom_write_rdf
|
|
645
|
+
|
|
646
|
+
from mapkgsutils.exports import _to_msdf_via_sssom_parser, write_rdf
|
|
647
|
+
|
|
648
|
+
msdf = _to_msdf_via_sssom_parser(self)
|
|
649
|
+
if msdf is None:
|
|
650
|
+
raise ValueError("Failed to convert mapping set to RDF.")
|
|
651
|
+
|
|
652
|
+
buf = io.StringIO()
|
|
653
|
+
_sssom_write_rdf(msdf, buf, serialisation=serialisation)
|
|
654
|
+
g = rdflib.Graph()
|
|
655
|
+
g.parse(data=buf.getvalue(), format=serialisation)
|
|
656
|
+
|
|
657
|
+
if output_path is not None:
|
|
658
|
+
write_rdf(self, self._resolve_path(output_path, ".ttl"), serialisation=serialisation)
|
|
659
|
+
|
|
660
|
+
return g
|
|
661
|
+
|
|
662
|
+
def to_json(self, output_path: Path | str | None = None) -> dict[str, Any]:
|
|
663
|
+
"""Return the mapping set as a JSON-compatible ``dict``, optionally writing to file.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
output_path: If given, the JSON is also written to this path.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
``dict`` representation of the mapping set in SSSOM JSON format.
|
|
670
|
+
"""
|
|
671
|
+
import io
|
|
672
|
+
import json
|
|
673
|
+
|
|
674
|
+
from sssom.writers import write_json as _sssom_write_json
|
|
675
|
+
|
|
676
|
+
from mapkgsutils.exports import _to_msdf_via_sssom_parser
|
|
677
|
+
|
|
678
|
+
msdf = _to_msdf_via_sssom_parser(self)
|
|
679
|
+
if msdf is None:
|
|
680
|
+
raise ValueError("Failed to convert mapping set to JSON.")
|
|
681
|
+
buf = io.StringIO()
|
|
682
|
+
_sssom_write_json(msdf, buf)
|
|
683
|
+
data: dict[str, Any] = json.loads(buf.getvalue())
|
|
684
|
+
|
|
685
|
+
if output_path is not None:
|
|
686
|
+
path = self._resolve_path(output_path, ".json")
|
|
687
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
688
|
+
path.write_text(buf.getvalue(), encoding="utf-8")
|
|
689
|
+
|
|
690
|
+
return data
|
|
691
|
+
|
|
692
|
+
def to_owl(
|
|
693
|
+
self, output_path: Path | str | None = None, serialisation: str = "turtle"
|
|
694
|
+
) -> rdflib.Graph:
|
|
695
|
+
"""Return an OWL ``rdflib.Graph``, optionally writing to file.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
output_path: If given, the graph is also serialised to this path.
|
|
699
|
+
serialisation: RDFLib serialisation format (default: ``"turtle"``).
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
:class:`rdflib.Graph` containing OWL axioms for the mapping set.
|
|
703
|
+
"""
|
|
704
|
+
import io
|
|
705
|
+
|
|
706
|
+
import rdflib
|
|
707
|
+
from sssom.writers import write_owl as _sssom_write_owl
|
|
708
|
+
|
|
709
|
+
from mapkgsutils.exports import _to_msdf_via_sssom_parser
|
|
710
|
+
|
|
711
|
+
msdf = _to_msdf_via_sssom_parser(self)
|
|
712
|
+
if msdf is None:
|
|
713
|
+
raise ValueError("Failed to convert mapping set to OWL.")
|
|
714
|
+
buf = io.StringIO()
|
|
715
|
+
_sssom_write_owl(msdf, buf, serialisation=serialisation)
|
|
716
|
+
g = rdflib.Graph()
|
|
717
|
+
g.parse(data=buf.getvalue(), format=serialisation)
|
|
718
|
+
|
|
719
|
+
if output_path is not None:
|
|
720
|
+
from mapkgsutils.exports import write_owl
|
|
721
|
+
|
|
722
|
+
write_owl(
|
|
723
|
+
self, self._resolve_path(output_path, "_owl.ttl"), serialisation=serialisation
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return g
|
|
727
|
+
|
|
728
|
+
def _save_shared(
|
|
729
|
+
self,
|
|
730
|
+
fmt: str,
|
|
731
|
+
output_path: Path | str | None,
|
|
732
|
+
**kwargs: object,
|
|
733
|
+
) -> Path | None:
|
|
734
|
+
"""Write one of the shared formats (sssom/rdf/json/owl).
|
|
735
|
+
|
|
736
|
+
Returns the written :class:`Path`, or ``None`` if *fmt* is not a
|
|
737
|
+
shared format (caller should handle it).
|
|
738
|
+
"""
|
|
739
|
+
if fmt in ("rdf", "owl", "json"):
|
|
740
|
+
from collections.abc import Callable as _Callable
|
|
741
|
+
|
|
742
|
+
from mapkgsutils.exports import write_json, write_owl, write_rdf
|
|
743
|
+
|
|
744
|
+
_write_fns: dict[str, tuple[_Callable[..., Path], str]] = {
|
|
745
|
+
"rdf": (write_rdf, ".ttl"),
|
|
746
|
+
"owl": (write_owl, "_owl.ttl"),
|
|
747
|
+
"json": (write_json, ".json"),
|
|
748
|
+
}
|
|
749
|
+
fn, suffix = _write_fns[fmt]
|
|
750
|
+
return fn(self, self._resolve_path(output_path, suffix), **kwargs)
|
|
751
|
+
|
|
752
|
+
if fmt == "sssom":
|
|
753
|
+
from mapkgsutils.exports import write_sssom
|
|
754
|
+
|
|
755
|
+
return write_sssom(self, self._resolve_path(output_path, "_sssom.tsv"))
|
|
756
|
+
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
def save(
|
|
760
|
+
self,
|
|
761
|
+
fmt: str,
|
|
762
|
+
output_path: Path | str | None = None,
|
|
763
|
+
**kwargs: object,
|
|
764
|
+
) -> Path:
|
|
765
|
+
"""Write to any supported format by name.
|
|
766
|
+
|
|
767
|
+
Shared formats: ``"sssom"``, ``"rdf"``, ``"json"``, ``"owl"``.
|
|
768
|
+
Subclasses override this to add type-specific formats.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
fmt: Format key (see above).
|
|
772
|
+
output_path: Destination path. Auto-generated if ``None``.
|
|
773
|
+
**kwargs: Forwarded to the format-specific writer.
|
|
774
|
+
|
|
775
|
+
Returns:
|
|
776
|
+
Path to the written file.
|
|
777
|
+
|
|
778
|
+
Raises:
|
|
779
|
+
ValueError: For unknown format keys.
|
|
780
|
+
"""
|
|
781
|
+
shared = self._save_shared(fmt, output_path, **kwargs)
|
|
782
|
+
if shared is not None:
|
|
783
|
+
return shared
|
|
784
|
+
raise ValueError(f"Unknown format {fmt!r}. Choose from: json, owl, rdf, sssom")
|
|
785
|
+
|
|
786
|
+
def find_ambiguous(self) -> AmbiguousMappingSet:
|
|
787
|
+
"""Find mappings whose subject is also a current primary entry.
|
|
788
|
+
|
|
789
|
+
Delegates to :func:`_find_ambiguous`. See that function for full
|
|
790
|
+
semantics.
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
:class:`AmbiguousMappingSet` with all conflicting mappings
|
|
794
|
+
annotated. Empty when no ambiguities are detected.
|
|
795
|
+
"""
|
|
796
|
+
return _find_ambiguous(self)
|
|
797
|
+
|
|
798
|
+
# Cardinality helpers
|
|
799
|
+
|
|
800
|
+
def _compute_cardinalities(self, on: str = "id") -> None:
|
|
801
|
+
"""Compute and set mapping_cardinality on all mappings.
|
|
802
|
+
|
|
803
|
+
'on' can be 'id' (uses subject_id/object_id) or 'label'.
|
|
804
|
+
"""
|
|
805
|
+
if not self.mappings: # type: ignore[has-type]
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
mappings = self._normalize_mappings()
|
|
809
|
+
|
|
810
|
+
if on == "label":
|
|
811
|
+
sec_field, pri_field = "subject_label", "object_label"
|
|
812
|
+
sentinel = WITHDRAWN_ENTRY_LABEL
|
|
813
|
+
else:
|
|
814
|
+
sec_field, pri_field = "subject_id", "object_id"
|
|
815
|
+
sentinel = WITHDRAWN_ENTRY
|
|
816
|
+
|
|
817
|
+
import polars as pl
|
|
818
|
+
|
|
819
|
+
sec_vals = [str(getattr(m, sec_field, None) or "") for m in mappings]
|
|
820
|
+
pri_vals = [str(getattr(m, pri_field, None) or "") for m in mappings]
|
|
821
|
+
|
|
822
|
+
df = pl.DataFrame({"sec": sec_vals, "pri": pri_vals})
|
|
823
|
+
sec_is_nf = pl.col("sec") == sentinel
|
|
824
|
+
pri_is_nf = pl.col("pri") == sentinel
|
|
825
|
+
|
|
826
|
+
# Withdrawn (sssom:NoTermFound) rows are excluded from the
|
|
827
|
+
# distinct-counterpart counts, matching sssom's own behavior.
|
|
828
|
+
real = df.filter(~sec_is_nf & ~pri_is_nf)
|
|
829
|
+
objects_per_subject = real.group_by("sec").agg(pl.col("pri").n_unique().alias("n_objects"))
|
|
830
|
+
subjects_per_object = real.group_by("pri").agg(pl.col("sec").n_unique().alias("n_subjects"))
|
|
831
|
+
|
|
832
|
+
cardinalities: list[str] = (
|
|
833
|
+
df.join(objects_per_subject, on="sec", how="left", maintain_order="left")
|
|
834
|
+
.join(subjects_per_object, on="pri", how="left", maintain_order="left")
|
|
835
|
+
.select(
|
|
836
|
+
pl.when(sec_is_nf & pri_is_nf)
|
|
837
|
+
.then(pl.lit("0:0"))
|
|
838
|
+
.when(sec_is_nf)
|
|
839
|
+
.then(pl.lit("0:1"))
|
|
840
|
+
.when(pri_is_nf)
|
|
841
|
+
.then(pl.lit("1:0"))
|
|
842
|
+
.when((pl.col("n_subjects") == 1) & (pl.col("n_objects") == 1))
|
|
843
|
+
.then(pl.lit("1:1"))
|
|
844
|
+
.when((pl.col("n_subjects") == 1) & (pl.col("n_objects") > 1))
|
|
845
|
+
.then(pl.lit("1:n"))
|
|
846
|
+
.when((pl.col("n_subjects") > 1) & (pl.col("n_objects") == 1))
|
|
847
|
+
.then(pl.lit("n:1"))
|
|
848
|
+
.otherwise(pl.lit("n:n"))
|
|
849
|
+
.alias("cardinality")
|
|
850
|
+
)
|
|
851
|
+
.get_column("cardinality")
|
|
852
|
+
.to_list()
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
for m, card in zip(mappings, cardinalities, strict=False):
|
|
856
|
+
m.mapping_cardinality = MappingCardinalityEnum(card)
|
|
857
|
+
|
|
858
|
+
self.mappings = mappings
|
|
859
|
+
|
|
860
|
+
def _normalize_mappings(self) -> list[Mapping]:
|
|
861
|
+
"""Normalize mappings to a list of Mapping objects.
|
|
862
|
+
|
|
863
|
+
Returns:
|
|
864
|
+
List of Mapping objects.
|
|
865
|
+
"""
|
|
866
|
+
mappings = self.mappings
|
|
867
|
+
if not isinstance(mappings, list):
|
|
868
|
+
mappings = [mappings]
|
|
869
|
+
for i, m in enumerate(mappings):
|
|
870
|
+
if isinstance(m, dict):
|
|
871
|
+
mappings[i] = Mapping(**m)
|
|
872
|
+
return mappings
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class AmbiguousMappingSet(BaseMappingSet):
|
|
876
|
+
"""Mapping set of ambiguous IDs or labels.
|
|
877
|
+
|
|
878
|
+
An entry is ambiguous when the same string appears both as a current
|
|
879
|
+
primary identifier/label (in the datasource's full primary set) and
|
|
880
|
+
as a secondary identifier/label in the mapping set (i.e. it is also
|
|
881
|
+
recorded as a previous or alias term that *maps to something else*).
|
|
882
|
+
|
|
883
|
+
Because the directionality of the mapping is unclear for such entries,
|
|
884
|
+
the resolver leaves them blank and warns the user rather than silently
|
|
885
|
+
overwriting data.
|
|
886
|
+
|
|
887
|
+
Attributes:
|
|
888
|
+
ambiguous_ids: Set of ID strings that are ambiguous.
|
|
889
|
+
ambiguous_labels: Set of label strings that are ambiguous.
|
|
890
|
+
"""
|
|
891
|
+
|
|
892
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
893
|
+
"""Initialise with empty ambiguous-ID/label stores."""
|
|
894
|
+
super().__init__(*args, **kwargs)
|
|
895
|
+
object.__setattr__(self, "ambiguous_ids", set())
|
|
896
|
+
object.__setattr__(self, "ambiguous_labels", set())
|
|
897
|
+
|
|
898
|
+
@property
|
|
899
|
+
def _ambiguous_ids(self) -> Any: # Fix type
|
|
900
|
+
return object.__getattribute__(self, "ambiguous_ids")
|
|
901
|
+
|
|
902
|
+
@property
|
|
903
|
+
def _ambiguous_labels(self) -> Any: # Fix type
|
|
904
|
+
return object.__getattribute__(self, "ambiguous_labels")
|
|
905
|
+
|
|
906
|
+
def save(
|
|
907
|
+
self,
|
|
908
|
+
fmt: str,
|
|
909
|
+
output_path: Path | str | None = None,
|
|
910
|
+
**kwargs: object,
|
|
911
|
+
) -> Path:
|
|
912
|
+
"""Write to any supported format by name (sssom/rdf/json/owl)."""
|
|
913
|
+
shared = self._save_shared(fmt, output_path, **kwargs)
|
|
914
|
+
if shared is not None:
|
|
915
|
+
return shared
|
|
916
|
+
raise ValueError(f"Unknown format {fmt!r}. Choose from: json, owl, rdf, sssom")
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _build_annotated_mapping(m: Mapping, new_comment: str) -> Mapping:
|
|
920
|
+
"""Return a copy of *m* with *new_comment* set as the ``comment`` field."""
|
|
921
|
+
m_fields = {
|
|
922
|
+
k: getattr(m, k, None)
|
|
923
|
+
for k in (f.name for f in dataclass_fields(m))
|
|
924
|
+
if getattr(m, k, None) is not None
|
|
925
|
+
}
|
|
926
|
+
m_fields["comment"] = new_comment
|
|
927
|
+
return Mapping(**m_fields)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _annotate_id_mappings(
|
|
931
|
+
mappings: list[Mapping],
|
|
932
|
+
primaries: set[str] | None = None,
|
|
933
|
+
) -> list[Mapping]:
|
|
934
|
+
"""Annotate ambiguous id mappings (subject_id is also an active primary ID)."""
|
|
935
|
+
if not primaries:
|
|
936
|
+
object_ids: set[str] = {str(getattr(m, "object_id", None) or "") for m in mappings} - {""}
|
|
937
|
+
else:
|
|
938
|
+
object_ids = primaries
|
|
939
|
+
|
|
940
|
+
result: list[Mapping] = []
|
|
941
|
+
for m in mappings:
|
|
942
|
+
subj_id = str(getattr(m, "subject_id", None) or "")
|
|
943
|
+
obj_id = str(getattr(m, "object_id", None) or "")
|
|
944
|
+
if subj_id and subj_id in object_ids:
|
|
945
|
+
existing = str(getattr(m, "comment", None) or "")
|
|
946
|
+
if existing.startswith("Ambiguous mapping:"):
|
|
947
|
+
result.append(m)
|
|
948
|
+
continue
|
|
949
|
+
new_comment = (
|
|
950
|
+
f"Ambiguous mapping: secondary '{subj_id}' is also a current primary ID"
|
|
951
|
+
+ (f" (this mapping resolves to '{obj_id}')" if obj_id else "")
|
|
952
|
+
+ "."
|
|
953
|
+
+ (f" {existing}" if existing else "")
|
|
954
|
+
)
|
|
955
|
+
result.append(_build_annotated_mapping(m, new_comment))
|
|
956
|
+
else:
|
|
957
|
+
result.append(m)
|
|
958
|
+
return result
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _annotate_label_mappings(
|
|
962
|
+
mappings: list[Mapping],
|
|
963
|
+
primary_labels: dict[str, set[str]] | None = None,
|
|
964
|
+
) -> list[Mapping]:
|
|
965
|
+
"""Annotate ambiguous label mappings."""
|
|
966
|
+
if primary_labels:
|
|
967
|
+
label_to_obj_ids = primary_labels
|
|
968
|
+
else:
|
|
969
|
+
label_to_obj_ids = {}
|
|
970
|
+
for m in mappings:
|
|
971
|
+
lbl = str(getattr(m, "object_label", None) or "")
|
|
972
|
+
oid = str(getattr(m, "object_id", None) or "")
|
|
973
|
+
if lbl and oid:
|
|
974
|
+
label_to_obj_ids.setdefault(lbl, set()).add(oid)
|
|
975
|
+
|
|
976
|
+
result: list[Mapping] = []
|
|
977
|
+
for m in mappings:
|
|
978
|
+
subj_label = str(getattr(m, "subject_label", None) or "")
|
|
979
|
+
obj_id = str(getattr(m, "object_id", None) or "")
|
|
980
|
+
ids_for_label = label_to_obj_ids.get(subj_label) if subj_label else None
|
|
981
|
+
conflicting_ids = (ids_for_label - {obj_id}) if ids_for_label else set()
|
|
982
|
+
if conflicting_ids:
|
|
983
|
+
existing = str(getattr(m, "comment", None) or "")
|
|
984
|
+
if existing.startswith("Ambiguous mapping:"):
|
|
985
|
+
result.append(m)
|
|
986
|
+
continue
|
|
987
|
+
conflict_list = ", ".join(sorted(conflicting_ids))
|
|
988
|
+
new_comment = (
|
|
989
|
+
f"Ambiguous mapping: subject_label '{subj_label}' is also the"
|
|
990
|
+
f" label of {conflict_list}"
|
|
991
|
+
+ (f" (this mapping resolves to '{obj_id}')" if obj_id else "")
|
|
992
|
+
+ "."
|
|
993
|
+
+ (f" Original comment: {existing}" if existing else "")
|
|
994
|
+
)
|
|
995
|
+
result.append(_build_annotated_mapping(m, new_comment))
|
|
996
|
+
else:
|
|
997
|
+
result.append(m)
|
|
998
|
+
return result
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _annotate_ambiguous_mappings(
|
|
1002
|
+
mappings: list[Mapping],
|
|
1003
|
+
primary_labels: dict[str, set[str]] | None = None,
|
|
1004
|
+
primary_ids: set[str] | None = None,
|
|
1005
|
+
mapping_type: str = "id",
|
|
1006
|
+
) -> list[Mapping]:
|
|
1007
|
+
"""Return a new list where ambiguous mappings carry an explanatory comment.
|
|
1008
|
+
|
|
1009
|
+
For **id** mappings a mapping is ambiguous when its ``subject_id`` also
|
|
1010
|
+
appears as an ``object_id`` in the list (the secondary ID is also a live
|
|
1011
|
+
primary ID).
|
|
1012
|
+
|
|
1013
|
+
For **label** mappings a mapping is ambiguous when its ``subject_label``
|
|
1014
|
+
appears as a primary label for a *different* entity, determined by
|
|
1015
|
+
checking whether the same label text is paired with a different
|
|
1016
|
+
``object_id`` elsewhere in the list.
|
|
1017
|
+
|
|
1018
|
+
This function is called automatically by
|
|
1019
|
+
:meth:`BaseParser.create_mapping_set` so that every output format
|
|
1020
|
+
(SSSOM, RDF, JSON, OWL, …) includes the annotation without the caller
|
|
1021
|
+
having to invoke :func:`_find_ambiguous` explicitly.
|
|
1022
|
+
|
|
1023
|
+
Args:
|
|
1024
|
+
mappings: The raw list of :class:`~sssom_schema.Mapping` objects.
|
|
1025
|
+
mapping_type: ``"id"`` or ``"label"``; controls which fields are
|
|
1026
|
+
examined for ambiguity.
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
A new list where ambiguous entries have a ``comment`` prepended;
|
|
1030
|
+
non-ambiguous entries are returned unchanged (same object).
|
|
1031
|
+
"""
|
|
1032
|
+
if not mappings:
|
|
1033
|
+
return mappings
|
|
1034
|
+
|
|
1035
|
+
if mapping_type == "id":
|
|
1036
|
+
return _annotate_id_mappings(mappings, primary_ids)
|
|
1037
|
+
return _annotate_label_mappings(mappings, primary_labels)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _get_primary_sets(
|
|
1041
|
+
mapping_set: MappingSet, mappings: list[Mapping]
|
|
1042
|
+
) -> tuple[set[str], dict[str, set[str]]]:
|
|
1043
|
+
"""Return ``(primary_ids, label_index)`` for the given mapping set.
|
|
1044
|
+
|
|
1045
|
+
``label_index`` maps each label text to the set of primary IDs that carry
|
|
1046
|
+
that label.
|
|
1047
|
+
"""
|
|
1048
|
+
stored_ids: set[str] | None = (
|
|
1049
|
+
getattr(mapping_set, "_primary_ids", None) or None
|
|
1050
|
+
) # treat empty set as missing
|
|
1051
|
+
stored_labels: dict[str, set[str]] | None = (
|
|
1052
|
+
getattr(mapping_set, "_primary_labels", None) or None
|
|
1053
|
+
) # treat empty dict as missing
|
|
1054
|
+
|
|
1055
|
+
if stored_ids is None:
|
|
1056
|
+
stored_ids = {str(getattr(m, "object_id", None) or "") for m in mappings}
|
|
1057
|
+
stored_ids.discard("")
|
|
1058
|
+
if stored_labels is None:
|
|
1059
|
+
# Build label-index fallback from the mappings themselves.
|
|
1060
|
+
label_index: dict[str, set[str]] = {}
|
|
1061
|
+
for m in mappings:
|
|
1062
|
+
oid = str(getattr(m, "object_id", None) or "")
|
|
1063
|
+
lbl = str(getattr(m, "object_label", None) or "")
|
|
1064
|
+
if oid and lbl:
|
|
1065
|
+
label_index.setdefault(lbl, set()).add(oid)
|
|
1066
|
+
stored_labels = label_index
|
|
1067
|
+
|
|
1068
|
+
return stored_ids, stored_labels
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _extract_fields_ambig(m: Mapping) -> tuple[str, str, str, str, str, str]:
|
|
1072
|
+
return (
|
|
1073
|
+
str(getattr(m, "subject_id", None) or ""),
|
|
1074
|
+
str(getattr(m, "subject_label", None) or ""),
|
|
1075
|
+
str(getattr(m, "object_id", None) or ""),
|
|
1076
|
+
str(getattr(m, "object_label", None) or ""),
|
|
1077
|
+
str(getattr(m, "predicate_id", None) or ""),
|
|
1078
|
+
str(getattr(m, "comment", None) or ""),
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _build_conflicts(
|
|
1083
|
+
subj_id: str | None,
|
|
1084
|
+
subj_label: str,
|
|
1085
|
+
obj_id: str,
|
|
1086
|
+
obj_label: str,
|
|
1087
|
+
pred: str,
|
|
1088
|
+
primary_ids: set[str],
|
|
1089
|
+
primary_labels: dict[str, set[str]],
|
|
1090
|
+
mode: str,
|
|
1091
|
+
) -> tuple[list[str], set[str], set[str]]:
|
|
1092
|
+
"""Return ``(conflicts, ambiguous_ids, ambiguous_labels)`` for one mapping.
|
|
1093
|
+
|
|
1094
|
+
For **id** mode a conflict is raised when ``subj_id`` is present in
|
|
1095
|
+
``primary_ids``.
|
|
1096
|
+
|
|
1097
|
+
For labels a conflict is raised only when ``subj_label`` maps to a
|
|
1098
|
+
primary ID *other* than ``obj_id`` in ``primary_labels``.
|
|
1099
|
+
"""
|
|
1100
|
+
conflicts = []
|
|
1101
|
+
amb_ids: set[str] = set()
|
|
1102
|
+
amb_labels: set[str] = set()
|
|
1103
|
+
if mode == "id":
|
|
1104
|
+
if subj_id and subj_id in primary_ids:
|
|
1105
|
+
amb_ids.add(subj_id)
|
|
1106
|
+
conflicts.append(
|
|
1107
|
+
f"secondary '{subj_id}' is also a current primary ID"
|
|
1108
|
+
+ (f" (mapping points to '{obj_id}')" if obj_id else "")
|
|
1109
|
+
)
|
|
1110
|
+
if mode == "label":
|
|
1111
|
+
if subj_label in primary_labels:
|
|
1112
|
+
ids_for_label = primary_labels.get(subj_label) if subj_label else None
|
|
1113
|
+
conflicting_ids = (ids_for_label - {obj_id}) if ids_for_label else set()
|
|
1114
|
+
if conflicting_ids:
|
|
1115
|
+
amb_labels.add(subj_label)
|
|
1116
|
+
conflict_list = ", ".join(sorted(conflicting_ids))
|
|
1117
|
+
conflicts.append(
|
|
1118
|
+
f"subject_label '{subj_label}' is also the primary label of {conflict_list}"
|
|
1119
|
+
+ (f" (this mapping resolves to '{obj_id}')" if obj_id else "")
|
|
1120
|
+
)
|
|
1121
|
+
return conflicts, amb_ids, amb_labels
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _make_annotated_mapping(m: Mapping, conflicts: list[str], existing_raw: str) -> Mapping:
|
|
1125
|
+
if existing_raw.startswith("Ambiguous mapping:"):
|
|
1126
|
+
# Already wrapped by _annotate_id_mappings/_annotate_label_mappings;
|
|
1127
|
+
# unwrap to the truly original comment to avoid nested wrapping.
|
|
1128
|
+
marker = " Original comment: "
|
|
1129
|
+
original = existing_raw.split(marker, 1)[1] if marker in existing_raw else ""
|
|
1130
|
+
else:
|
|
1131
|
+
original = existing_raw
|
|
1132
|
+
|
|
1133
|
+
conflict_detail = "; ".join(conflicts)
|
|
1134
|
+
|
|
1135
|
+
new_comment = f"Ambiguous: {conflict_detail}." + (
|
|
1136
|
+
f" Original comment: {original}" if original else ""
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
m_fields = {
|
|
1140
|
+
k: getattr(m, k, None)
|
|
1141
|
+
for k in (f.name for f in dataclass_fields(m))
|
|
1142
|
+
if getattr(m, k, None) is not None
|
|
1143
|
+
}
|
|
1144
|
+
m_fields["comment"] = new_comment
|
|
1145
|
+
return Mapping(**m_fields)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _find_ambiguous(mapping_set: BaseMappingSet) -> AmbiguousMappingSet:
|
|
1149
|
+
"""Identify mappings whose subject is also a current primary entry.
|
|
1150
|
+
|
|
1151
|
+
Args:
|
|
1152
|
+
mapping_set: Any :class:`BaseMappingSet` (id- or label-based).
|
|
1153
|
+
|
|
1154
|
+
Returns:
|
|
1155
|
+
:class:`AmbiguousMappingSet` whose mappings each carry a ``comment``
|
|
1156
|
+
explaining which primary terms the subject conflicts with. The sets
|
|
1157
|
+
``ambiguous_ids`` and ``ambiguous_labels`` are populated accordingly.
|
|
1158
|
+
Returns an empty :class:`AmbiguousMappingSet` when no ambiguities are
|
|
1159
|
+
found.
|
|
1160
|
+
"""
|
|
1161
|
+
mode = mapping_set._ambiguity_mode
|
|
1162
|
+
get_subj = "subject_id" if mode == "id" else "object_id"
|
|
1163
|
+
mappings = list(mapping_set.mappings or [])
|
|
1164
|
+
primary_ids, primary_labels = _get_primary_sets(mapping_set, mappings)
|
|
1165
|
+
|
|
1166
|
+
ambiguous_mappings = []
|
|
1167
|
+
ambiguous_ids = set()
|
|
1168
|
+
ambiguous_labels = set()
|
|
1169
|
+
|
|
1170
|
+
for m in mappings:
|
|
1171
|
+
subj_id, subj_label, obj_id, obj_label, pred, raw_comment = _extract_fields_ambig(m)
|
|
1172
|
+
conflicts, ids, labels = _build_conflicts(
|
|
1173
|
+
subj_id, subj_label, obj_id, obj_label, pred, primary_ids, primary_labels, mode
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
ambiguous_ids |= ids
|
|
1177
|
+
ambiguous_labels |= labels
|
|
1178
|
+
if not conflicts:
|
|
1179
|
+
continue
|
|
1180
|
+
|
|
1181
|
+
ambiguous_mappings.append(_make_annotated_mapping(m, conflicts, raw_comment))
|
|
1182
|
+
|
|
1183
|
+
if ambiguous_mappings:
|
|
1184
|
+
annotated_by_subj = {
|
|
1185
|
+
str(getattr(am, "subject_id", None) or ""): am for am in ambiguous_mappings
|
|
1186
|
+
}
|
|
1187
|
+
annotated_by_label = {
|
|
1188
|
+
str(getattr(am, "subject_label", None) or ""): am for am in ambiguous_mappings
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
updated_source = []
|
|
1192
|
+
changed = False
|
|
1193
|
+
|
|
1194
|
+
for m in mappings:
|
|
1195
|
+
subj_id = str(getattr(m, get_subj, None) or "")
|
|
1196
|
+
subj_label = str(getattr(m, "subject_label", None) or "")
|
|
1197
|
+
|
|
1198
|
+
replacement = annotated_by_subj.get(subj_id) or annotated_by_label.get(subj_label)
|
|
1199
|
+
|
|
1200
|
+
if replacement is not None and replacement is not m:
|
|
1201
|
+
updated_source.append(replacement)
|
|
1202
|
+
changed = True
|
|
1203
|
+
else:
|
|
1204
|
+
updated_source.append(m)
|
|
1205
|
+
|
|
1206
|
+
if changed:
|
|
1207
|
+
mapping_set.mappings = updated_source
|
|
1208
|
+
|
|
1209
|
+
kwargs = {}
|
|
1210
|
+
for attr in (
|
|
1211
|
+
"curie_map",
|
|
1212
|
+
"mapping_set_id",
|
|
1213
|
+
"mapping_set_title",
|
|
1214
|
+
"mapping_set_description",
|
|
1215
|
+
"license",
|
|
1216
|
+
"creator_id",
|
|
1217
|
+
"creator_label",
|
|
1218
|
+
"mapping_provider",
|
|
1219
|
+
"mapping_tool",
|
|
1220
|
+
"mapping_tool_version",
|
|
1221
|
+
"mapping_date",
|
|
1222
|
+
"subject_source",
|
|
1223
|
+
"subject_source_version",
|
|
1224
|
+
"object_source",
|
|
1225
|
+
"object_source_version",
|
|
1226
|
+
):
|
|
1227
|
+
val = getattr(mapping_set, attr, None)
|
|
1228
|
+
if val is not None:
|
|
1229
|
+
kwargs[attr] = val
|
|
1230
|
+
|
|
1231
|
+
result = AmbiguousMappingSet(mappings=ambiguous_mappings, **kwargs)
|
|
1232
|
+
|
|
1233
|
+
object.__setattr__(result, "ambiguous_ids", ambiguous_ids)
|
|
1234
|
+
object.__setattr__(result, "ambiguous_labels", ambiguous_labels)
|
|
1235
|
+
|
|
1236
|
+
result._compute_cardinalities()
|
|
1237
|
+
return result
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
class BaseParser(ABC):
|
|
1241
|
+
"""Abstract base class for all datasource parsers.
|
|
1242
|
+
|
|
1243
|
+
Each parser is responsible for reading files from a specific datasource
|
|
1244
|
+
and extracting a :class:`BaseMappingSet` of cross-references between two
|
|
1245
|
+
identifier/label spaces.
|
|
1246
|
+
"""
|
|
1247
|
+
|
|
1248
|
+
# To be overridden by subclasses
|
|
1249
|
+
datasource_name: str = ""
|
|
1250
|
+
default_source_url: str = ""
|
|
1251
|
+
#: Importable package holding this datasource family's ``*.yaml``
|
|
1252
|
+
#: config files. Set by the concrete framework subclass.
|
|
1253
|
+
config_package: ClassVar[str] = ""
|
|
1254
|
+
#: Which :class:`BaseMappingSet` subclass :meth:`create_mapping_set`
|
|
1255
|
+
#: instantiates for each ``mapping_type``. The concrete framework
|
|
1256
|
+
#: subclass overrides this with its own mapping-set classes.
|
|
1257
|
+
mapping_set_classes: ClassVar[dict[str, type[BaseMappingSet]]] = {
|
|
1258
|
+
"id": BaseMappingSet,
|
|
1259
|
+
"label": BaseMappingSet,
|
|
1260
|
+
}
|
|
1261
|
+
#: Recorded as the SSSOM ``mapping_tool_version`` of generated mapping
|
|
1262
|
+
#: sets. Set by the concrete framework subclass to its own version.
|
|
1263
|
+
mapping_tool_version: ClassVar[str] = ""
|
|
1264
|
+
_config: DatasourceConfig | None = None
|
|
1265
|
+
|
|
1266
|
+
def __init__(
|
|
1267
|
+
self,
|
|
1268
|
+
version: str | None = None,
|
|
1269
|
+
show_progress: bool = True,
|
|
1270
|
+
config_name: str | None = None,
|
|
1271
|
+
):
|
|
1272
|
+
"""Initialize the parser.
|
|
1273
|
+
|
|
1274
|
+
Args:
|
|
1275
|
+
version: Version/release identifier for the datasource.
|
|
1276
|
+
show_progress: Whether to show progress bars during parsing.
|
|
1277
|
+
config_name: Name of config file to load (defaults to class name).
|
|
1278
|
+
"""
|
|
1279
|
+
self.version = version
|
|
1280
|
+
self.show_progress = show_progress
|
|
1281
|
+
# Genome assembly/build for the SSSOM ``*_source_version`` fields.
|
|
1282
|
+
# Default ``None`` -> resolved from config per species, or left to
|
|
1283
|
+
# fall back to ``self.version``. Parsers that read a build straight
|
|
1284
|
+
# from the data (e.g. an Ensembl-style ``mapping_session``) may set
|
|
1285
|
+
# this, or override per mapping row.
|
|
1286
|
+
self.genome_build: str | None = None
|
|
1287
|
+
# Release date of the source data, used for the SSSOM ``mapping_date``.
|
|
1288
|
+
# Set by the download layer to the upstream release date; falls back
|
|
1289
|
+
# to the version when that is an ISO date (e.g. quarterly
|
|
1290
|
+
# date-versioned archives) or to today as a last resort.
|
|
1291
|
+
self.release_date: str | date | datetime | None = None
|
|
1292
|
+
|
|
1293
|
+
# Load config from YAML if available
|
|
1294
|
+
if config_name:
|
|
1295
|
+
self._config = get_datasource_config(config_name, config_package=self.config_package)
|
|
1296
|
+
elif self.datasource_name:
|
|
1297
|
+
try:
|
|
1298
|
+
self._config = get_datasource_config(
|
|
1299
|
+
self.datasource_name.lower(), config_package=self.config_package
|
|
1300
|
+
)
|
|
1301
|
+
except FileNotFoundError:
|
|
1302
|
+
self._config = None
|
|
1303
|
+
|
|
1304
|
+
@property
|
|
1305
|
+
def config(self) -> DatasourceConfig | None:
|
|
1306
|
+
"""Get the loaded configuration."""
|
|
1307
|
+
return self._config
|
|
1308
|
+
|
|
1309
|
+
def get_download_url(self, key: str) -> str | None:
|
|
1310
|
+
"""Get a download URL from config by key."""
|
|
1311
|
+
if self._config:
|
|
1312
|
+
return self._config.download_urls.get(key)
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
def get_curie_map(self) -> dict[str, str]:
|
|
1316
|
+
"""Get the CURIE map from config."""
|
|
1317
|
+
if self._config and self._config.mappingset_metadata:
|
|
1318
|
+
result: dict[str, str] = self._config.mappingset_metadata.get("curie_map", {})
|
|
1319
|
+
return result
|
|
1320
|
+
return {}
|
|
1321
|
+
|
|
1322
|
+
def get_mappingset_metadata(self) -> dict[str, Any]:
|
|
1323
|
+
"""Get mapping set metadata from config."""
|
|
1324
|
+
if self._config:
|
|
1325
|
+
result: dict[str, Any] = self._config.mappingset_metadata
|
|
1326
|
+
return result
|
|
1327
|
+
return {}
|
|
1328
|
+
|
|
1329
|
+
def get_mapping_metadata(self) -> dict[str, Any]:
|
|
1330
|
+
"""Get mapping metadata from config."""
|
|
1331
|
+
if self._config:
|
|
1332
|
+
result: dict[str, Any] = self._config.mapping_metadata
|
|
1333
|
+
return result
|
|
1334
|
+
return {}
|
|
1335
|
+
|
|
1336
|
+
def load_metadata(self, yaml_path: str) -> dict[str, Any]:
|
|
1337
|
+
"""Load metadata from a YAML config file."""
|
|
1338
|
+
with open(yaml_path, encoding="utf-8") as f:
|
|
1339
|
+
result: dict[str, Any] = yaml.safe_load(f)
|
|
1340
|
+
return result
|
|
1341
|
+
|
|
1342
|
+
def apply_metadata_to_mappingset(
|
|
1343
|
+
self,
|
|
1344
|
+
mappingset: MappingSet,
|
|
1345
|
+
metadata: dict[str, Any],
|
|
1346
|
+
) -> None:
|
|
1347
|
+
"""Apply metadata to a MappingSet and its Mappings."""
|
|
1348
|
+
# Set MappingSet fields
|
|
1349
|
+
for key, value in metadata.get("mappingset", {}).items():
|
|
1350
|
+
if hasattr(mappingset, key) and value is not None:
|
|
1351
|
+
setattr(mappingset, key, value)
|
|
1352
|
+
# Set Mapping fields
|
|
1353
|
+
if hasattr(mappingset, "mappings") and mappingset.mappings:
|
|
1354
|
+
for mapping in mappingset.mappings:
|
|
1355
|
+
for key, value in metadata.get("mapping", {}).items():
|
|
1356
|
+
if hasattr(mapping, key) and value is not None:
|
|
1357
|
+
setattr(mapping, key, value)
|
|
1358
|
+
|
|
1359
|
+
@staticmethod
|
|
1360
|
+
def _pair_hash(pri: str, sec: str) -> str:
|
|
1361
|
+
"""See :func:`pair_hash`."""
|
|
1362
|
+
return pair_hash(pri, sec)
|
|
1363
|
+
|
|
1364
|
+
def _record_id(self, namespace: str, pri: str, sec: str) -> str:
|
|
1365
|
+
"""See :func:`mint_record_id`."""
|
|
1366
|
+
return mint_record_id(pri, sec, namespace=namespace)
|
|
1367
|
+
|
|
1368
|
+
def _product_slug(self) -> str | None:
|
|
1369
|
+
"""Extra IRI path segment identifying the run's data product.
|
|
1370
|
+
|
|
1371
|
+
``None`` for most parsers (one release == one product). Override
|
|
1372
|
+
when a parser option selects a genuinely different dataset rather
|
|
1373
|
+
than just a different output mode -- e.g. a species selector for a
|
|
1374
|
+
multi-species datasource, where the same release number produces a
|
|
1375
|
+
disjoint set of mappings per species. Folded into ``mapping_set_id``
|
|
1376
|
+
and :meth:`_record_namespace` so two runs that differ only in this
|
|
1377
|
+
option don't collide on either IRI.
|
|
1378
|
+
"""
|
|
1379
|
+
return None
|
|
1380
|
+
|
|
1381
|
+
def _record_namespace(self) -> str:
|
|
1382
|
+
"""Return this run's ``record_id`` namespace: ``{base}/{version}/{slug}/``.
|
|
1383
|
+
|
|
1384
|
+
Mirrors ``mapping_set_id``'s ``{base}/{version}/{slug}`` ordering
|
|
1385
|
+
(see :meth:`create_mapping_set`) so a mapping's ``record_id`` is
|
|
1386
|
+
scoped to the same release/product as the mapping *set* it's
|
|
1387
|
+
asserted in -- use this (instead of reading
|
|
1388
|
+
``mapping_metadata()["record_id"]`` directly) when building
|
|
1389
|
+
per-row ``record_id`` values.
|
|
1390
|
+
"""
|
|
1391
|
+
base = str(self.get_mapping_metadata().get("record_id") or "")
|
|
1392
|
+
version = str(self.version) if self.version else None
|
|
1393
|
+
parts = [p for p in (version, self._product_slug()) if p]
|
|
1394
|
+
return base + "".join(f"{p}/" for p in parts)
|
|
1395
|
+
|
|
1396
|
+
def _extract_version_from_file(self, file_path: Path) -> str | None:
|
|
1397
|
+
"""Extract a version string embedded in a data file's header.
|
|
1398
|
+
|
|
1399
|
+
Override in subclasses where the source file contains release
|
|
1400
|
+
metadata (e.g. ``Release: 2026_01`` in UniProt flat files).
|
|
1401
|
+
|
|
1402
|
+
Args:
|
|
1403
|
+
file_path: Path to the data file to inspect.
|
|
1404
|
+
|
|
1405
|
+
Returns:
|
|
1406
|
+
Version string, or ``None`` if not found.
|
|
1407
|
+
"""
|
|
1408
|
+
return None
|
|
1409
|
+
|
|
1410
|
+
def _resolve_version(self, file_path: Path | None = None) -> str:
|
|
1411
|
+
"""Resolve the dataset version to use for source version fields.
|
|
1412
|
+
|
|
1413
|
+
Resolution order:
|
|
1414
|
+
1. ``self.version`` if already set explicitly.
|
|
1415
|
+
2. Version extracted from file header via ``_extract_version_from_file``.
|
|
1416
|
+
3. ISO date or release token found in the filename stem
|
|
1417
|
+
(e.g. ``withdrawn_2026-04-07.txt`` -> ``2026-04-07``,
|
|
1418
|
+
``chebi_245.sdf`` -> ``245``).
|
|
1419
|
+
4. File modification date (ISO-8601) when a path is provided.
|
|
1420
|
+
5. Today's date as a last resort.
|
|
1421
|
+
|
|
1422
|
+
Sets ``self.version`` to the resolved value so that
|
|
1423
|
+
``create_mapping_set`` picks it up for ``subject_source_version`` /
|
|
1424
|
+
``object_source_version`` automatically.
|
|
1425
|
+
|
|
1426
|
+
Args:
|
|
1427
|
+
file_path: Optional path to the primary input file.
|
|
1428
|
+
|
|
1429
|
+
Returns:
|
|
1430
|
+
Resolved version string.
|
|
1431
|
+
"""
|
|
1432
|
+
if self.version:
|
|
1433
|
+
return self.version
|
|
1434
|
+
|
|
1435
|
+
if file_path is not None:
|
|
1436
|
+
file_path = Path(file_path)
|
|
1437
|
+
# 1. Try header-embedded version (parser-specific override)
|
|
1438
|
+
extracted = self._extract_version_from_file(file_path)
|
|
1439
|
+
if extracted:
|
|
1440
|
+
self.version = extracted
|
|
1441
|
+
return self.version
|
|
1442
|
+
# 2. Try ISO date (YYYY-MM-DD) in the filename stem
|
|
1443
|
+
iso_match = re.search(r"\d{4}-\d{2}-\d{2}", file_path.stem)
|
|
1444
|
+
if iso_match:
|
|
1445
|
+
self.version = iso_match.group(0)
|
|
1446
|
+
return self.version
|
|
1447
|
+
# 3. Try a plain numeric/semver token in the filename stem
|
|
1448
|
+
# e.g. "chebi_245" -> "245", "gene_history_v2" -> "2"
|
|
1449
|
+
num_match = re.search(r"(?<![.\d])(\d{3,})(?![.\d])", file_path.stem)
|
|
1450
|
+
if num_match:
|
|
1451
|
+
self.version = num_match.group(1)
|
|
1452
|
+
return self.version
|
|
1453
|
+
# 4. Fall back to file modification time
|
|
1454
|
+
try:
|
|
1455
|
+
mtime = file_path.stat().st_mtime
|
|
1456
|
+
self.version = date.fromtimestamp(mtime).isoformat()
|
|
1457
|
+
return self.version
|
|
1458
|
+
except OSError:
|
|
1459
|
+
pass
|
|
1460
|
+
|
|
1461
|
+
self.version = date.today().isoformat()
|
|
1462
|
+
return self.version
|
|
1463
|
+
|
|
1464
|
+
@abstractmethod
|
|
1465
|
+
def parse(self, input_path: Path | str | None) -> MappingSet:
|
|
1466
|
+
"""Parse the input file(s) and return a MappingSet.
|
|
1467
|
+
|
|
1468
|
+
Args:
|
|
1469
|
+
input_path: Path to the input file or directory.
|
|
1470
|
+
|
|
1471
|
+
Returns:
|
|
1472
|
+
A MappingSet containing all extracted mappings.
|
|
1473
|
+
"""
|
|
1474
|
+
|
|
1475
|
+
def _progress(
|
|
1476
|
+
self,
|
|
1477
|
+
iterable: Iterable[_T],
|
|
1478
|
+
desc: str | None = None,
|
|
1479
|
+
total: int | None = None,
|
|
1480
|
+
) -> Iterable[_T]:
|
|
1481
|
+
"""Wrap an iterable with a progress bar if enabled.
|
|
1482
|
+
|
|
1483
|
+
Args:
|
|
1484
|
+
iterable: The iterable to wrap.
|
|
1485
|
+
desc: Description for the progress bar.
|
|
1486
|
+
total: Total number of items (if known).
|
|
1487
|
+
|
|
1488
|
+
Returns:
|
|
1489
|
+
The iterable, optionally wrapped in tqdm.
|
|
1490
|
+
"""
|
|
1491
|
+
if self.show_progress:
|
|
1492
|
+
return cast(Iterable[_T], tqdm(iterable, desc=desc, total=total))
|
|
1493
|
+
return iterable
|
|
1494
|
+
|
|
1495
|
+
def _label_predicate_for_type(self, label_type: str) -> dict[str, str]:
|
|
1496
|
+
"""Return predicate fields for a label mapping type.
|
|
1497
|
+
|
|
1498
|
+
Used by :meth:`_build_mappings` when a row carries a ``_label_type``
|
|
1499
|
+
key.
|
|
1500
|
+
|
|
1501
|
+
- ``"previous"``: the secondary name, label or label ``IAO:0100001`` "term replaced by").
|
|
1502
|
+
- ``"alias"`` (or any other value): a valid alternative name: ``oboInOwl:hasExactSynonym``.
|
|
1503
|
+
|
|
1504
|
+
Args:
|
|
1505
|
+
label_type: ``"previous"`` or ``"alias"``.
|
|
1506
|
+
|
|
1507
|
+
Returns:
|
|
1508
|
+
Dict with at least ``predicate_id`` and, where available,
|
|
1509
|
+
``predicate_label``.
|
|
1510
|
+
"""
|
|
1511
|
+
if label_type == "previous":
|
|
1512
|
+
m_meta = self.get_mapping_metadata()
|
|
1513
|
+
result: dict[str, str] = {"predicate_id": m_meta["predicate_id"]}
|
|
1514
|
+
pred_label = m_meta.get("predicate_label")
|
|
1515
|
+
if pred_label:
|
|
1516
|
+
result["predicate_label"] = str(pred_label)
|
|
1517
|
+
return result
|
|
1518
|
+
return {
|
|
1519
|
+
"predicate_id": "oboInOwl:hasExactSynonym",
|
|
1520
|
+
"predicate_label": "has exact synonym",
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
def _finalize_row(self, row: dict[str, Any]) -> dict[str, Any]:
|
|
1524
|
+
"""Resolve ``_label_type`` into predicate fields and remove the key.
|
|
1525
|
+
|
|
1526
|
+
If the row contains a ``_label_type`` entry and does not already
|
|
1527
|
+
have an explicit ``predicate_id``, the appropriate predicate fields
|
|
1528
|
+
are injected via :meth:`_label_predicate_for_type`. The sentinel key
|
|
1529
|
+
is always removed before the row is used to construct a
|
|
1530
|
+
:class:`~sssom_schema.Mapping`.
|
|
1531
|
+
|
|
1532
|
+
Args:
|
|
1533
|
+
row: Merged row dict (may contain ``_label_type``).
|
|
1534
|
+
|
|
1535
|
+
Returns:
|
|
1536
|
+
The same dict, mutated in-place and returned for convenience.
|
|
1537
|
+
"""
|
|
1538
|
+
label_type = row.pop("_label_type", None)
|
|
1539
|
+
if label_type is not None and "predicate_id" not in row:
|
|
1540
|
+
row.update(self._label_predicate_for_type(label_type))
|
|
1541
|
+
return row
|
|
1542
|
+
|
|
1543
|
+
def _build_mappings(
|
|
1544
|
+
self,
|
|
1545
|
+
rows: Iterable[dict[str, Any]],
|
|
1546
|
+
fixed_fields: dict[str, Any] | None = None,
|
|
1547
|
+
*,
|
|
1548
|
+
desc: str = "Building mappings",
|
|
1549
|
+
total: int | None = None,
|
|
1550
|
+
) -> list[Mapping]:
|
|
1551
|
+
"""Build SSSOM Mapping objects from row dicts.
|
|
1552
|
+
|
|
1553
|
+
Automatically injects mapping-level fields from the parser's
|
|
1554
|
+
config metadata (e.g. ``confidence``) unless the caller already
|
|
1555
|
+
provides them in ``fixed_fields`` or individual row dicts.
|
|
1556
|
+
|
|
1557
|
+
Rows may carry a special ``_label_type`` key (``"alias"`` or
|
|
1558
|
+
``"previous"``) instead of an explicit ``predicate_id``; the base
|
|
1559
|
+
class will resolve it to the correct predicate via
|
|
1560
|
+
:meth:`_label_predicate_for_type` before constructing the
|
|
1561
|
+
:class:`~sssom_schema.Mapping`.
|
|
1562
|
+
|
|
1563
|
+
Args:
|
|
1564
|
+
rows: Per-row fields as dicts (subject_id, object_id, etc.).
|
|
1565
|
+
fixed_fields: Fields shared by all rows (predicate_id, license, etc.).
|
|
1566
|
+
desc: Progress bar description.
|
|
1567
|
+
total: Total count for the progress bar.
|
|
1568
|
+
|
|
1569
|
+
Returns:
|
|
1570
|
+
List of Mapping objects.
|
|
1571
|
+
"""
|
|
1572
|
+
_auto_fields = ("confidence",)
|
|
1573
|
+
m_meta = self.get_mapping_metadata()
|
|
1574
|
+
auto: dict[str, Any] = {
|
|
1575
|
+
k: m_meta[k] for k in _auto_fields if k in m_meta and m_meta[k] is not None
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
# Build base
|
|
1579
|
+
base: dict[str, Any] = {**auto, **(fixed_fields or {})}
|
|
1580
|
+
|
|
1581
|
+
if base:
|
|
1582
|
+
merged: Iterable[dict[str, Any]] = (self._finalize_row({**base, **row}) for row in rows)
|
|
1583
|
+
else:
|
|
1584
|
+
merged = (self._finalize_row(dict(row)) for row in rows)
|
|
1585
|
+
return [Mapping(**row) for row in self._progress(merged, desc=desc, total=total)]
|
|
1586
|
+
|
|
1587
|
+
def _build_comment(
|
|
1588
|
+
self,
|
|
1589
|
+
base_comment: str,
|
|
1590
|
+
additional: str | None = None,
|
|
1591
|
+
) -> str:
|
|
1592
|
+
"""Build a comment string with version information.
|
|
1593
|
+
|
|
1594
|
+
Args:
|
|
1595
|
+
base_comment: The base comment text.
|
|
1596
|
+
additional: Additional text to append.
|
|
1597
|
+
|
|
1598
|
+
Returns:
|
|
1599
|
+
The complete comment string.
|
|
1600
|
+
"""
|
|
1601
|
+
parts = [base_comment] if base_comment else []
|
|
1602
|
+
if additional:
|
|
1603
|
+
parts.append(additional)
|
|
1604
|
+
if self.version:
|
|
1605
|
+
parts.append(f"Release: {self.version}.")
|
|
1606
|
+
return " ".join(parts)
|
|
1607
|
+
|
|
1608
|
+
def _find_merged_column(
|
|
1609
|
+
self,
|
|
1610
|
+
columns: list[str],
|
|
1611
|
+
merged_info_patterns: list[str],
|
|
1612
|
+
) -> str | None:
|
|
1613
|
+
"""Find the merged info column regardless of naming variant."""
|
|
1614
|
+
normalized_patterns = [p.lower() for p in merged_info_patterns]
|
|
1615
|
+
for col in columns:
|
|
1616
|
+
normalized = self._normalize_column_name(col)
|
|
1617
|
+
if normalized in normalized_patterns:
|
|
1618
|
+
return col
|
|
1619
|
+
# Also check for partial match on key identifying part
|
|
1620
|
+
if "merged_into_report" in normalized:
|
|
1621
|
+
return col
|
|
1622
|
+
return None
|
|
1623
|
+
|
|
1624
|
+
@staticmethod
|
|
1625
|
+
def _normalize_column_name(col: str) -> str:
|
|
1626
|
+
"""Normalize column name for case-insensitive matching."""
|
|
1627
|
+
return col.lower().strip()
|
|
1628
|
+
|
|
1629
|
+
@staticmethod
|
|
1630
|
+
def _find_column(columns: list[str], name: str) -> str | None:
|
|
1631
|
+
"""Find column by case-insensitive name."""
|
|
1632
|
+
lower_name = name.lower()
|
|
1633
|
+
for col in columns:
|
|
1634
|
+
if col.lower() == lower_name:
|
|
1635
|
+
return col
|
|
1636
|
+
return None
|
|
1637
|
+
|
|
1638
|
+
@staticmethod
|
|
1639
|
+
def normalize_withdrawn_id(subject_id: str | None) -> str:
|
|
1640
|
+
"""Normalize a primary ID, converting empty/null to withdrawn.
|
|
1641
|
+
|
|
1642
|
+
Args:
|
|
1643
|
+
subject_id: The raw primary identifier from the source file.
|
|
1644
|
+
|
|
1645
|
+
Returns:
|
|
1646
|
+
The normalized primary ID, or WITHDRAWN_ENTRY for empty values.
|
|
1647
|
+
"""
|
|
1648
|
+
if not subject_id or subject_id in ("-", ""):
|
|
1649
|
+
return WITHDRAWN_ENTRY
|
|
1650
|
+
return subject_id
|
|
1651
|
+
|
|
1652
|
+
@staticmethod
|
|
1653
|
+
def is_withdrawn(identifier: str) -> bool:
|
|
1654
|
+
"""Return if is withdrawn."""
|
|
1655
|
+
return WITHDRAWN_ENTRY == identifier
|
|
1656
|
+
|
|
1657
|
+
@staticmethod
|
|
1658
|
+
def _split_labels(labels_str: str, sep: str = "|") -> list[str]:
|
|
1659
|
+
"""Split a separated string of labels."""
|
|
1660
|
+
if not labels_str:
|
|
1661
|
+
return []
|
|
1662
|
+
return [s.strip() for s in labels_str.split(sep) if s.strip()]
|
|
1663
|
+
|
|
1664
|
+
@staticmethod
|
|
1665
|
+
def is_withdrawn_primary(id: str) -> bool:
|
|
1666
|
+
"""Check if an ID represents a withdrawn/deleted entry.
|
|
1667
|
+
|
|
1668
|
+
Args:
|
|
1669
|
+
id: The primary identifier to check.
|
|
1670
|
+
|
|
1671
|
+
Returns:
|
|
1672
|
+
True if the primary ID indicates a withdrawn entry.
|
|
1673
|
+
"""
|
|
1674
|
+
return id == WITHDRAWN_ENTRY
|
|
1675
|
+
|
|
1676
|
+
@staticmethod
|
|
1677
|
+
def _parse_merged_info(merged_str: str) -> tuple[str, str] | None:
|
|
1678
|
+
"""Parse merged_into_report to extract hgnc_id and label.
|
|
1679
|
+
|
|
1680
|
+
Returns (hgnc_id, label) or None if parsing fails.
|
|
1681
|
+
"""
|
|
1682
|
+
if not merged_str or merged_str == "":
|
|
1683
|
+
return None
|
|
1684
|
+
# Try pipe separator first then slash
|
|
1685
|
+
if "|" in merged_str:
|
|
1686
|
+
parts = merged_str.split("|")
|
|
1687
|
+
else:
|
|
1688
|
+
parts = merged_str.split("/")
|
|
1689
|
+
if len(parts) >= 2:
|
|
1690
|
+
return (parts[0].strip(), parts[1].strip())
|
|
1691
|
+
return None
|
|
1692
|
+
|
|
1693
|
+
def _resolve_mapping_date(self) -> str:
|
|
1694
|
+
"""Resolve the SSSOM ``mapping_date`` for the output mapping set.
|
|
1695
|
+
|
|
1696
|
+
The mapping date reflects when the source data was released, not when
|
|
1697
|
+
the mapping set was generated. Resolution order:
|
|
1698
|
+
|
|
1699
|
+
1. ``self.release_date`` when set by the download layer (the upstream
|
|
1700
|
+
release date, e.g. an HTTP ``Last-Modified`` or archive date).
|
|
1701
|
+
2. ``self.version`` when it is an ISO date (``YYYY-MM-DD``), which is
|
|
1702
|
+
the most specific signal for sources whose version *is* a date
|
|
1703
|
+
(e.g. a quarterly date-versioned archive).
|
|
1704
|
+
3. Today's date as a last resort (e.g. live SPARQL queries).
|
|
1705
|
+
|
|
1706
|
+
Returns:
|
|
1707
|
+
ISO-8601 date string (``YYYY-MM-DD``).
|
|
1708
|
+
"""
|
|
1709
|
+
rd = self.release_date
|
|
1710
|
+
if isinstance(rd, datetime):
|
|
1711
|
+
return rd.date().isoformat()
|
|
1712
|
+
if isinstance(rd, date):
|
|
1713
|
+
return rd.isoformat()
|
|
1714
|
+
if isinstance(rd, str) and rd:
|
|
1715
|
+
return rd
|
|
1716
|
+
if self.version and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(self.version)):
|
|
1717
|
+
return str(self.version)
|
|
1718
|
+
return date.today().isoformat()
|
|
1719
|
+
|
|
1720
|
+
def _species_build(self) -> str | None:
|
|
1721
|
+
"""Return the configured genome build for this run's species, if any.
|
|
1722
|
+
|
|
1723
|
+
Reads ``species.available[<species>].build`` from config.
|
|
1724
|
+
``self.species`` may be a canonical taxon ID (single-species runs)
|
|
1725
|
+
or a datasource token (all-species runs), so both the ``available``
|
|
1726
|
+
key and each entry's ``token`` are matched.
|
|
1727
|
+
|
|
1728
|
+
Returns:
|
|
1729
|
+
The per-species build string, or ``None`` when not configured.
|
|
1730
|
+
"""
|
|
1731
|
+
cfg = self._config
|
|
1732
|
+
species = getattr(self, "species", None)
|
|
1733
|
+
if not cfg or species is None:
|
|
1734
|
+
return None
|
|
1735
|
+
available = (cfg.species or {}).get("available") or {}
|
|
1736
|
+
entry = available.get(species) or available.get(str(species))
|
|
1737
|
+
if entry is None:
|
|
1738
|
+
for value in available.values():
|
|
1739
|
+
if isinstance(value, dict) and str(value.get("token")) == str(species):
|
|
1740
|
+
entry = value
|
|
1741
|
+
break
|
|
1742
|
+
if isinstance(entry, dict) and entry.get("build"):
|
|
1743
|
+
return str(entry["build"])
|
|
1744
|
+
return None
|
|
1745
|
+
|
|
1746
|
+
def _genome_build(self) -> str | None:
|
|
1747
|
+
"""Resolve the genome assembly/build for the source-version fields.
|
|
1748
|
+
|
|
1749
|
+
The mapping set's release is already explicit in
|
|
1750
|
+
``mapping_set_version`` / ``mapping_set_id``, so
|
|
1751
|
+
``subject_source_version`` / ``object_source_version`` carry the
|
|
1752
|
+
genome build (e.g. ``"GRCh38"``) rather than repeating the release.
|
|
1753
|
+
Resolution order:
|
|
1754
|
+
|
|
1755
|
+
1. an explicit :attr:`genome_build` override (e.g. a build a parser
|
|
1756
|
+
discovered straight from the data);
|
|
1757
|
+
2. the per-species build from ``species.available[<species>].build``;
|
|
1758
|
+
3. the datasource-wide ``genome_build.default`` -- but *only* for
|
|
1759
|
+
single-build datasources (those with no ``species.available`` map,
|
|
1760
|
+
e.g. a human-only datasource). A multi-species datasource never
|
|
1761
|
+
falls through to ``default``, so it can't stamp one build (say
|
|
1762
|
+
GRCh38) onto a non-human species; an uncurated species is left to
|
|
1763
|
+
fall back to the release instead.
|
|
1764
|
+
|
|
1765
|
+
Returns:
|
|
1766
|
+
The resolved build, or ``None`` when none is configured (e.g.
|
|
1767
|
+
ChEBI), so callers fall back to :attr:`version`.
|
|
1768
|
+
"""
|
|
1769
|
+
if self.genome_build:
|
|
1770
|
+
return self.genome_build
|
|
1771
|
+
species_build = self._species_build()
|
|
1772
|
+
if species_build:
|
|
1773
|
+
return species_build
|
|
1774
|
+
cfg = self._config
|
|
1775
|
+
if not cfg or (cfg.species or {}).get("available"):
|
|
1776
|
+
return None
|
|
1777
|
+
default = (cfg.genome_build or {}).get("default")
|
|
1778
|
+
return str(default) if default else None
|
|
1779
|
+
|
|
1780
|
+
def _source_version(self) -> str | None:
|
|
1781
|
+
"""Return the value for the set-level SSSOM ``*_source_version`` fields.
|
|
1782
|
+
|
|
1783
|
+
The genome build when one is resolvable (see :meth:`_genome_build`),
|
|
1784
|
+
otherwise the release :attr:`version` (unchanged behaviour for
|
|
1785
|
+
datasources without a genome build, e.g. ChEBI).
|
|
1786
|
+
"""
|
|
1787
|
+
return self._genome_build() or self.version
|
|
1788
|
+
|
|
1789
|
+
def create_mapping_set(
|
|
1790
|
+
self,
|
|
1791
|
+
mappings: list[Mapping],
|
|
1792
|
+
mapping_type: str = "id",
|
|
1793
|
+
) -> BaseMappingSet:
|
|
1794
|
+
"""Create a mapping set instance with config metadata.
|
|
1795
|
+
|
|
1796
|
+
Common factory method for creating mapping sets with
|
|
1797
|
+
all SSSOM metadata populated from the YAML config. It also computes
|
|
1798
|
+
cardinalities for mappings.
|
|
1799
|
+
|
|
1800
|
+
Args:
|
|
1801
|
+
mappings: List of SSSOM Mapping objects.
|
|
1802
|
+
mapping_type: "id" for cardinality by ID, "label" for
|
|
1803
|
+
cardinality by label; also selects the mapping-set class
|
|
1804
|
+
via :attr:`mapping_set_classes`.
|
|
1805
|
+
|
|
1806
|
+
Returns:
|
|
1807
|
+
MappingSet with computed cardinalities.
|
|
1808
|
+
"""
|
|
1809
|
+
import curies as _curies
|
|
1810
|
+
|
|
1811
|
+
ms_meta = self.get_mappingset_metadata()
|
|
1812
|
+
curie_map = self.get_curie_map()
|
|
1813
|
+
|
|
1814
|
+
# Build a converter from the curie_map
|
|
1815
|
+
converter = _curies.Converter.from_prefix_map(curie_map)
|
|
1816
|
+
|
|
1817
|
+
def _compress(val: Any) -> Any:
|
|
1818
|
+
"""Compress a URI string or list of URI strings to CURIEs."""
|
|
1819
|
+
if isinstance(val, str):
|
|
1820
|
+
return converter.compress(val) or val
|
|
1821
|
+
if isinstance(val, list):
|
|
1822
|
+
return [converter.compress(v) or v if isinstance(v, str) else v for v in val]
|
|
1823
|
+
return val
|
|
1824
|
+
|
|
1825
|
+
mapping_set_class = self.mapping_set_classes.get(
|
|
1826
|
+
mapping_type, self.mapping_set_classes["id"]
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
# Build description with version if available
|
|
1830
|
+
description = ms_meta.get("mapping_set_description", "")
|
|
1831
|
+
if self.version and description:
|
|
1832
|
+
description = f"{description} Version: {self.version}."
|
|
1833
|
+
|
|
1834
|
+
# Annotate ambiguous mappings (primary also appears as secondary)
|
|
1835
|
+
mappings = _annotate_ambiguous_mappings(mappings, mapping_type=mapping_type)
|
|
1836
|
+
# Source-version carries the genome build, not the release (the
|
|
1837
|
+
# release is already explicit in mapping_set_version/_id).
|
|
1838
|
+
source_version = self._source_version()
|
|
1839
|
+
product_slug = self._product_slug()
|
|
1840
|
+
version_path = f"/{self.version}/{product_slug}" if product_slug else f"/{self.version}"
|
|
1841
|
+
fix_ms_id = str(ms_meta.get("mapping_set_id")) + version_path
|
|
1842
|
+
# Create the mapping set with SSSOM metadata
|
|
1843
|
+
mapping_set = mapping_set_class(
|
|
1844
|
+
mappings=mappings,
|
|
1845
|
+
curie_map=curie_map,
|
|
1846
|
+
mapping_set_id=fix_ms_id,
|
|
1847
|
+
mapping_set_version=self.version,
|
|
1848
|
+
mapping_set_title=ms_meta.get("mapping_set_title"),
|
|
1849
|
+
mapping_set_description=description or None,
|
|
1850
|
+
creator_id=_compress(ms_meta.get("creator_id")),
|
|
1851
|
+
creator_label=ms_meta.get("creator_label"),
|
|
1852
|
+
comment=ms_meta.get("comment"),
|
|
1853
|
+
license=_compress(ms_meta.get("license")),
|
|
1854
|
+
subject_source=ms_meta.get("subject_source"),
|
|
1855
|
+
subject_source_version=source_version,
|
|
1856
|
+
object_source=ms_meta.get("object_source"),
|
|
1857
|
+
object_source_version=source_version,
|
|
1858
|
+
mapping_provider=_compress(ms_meta.get("mapping_provider")),
|
|
1859
|
+
mapping_tool=_compress(ms_meta.get("mapping_tool")),
|
|
1860
|
+
mapping_tool_version=self.mapping_tool_version or None,
|
|
1861
|
+
mapping_date=self._resolve_mapping_date(),
|
|
1862
|
+
see_also=_compress(ms_meta.get("see_also")),
|
|
1863
|
+
issue_tracker=_compress(ms_meta.get("issue_tracker")),
|
|
1864
|
+
subject_preprocessing=_compress(ms_meta.get("subject_preprocessing")),
|
|
1865
|
+
object_preprocessing=_compress(ms_meta.get("object_preprocessing")),
|
|
1866
|
+
)
|
|
1867
|
+
# Annotate ambiguous mappings (primary also appears as secondary)
|
|
1868
|
+
if mapping_type == "label":
|
|
1869
|
+
pri_labels = mapping_set._primary_labels
|
|
1870
|
+
mappings_updated = _annotate_ambiguous_mappings(
|
|
1871
|
+
mappings, mapping_type=mapping_type, primary_labels=pri_labels
|
|
1872
|
+
)
|
|
1873
|
+
else:
|
|
1874
|
+
pri_ids = mapping_set._primary_ids
|
|
1875
|
+
mappings_updated = _annotate_ambiguous_mappings(
|
|
1876
|
+
mappings, mapping_type=mapping_type, primary_ids=pri_ids
|
|
1877
|
+
)
|
|
1878
|
+
mapping_set.mappings = mappings_updated
|
|
1879
|
+
# Compute cardinalities
|
|
1880
|
+
mapping_set._compute_cardinalities(on=mapping_type)
|
|
1881
|
+
|
|
1882
|
+
return mapping_set
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
__all__ = [
|
|
1886
|
+
"WITHDRAWN_ENTRY",
|
|
1887
|
+
"WITHDRAWN_ENTRY_LABEL",
|
|
1888
|
+
"AmbiguousMappingSet",
|
|
1889
|
+
"BaseDownloader",
|
|
1890
|
+
"BaseMappingSet",
|
|
1891
|
+
"BaseParser",
|
|
1892
|
+
"DatasourceConfig",
|
|
1893
|
+
"DistributionEra",
|
|
1894
|
+
"XrefSource",
|
|
1895
|
+
"get_datasource_config",
|
|
1896
|
+
"load_config",
|
|
1897
|
+
"mint_record_id",
|
|
1898
|
+
"pair_hash",
|
|
1899
|
+
]
|