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.
@@ -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
+ ]