mapkgsutils 0.0.2__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: mapkgsutils
3
+ Version: 0.0.4
4
+ Summary: Utils shared by several mapping set generation tools for biomedical databases
5
+ Keywords: snekpack,cookiecutter
6
+ Author: Javier Millan Acosta
7
+ Author-email: Javier Millan Acosta <javier.millanacosta@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 1 - Planning
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Framework :: Sphinx
16
+ Classifier: Natural Language :: English
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Classifier: Typing :: Typed
25
+ Requires-Dist: more-itertools
26
+ Requires-Dist: tqdm
27
+ Requires-Dist: click
28
+ Requires-Dist: more-click
29
+ Requires-Dist: typing-extensions
30
+ Requires-Dist: pydantic>=2.0
31
+ Requires-Dist: pyyaml
32
+ Requires-Dist: sssom==0.4.21
33
+ Requires-Dist: sssom-schema==1.1.0a5
34
+ Requires-Dist: curies>=0.7.0
35
+ Requires-Dist: polars>=0.20.0
36
+ Requires-Dist: polars-runtime-32
37
+ Requires-Dist: pandas
38
+ Requires-Dist: numpy
39
+ Requires-Dist: httpx>=0.24.0
40
+ Maintainer: Javier Millan Acosta
41
+ Maintainer-email: Javier Millan Acosta <javier.millanacosta@gmail.com>
42
+ Requires-Python: >=3.10
43
+ Project-URL: Bug Tracker, https://github.com//mapkgsutils/issues
44
+ Project-URL: Homepage, https://github.com//mapkgsutils
45
+ Project-URL: Repository, https://github.com//mapkgsutils.git
46
+ Project-URL: Documentation, https://mapkgsutils.readthedocs.io
47
+ Project-URL: Funding, https://github.com/sponsors/jmillanacosta
48
+ Description-Content-Type: text/markdown
49
+
50
+ <!--
51
+ <p align="center">
52
+ <img src="https://github.com//mapkgsutils/raw/main/docs/source/logo.png" height="150">
53
+ </p>
54
+ -->
55
+
56
+ <h1 align="center">
57
+ MaPkgsUtilS
58
+ </h1>
59
+
60
+ <p align="center">
61
+ <a href="https://github.com//mapkgsutils/actions/workflows/tests.yml">
62
+ <img alt="Tests" src="https://github.com//mapkgsutils/actions/workflows/tests.yml/badge.svg" /></a>
63
+ <a href="https://pypi.org/project/mapkgsutils">
64
+ <img alt="PyPI" src="https://img.shields.io/pypi/v/mapkgsutils" /></a>
65
+ <a href="https://pypi.org/project/mapkgsutils">
66
+ <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/mapkgsutils" /></a>
67
+ <a href="https://github.com//mapkgsutils/blob/main/LICENSE">
68
+ <img alt="PyPI - License" src="https://img.shields.io/pypi/l/mapkgsutils" /></a>
69
+ <a href='https://mapkgsutils.readthedocs.io/en/latest/?badge=latest'>
70
+ <img src='https://readthedocs.org/projects/mapkgsutils/badge/?version=latest' alt='Documentation Status' /></a>
71
+ <a href="https://codecov.io/gh//mapkgsutils/branch/main">
72
+ <img src="https://codecov.io/gh//mapkgsutils/branch/main/graph/badge.svg" alt="Codecov status" /></a>
73
+ <a href="https://github.com/cthoyt/cookiecutter-python-package">
74
+ <img alt="Cookiecutter template from @cthoyt" src="https://img.shields.io/badge/Cookiecutter-snekpack-blue" /></a>
75
+ <a href="https://github.com/astral-sh/ruff">
76
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff" style="max-width:100%;"></a>
77
+ <a href="https://github.com//mapkgsutils/blob/main/.github/CODE_OF_CONDUCT.md">
78
+ <img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"/></a>
79
+ <!-- uncomment if you archive on zenodo
80
+ <a href="https://doi.org/10.5281/zenodo.XXXXXX">
81
+ <img src="https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXX.svg" alt="DOI"></a>
82
+ -->
83
+ </p>
84
+
85
+ Utils shared by several mapping set generation tools for biomedical databases.
86
+
87
+ ## 🚀 Installation
88
+
89
+ <!-- Uncomment this section after your first ``just finish``
90
+ The most recent release can be installed from
91
+ [PyPI](https://pypi.org/project/mapkgsutils/) with uv:
92
+
93
+ ```console
94
+ $ uv pip install mapkgsutils
95
+ ```
96
+
97
+ or with pip:
98
+
99
+ ```console
100
+ $ python3 -m pip install mapkgsutils
101
+ ```
102
+ -->
103
+
104
+ The most recent code and data can be installed directly from GitHub with uv:
105
+
106
+ ```console
107
+ $ uv pip install git+https://github.com//mapkgsutils.git
108
+ ```
109
+
110
+ or with pip:
111
+
112
+ ```console
113
+ $ python3 -m pip install git+https://github.com//mapkgsutils.git
114
+ ```
@@ -0,0 +1,65 @@
1
+ <!--
2
+ <p align="center">
3
+ <img src="https://github.com//mapkgsutils/raw/main/docs/source/logo.png" height="150">
4
+ </p>
5
+ -->
6
+
7
+ <h1 align="center">
8
+ MaPkgsUtilS
9
+ </h1>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com//mapkgsutils/actions/workflows/tests.yml">
13
+ <img alt="Tests" src="https://github.com//mapkgsutils/actions/workflows/tests.yml/badge.svg" /></a>
14
+ <a href="https://pypi.org/project/mapkgsutils">
15
+ <img alt="PyPI" src="https://img.shields.io/pypi/v/mapkgsutils" /></a>
16
+ <a href="https://pypi.org/project/mapkgsutils">
17
+ <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/mapkgsutils" /></a>
18
+ <a href="https://github.com//mapkgsutils/blob/main/LICENSE">
19
+ <img alt="PyPI - License" src="https://img.shields.io/pypi/l/mapkgsutils" /></a>
20
+ <a href='https://mapkgsutils.readthedocs.io/en/latest/?badge=latest'>
21
+ <img src='https://readthedocs.org/projects/mapkgsutils/badge/?version=latest' alt='Documentation Status' /></a>
22
+ <a href="https://codecov.io/gh//mapkgsutils/branch/main">
23
+ <img src="https://codecov.io/gh//mapkgsutils/branch/main/graph/badge.svg" alt="Codecov status" /></a>
24
+ <a href="https://github.com/cthoyt/cookiecutter-python-package">
25
+ <img alt="Cookiecutter template from @cthoyt" src="https://img.shields.io/badge/Cookiecutter-snekpack-blue" /></a>
26
+ <a href="https://github.com/astral-sh/ruff">
27
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff" style="max-width:100%;"></a>
28
+ <a href="https://github.com//mapkgsutils/blob/main/.github/CODE_OF_CONDUCT.md">
29
+ <img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"/></a>
30
+ <!-- uncomment if you archive on zenodo
31
+ <a href="https://doi.org/10.5281/zenodo.XXXXXX">
32
+ <img src="https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXX.svg" alt="DOI"></a>
33
+ -->
34
+ </p>
35
+
36
+ Utils shared by several mapping set generation tools for biomedical databases.
37
+
38
+ ## 🚀 Installation
39
+
40
+ <!-- Uncomment this section after your first ``just finish``
41
+ The most recent release can be installed from
42
+ [PyPI](https://pypi.org/project/mapkgsutils/) with uv:
43
+
44
+ ```console
45
+ $ uv pip install mapkgsutils
46
+ ```
47
+
48
+ or with pip:
49
+
50
+ ```console
51
+ $ python3 -m pip install mapkgsutils
52
+ ```
53
+ -->
54
+
55
+ The most recent code and data can be installed directly from GitHub with uv:
56
+
57
+ ```console
58
+ $ uv pip install git+https://github.com//mapkgsutils.git
59
+ ```
60
+
61
+ or with pip:
62
+
63
+ ```console
64
+ $ python3 -m pip install git+https://github.com//mapkgsutils.git
65
+ ```
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "mapkgsutils"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "Utils shared by several mapping set generation tools for biomedical databases"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -236,7 +236,7 @@ known-first-party = [
236
236
  docstring-code-format = true
237
237
 
238
238
  [tool.bumpversion]
239
- current_version = "0.0.2"
239
+ current_version = "0.0.4"
240
240
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(?:-(?P<release>[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+(?P<build>[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
241
241
  serialize = [
242
242
  "{major}.{minor}.{patch}-{release}+{build}",
@@ -65,11 +65,11 @@ class DistributionEraSchema(BaseModel):
65
65
  class DatasourceConfigSchema(BaseModel):
66
66
  """Top-level schema for a ``config/<datasource>.yaml`` file.
67
67
 
68
- ``name``, ``prefix``, and ``curie_base_url`` are required -- they are the
69
- only fields :class:`~mapkgsutils.parsers.base.DatasourceConfig` declares
70
- without a default. Every other recognized key is optional and permissive
71
- in shape; unrecognized top-level keys are not an error here (see
72
- :func:`validate_config_dict`), only a warning.
68
+ ``name``, ``prefix``, and ``curie_base_url`` are the only fields
69
+ :class:`~mapkgsutils.parsers.base.DatasourceConfig` declares without a
70
+ default, so they're required here too. Every other recognized key is
71
+ optional and permissive in shape. Unrecognized top-level keys just get a
72
+ warning (see :func:`validate_config_dict`), not a hard error.
73
73
  """
74
74
 
75
75
  model_config = ConfigDict(extra="allow")
@@ -99,7 +99,6 @@ class DatasourceConfigSchema(BaseModel):
99
99
  distribution_eras: list[DistributionEraSchema] = Field(default_factory=list)
100
100
  xref_sources: list[XrefSourceSchema] = Field(default_factory=list)
101
101
  species: dict[str, Any] = Field(default_factory=dict)
102
- genome_build: dict[str, Any] = Field(default_factory=dict)
103
102
  subset: dict[str, Any] = Field(default_factory=dict)
104
103
  mappingset: dict[str, Any] = Field(default_factory=dict)
105
104
  mapping: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,382 @@
1
+ """Generic mapping-date consolidation: cache I/O and the release/date walk skeleton.
2
+
3
+ A versioned datasource doesn't always record when a mapping first appeared.
4
+ Recovering that date means walking every historical release once and
5
+ recording the first/last release each mapping (keyed by its
6
+ version-independent pair hash) was seen in.
7
+
8
+ This module provides the cache I/O, the release-walk and single-pass-date
9
+ loop shapes, and rebuilding the cache back into a real SSSOM mapping set.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections.abc import Callable, Iterable, Mapping
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from mapkgsutils.logging import logger
21
+ from mapkgsutils.parsers.base import _cmp_versions
22
+
23
+ __all__ = [
24
+ "CACHE_COLUMNS",
25
+ "build_consolidated_mapping_set",
26
+ "consolidate_by_date",
27
+ "consolidate_by_release",
28
+ "load_mapping_dates",
29
+ "read_cache",
30
+ "read_meta",
31
+ "sssom_output_path",
32
+ "write_cache",
33
+ "write_consolidated_sssom",
34
+ "write_meta",
35
+ ]
36
+
37
+ CACHE_COLUMNS = (
38
+ "record_id",
39
+ "first_seen_version",
40
+ "first_seen_date",
41
+ "last_seen_version",
42
+ "last_seen_date",
43
+ # JSON-encoded snapshot of the mapping's own fields (subject_id,
44
+ # object_id, predicate_id, ...) as last seen. Used to rebuild the cache
45
+ # into a real SSSOM mapping set later (see build_consolidated_mapping_set).
46
+ # Empty for legacy/hand-built rows.
47
+ "fields_json",
48
+ )
49
+
50
+
51
+ def sssom_output_path(cache_path: Path) -> Path:
52
+ """Return the companion SSSOM mapping-set path for a consolidated cache file."""
53
+ return cache_path.with_name(cache_path.stem + "_sssom.tsv")
54
+
55
+
56
+ def read_cache(cache_path: Path) -> dict[str, dict[str, str]]:
57
+ """Read a consolidated mapping-date cache TSV into a dict keyed by record_id."""
58
+ if not cache_path.exists():
59
+ return {}
60
+
61
+ import polars as pl
62
+
63
+ df = pl.read_csv(cache_path, separator="\t", schema_overrides={"record_id": pl.Utf8})
64
+ cols = [c for c in CACHE_COLUMNS[1:] if c in df.columns]
65
+ return {
66
+ str(row["record_id"]): {col: str(row[col]) for col in cols}
67
+ for row in df.iter_rows(named=True)
68
+ }
69
+
70
+
71
+ def write_cache(cache_path: Path, records: dict[str, dict[str, str]]) -> None:
72
+ """Write the merged ``record_id -> first/last seen`` dict to a TSV file."""
73
+ import polars as pl
74
+
75
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
76
+ rows = [
77
+ {"record_id": rid, **{c: fields.get(c, "") for c in CACHE_COLUMNS[1:]}}
78
+ for rid, fields in records.items()
79
+ ]
80
+ schema = list(CACHE_COLUMNS)
81
+ df = pl.DataFrame(rows, schema=schema) if rows else pl.DataFrame(schema=schema)
82
+ df.write_csv(cache_path, separator="\t")
83
+
84
+
85
+ def read_meta(meta_path: Path) -> str | None:
86
+ """Read the ``last_version`` sidecar, or ``None`` if absent/unreadable."""
87
+ if not meta_path.exists():
88
+ return None
89
+ try:
90
+ data: dict[str, Any] = json.loads(meta_path.read_text(encoding="utf-8"))
91
+ except (OSError, json.JSONDecodeError):
92
+ return None
93
+ last_version = data.get("last_version")
94
+ return str(last_version) if last_version is not None else None
95
+
96
+
97
+ def write_meta(meta_path: Path, last_version: str) -> None:
98
+ """Write the ``last_version`` sidecar used to resume an interrupted walk."""
99
+ meta_path.parent.mkdir(parents=True, exist_ok=True)
100
+ meta_path.write_text(json.dumps({"last_version": last_version}), encoding="utf-8")
101
+
102
+
103
+ def load_mapping_dates(cache_path: Path) -> dict[str, str]:
104
+ """Load the consolidated ``record_id -> first_seen_date`` index at *cache_path*.
105
+
106
+ Safe to call even when the index hasn't been built yet: returns ``{}``.
107
+
108
+ Args:
109
+ cache_path: Path to the cache TSV (see :func:`write_cache`).
110
+
111
+ Returns:
112
+ Dict mapping each ``record_id`` to its first-seen ISO date string.
113
+ Records walked but never assigned a real release date (e.g. an
114
+ unresolvable ``Last-Modified``) are omitted, leaving their
115
+ ``mapping_date`` unset rather than passing through a non-date value.
116
+ """
117
+ records = read_cache(cache_path)
118
+ return {
119
+ rid: fields["first_seen_date"]
120
+ for rid, fields in records.items()
121
+ if fields.get("first_seen_date")
122
+ }
123
+
124
+
125
+ def _mapping_fields_json(m: Any) -> str:
126
+ """JSON-encode a mapping's own fields (excluding mapping_date/record_id).
127
+
128
+ Snapshots a mapping's current shape (subject_id, object_id,
129
+ predicate_id, ...) so :func:`build_consolidated_mapping_set` can rebuild
130
+ it into a real SSSOM mapping set later. ``default=str`` handles
131
+ enum-valued fields like ``mapping_cardinality``, which round-trip
132
+ cleanly back through ``Mapping(**fields)``. Returns ``"{}"`` for
133
+ non-dataclass stand-ins (e.g. test doubles); callers skip those rows
134
+ rather than materializing them.
135
+ """
136
+ from dataclasses import fields as dataclass_fields
137
+ from dataclasses import is_dataclass
138
+
139
+ if not is_dataclass(m):
140
+ return "{}"
141
+ fields = {
142
+ f.name: getattr(m, f.name)
143
+ for f in dataclass_fields(m)
144
+ if f.name not in ("mapping_date", "record_id") and getattr(m, f.name, None) is not None
145
+ }
146
+ return json.dumps(fields, default=str)
147
+
148
+
149
+ def consolidate_by_date(
150
+ cache_path: Path,
151
+ meta_path: Path,
152
+ *,
153
+ run_one_version: Callable[[], Any],
154
+ ) -> bool:
155
+ """Single-pass "date" mode: capture each row's own real ``mapping_date``.
156
+
157
+ Args:
158
+ cache_path: Path to the cache TSV to write.
159
+ meta_path: Path to the ``last_version`` sidecar to write.
160
+ run_one_version: Zero-arg callable returning a freshly parsed
161
+ mapping set (the current/latest release, already bound to
162
+ whatever datasource/mapping-kind/kwargs the caller needs).
163
+
164
+ Returns:
165
+ ``True`` and writes the cache when the mapping set produced at
166
+ least one real per-row date; ``False`` (cache untouched) when it
167
+ produced none, so the caller can fall back to a release-walk.
168
+ """
169
+ mapping_set = run_one_version()
170
+ dated = [m for m in (mapping_set.mappings or []) if getattr(m, "mapping_date", None)]
171
+ if not dated:
172
+ return False
173
+
174
+ version_label = str(getattr(mapping_set, "mapping_set_version", None) or "current")
175
+ records: dict[str, dict[str, str]] = {}
176
+ for m in dated:
177
+ # record_id is release-scoped; its trailing 16 hex chars are always
178
+ # the version-independent pair hash (see mapkgsutils.parsers.base.pair_hash),
179
+ # which is what must match across releases.
180
+ pair_key = str(getattr(m, "record_id", None) or "")[-16:]
181
+ if not pair_key:
182
+ continue
183
+ date_str = str(m.mapping_date)
184
+ records[pair_key] = {
185
+ "first_seen_version": version_label,
186
+ "first_seen_date": date_str,
187
+ "last_seen_version": version_label,
188
+ "last_seen_date": date_str,
189
+ "fields_json": _mapping_fields_json(m),
190
+ }
191
+ write_cache(cache_path, records)
192
+ write_meta(meta_path, version_label)
193
+ return True
194
+
195
+
196
+ def build_consolidated_mapping_set(
197
+ records: dict[str, dict[str, str]],
198
+ last_version: str | None,
199
+ *,
200
+ mapping_set_class: type[Any],
201
+ record_namespace: str,
202
+ mapping_set_metadata: Mapping[str, Any],
203
+ cardinality_on: str = "id",
204
+ ) -> Any:
205
+ """Materialize the consolidated index as a real SSSOM mapping set.
206
+
207
+ Each row's ``mapping_date`` and ``subject_source_version``/
208
+ ``object_source_version`` are overridden from the record's
209
+ ``first_seen_date``/``first_seen_version``: the date of first
210
+ appearance and the release it first appeared in, rather than whatever
211
+ the last-seen snapshot happened to carry. The two stay in their own
212
+ fields. A release version (e.g. ChEBI's "183") is not a date and never
213
+ goes in ``mapping_date``, and a real date never goes in the version
214
+ fields.
215
+
216
+ Args:
217
+ records: ``record_id -> fields`` dict as read by :func:`read_cache`.
218
+ last_version: The most recent release the walk has processed.
219
+ mapping_set_class: Concrete ``BaseMappingSet`` subclass to build.
220
+ record_namespace: Base IRI namespace prefixed to each rebuilt
221
+ ``record_id``.
222
+ mapping_set_metadata: ``mapping_set_id``/``curie_map``/``license``/...
223
+ metadata for the resulting mapping set.
224
+ cardinality_on: ``"id"`` or ``"label"``, forwarded to
225
+ :meth:`~mapkgsutils.parsers.base.BaseMappingSet._compute_cardinalities`.
226
+
227
+ Returns:
228
+ A ``mapping_set_class`` instance with cardinalities computed.
229
+ """
230
+ from sssom_schema import Mapping as SSSOMMapping
231
+
232
+ version_label = str(last_version) if last_version else "current"
233
+
234
+ mappings = []
235
+ for pair_key, fields in records.items():
236
+ fields_json = fields.get("fields_json") or ""
237
+ if not fields_json:
238
+ continue
239
+ try:
240
+ row_fields = json.loads(fields_json)
241
+ except json.JSONDecodeError:
242
+ continue
243
+ if not row_fields:
244
+ # Non-dataclass stand-ins (see _mapping_fields_json) have
245
+ # nothing to materialize into a real Mapping; date bookkeeping
246
+ # for them still lives in the TSV cache, just not in the SSSOM.
247
+ continue
248
+ # mapping_date and subject/object_source_version are semantically
249
+ # distinct SSSOM fields: a release version (e.g. ChEBI's "183") is
250
+ # never a date, and must never end up in mapping_date or vice versa.
251
+ # Override both from first_seen_*, not whatever the snapshot's own
252
+ # (last-seen) values were.
253
+ row_fields["mapping_date"] = fields.get("first_seen_date") or None
254
+ first_seen_version = fields.get("first_seen_version") or None
255
+ row_fields["subject_source_version"] = first_seen_version
256
+ row_fields["object_source_version"] = first_seen_version
257
+ row_fields["record_id"] = f"{record_namespace}{version_label}/consolidate/{pair_key}"
258
+ mappings.append(SSSOMMapping(**row_fields))
259
+
260
+ base_ms_id = str(mapping_set_metadata.get("mapping_set_id") or "")
261
+ mapping_set = mapping_set_class(
262
+ mappings=mappings,
263
+ curie_map=mapping_set_metadata.get("curie_map") or {},
264
+ mapping_set_id=f"{base_ms_id}/{version_label}/consolidate",
265
+ mapping_set_version=version_label,
266
+ mapping_set_title=mapping_set_metadata.get("mapping_set_title"),
267
+ mapping_set_description=mapping_set_metadata.get("mapping_set_description"),
268
+ license=mapping_set_metadata.get("license"),
269
+ )
270
+ mapping_set._compute_cardinalities(on=cardinality_on)
271
+ return mapping_set
272
+
273
+
274
+ def write_consolidated_sssom(
275
+ cache_path: Path,
276
+ meta_path: Path,
277
+ *,
278
+ mapping_set_class: type[Any],
279
+ record_namespace: str,
280
+ mapping_set_metadata: Mapping[str, Any],
281
+ cardinality_on: str = "id",
282
+ ) -> Path:
283
+ """Build and save the companion SSSOM mapping set next to the cache file.
284
+
285
+ Args:
286
+ cache_path: Path to the cache TSV (see :func:`read_cache`).
287
+ meta_path: Path to the ``last_version`` sidecar (see :func:`read_meta`).
288
+ mapping_set_class: Concrete ``BaseMappingSet`` subclass to build.
289
+ record_namespace: Base IRI namespace for rebuilt ``record_id`` values.
290
+ mapping_set_metadata: ``mapping_set_id``/``curie_map``/``license``/...
291
+ metadata for the resulting mapping set.
292
+ cardinality_on: ``"id"`` or ``"label"``, see
293
+ :func:`build_consolidated_mapping_set`.
294
+
295
+ Returns:
296
+ Path to the written SSSOM TSV (see :func:`sssom_output_path`).
297
+ """
298
+ records = read_cache(cache_path)
299
+ last_version = read_meta(meta_path)
300
+ mapping_set = build_consolidated_mapping_set(
301
+ records,
302
+ last_version,
303
+ mapping_set_class=mapping_set_class,
304
+ record_namespace=record_namespace,
305
+ mapping_set_metadata=mapping_set_metadata,
306
+ cardinality_on=cardinality_on,
307
+ )
308
+ output_path = sssom_output_path(cache_path)
309
+ mapping_set.save("sssom", output_path)
310
+ return output_path
311
+
312
+
313
+ def consolidate_by_release(
314
+ cache_path: Path,
315
+ meta_path: Path,
316
+ *,
317
+ label: str,
318
+ list_versions: Callable[[], list[str]],
319
+ run_one_version: Callable[[str], Any],
320
+ resolve_release_date: Callable[[str], datetime | None],
321
+ show_progress: bool = True,
322
+ force: bool = False,
323
+ ) -> None:
324
+ """Historical-walk "release" mode: track first/last-seen release per mapping.
325
+
326
+ Args:
327
+ cache_path: Path to the cache TSV to read/write.
328
+ meta_path: Path to the ``last_version`` sidecar to read/write.
329
+ label: Datasource name, used only for the progress bar/log messages.
330
+ list_versions: Zero-arg callable returning every available version,
331
+ oldest first.
332
+ run_one_version: Callable taking a version string and returning a
333
+ freshly downloaded+parsed mapping set for it.
334
+ resolve_release_date: Callable taking a version string and
335
+ returning its upstream release date, or ``None`` when
336
+ unresolvable.
337
+ show_progress: Whether to show a progress bar over releases.
338
+ force: Re-scan every release from scratch, ignoring any existing
339
+ cache/resume state.
340
+ """
341
+ records: dict[str, dict[str, str]] = {} if force else read_cache(cache_path)
342
+ last_version = None if force else read_meta(meta_path)
343
+
344
+ versions = list_versions()
345
+ if last_version is not None:
346
+ versions = [v for v in versions if _cmp_versions(v, last_version) > 0]
347
+
348
+ iterator: Iterable[str] = versions
349
+ if show_progress:
350
+ from tqdm import tqdm
351
+
352
+ iterator = tqdm(versions, desc=f"Consolidating {label.upper()} mapping dates")
353
+
354
+ for v in iterator:
355
+ try:
356
+ mapping_set = run_one_version(v)
357
+ release_date = resolve_release_date(v)
358
+ # Empty when no real release date resolves. v is a version, not
359
+ # a date, and isn't always date-shaped (e.g. ChEBI's plain
360
+ # release numbers), so don't store it as one.
361
+ date_str = release_date.date().isoformat() if release_date else ""
362
+
363
+ for m in mapping_set.mappings or []:
364
+ # record_id is release-scoped; match across releases on its
365
+ # trailing pair hash instead (see mapkgsutils.parsers.base.pair_hash).
366
+ pair_key = str(getattr(m, "record_id", None) or "")[-16:]
367
+ if not pair_key:
368
+ continue
369
+ entry = records.setdefault(
370
+ pair_key,
371
+ {"first_seen_version": v, "first_seen_date": date_str},
372
+ )
373
+ entry["last_seen_version"] = v
374
+ entry["last_seen_date"] = date_str
375
+ entry["fields_json"] = _mapping_fields_json(m)
376
+ except Exception:
377
+ logger.warning("Skipping %s version %s during consolidation", label, v, exc_info=True)
378
+ continue
379
+
380
+ last_version = v
381
+ write_cache(cache_path, records)
382
+ write_meta(meta_path, last_version)
@@ -1,18 +1,13 @@
1
- """Disambiguate ambiguous secondary with context: label, id, and xref evidence.
1
+ """Disambiguate ambiguous mappings with context: label, id, and xref evidence.
2
2
 
3
3
  :class:`ContextSpec` describes a per-row piece of evidence that can
4
4
  be used to decide which of those two entities a given ambiguous cell actually
5
5
  means:
6
6
 
7
- * ``label``: an alias/synonym string.
7
+ * ``label``: a label string.
8
8
  * ``id`` : a related/foreign identifier string.
9
9
  * ``xref`` : a cross-reference token (e.g. an Ensembl ID) resolved through
10
10
  an independent :class:`XrefMapping` crosswalk table.
11
-
12
- All three are resolved the same way: confirm *secondary usage* (the evidence
13
- points to the mapping's target), confirm *primary usage* (the evidence points
14
- to the token's own current identity), or leave the cell unresolved. Every
15
- attempt can be recorded as a :class:`DecisionRecord` for an auditable TSV log.
16
11
  """
17
12
 
18
13
  from __future__ import annotations
@@ -35,8 +30,8 @@ class XrefRecord:
35
30
 
36
31
  Args:
37
32
  subject_id: The cross-reference token, e.g. ``"ENSG00000197471"``.
38
- object_id: The target primary id, e.g. ``"HGNC:11249"``.
39
- object_label: The target primary label, e.g. ``"SPN"``.
33
+ object_id: The target id, e.g. ``"HGNC:11249"``.
34
+ object_label: The target label, e.g. ``"SPN"``.
40
35
  predicate_id: The equivalence predicate, e.g. ``"skos:exactMatch"``.
41
36
  ``None`` when the source table carries no predicate column.
42
37
  """
@@ -93,8 +88,8 @@ def load_xref_mapping(
93
88
  Args:
94
89
  path: Path to the crosswalk file.
95
90
  subject_col: Column with the cross-reference token.
96
- object_col: Column with the target primary id.
97
- object_label_col: Column with the target primary label (optional).
91
+ object_col: Column with the target id.
92
+ object_label_col: Column with the target label (optional).
98
93
  predicate_col: Column with the equivalence predicate (optional).
99
94
  sep: Field delimiter.
100
95
 
@@ -257,13 +252,12 @@ def resolve_ambiguous_with_xref(
257
252
  """Attempt to resolve an ambiguous token using a cross-reference token.
258
253
 
259
254
  Args:
260
- token: The ambiguous label or ID (appears both as a current primary
261
- entry and as a secondary one pointing elsewhere).
255
+ token: The ambiguous label or ID.
262
256
  xref_token: The row's cross-reference token, e.g. an Ensembl ID.
263
- lkp: ``{secondary_token: resolved_token}`` lookup.
257
+ lkp: ``{token: resolved_token}`` lookup.
264
258
  xref_index: ``{xref_token: [XrefRecord, ...]}`` (see
265
259
  :meth:`XrefMapping.by_subject`).
266
- token_to_id: ``{primary_token: primary_id}``. ``None`` when *token*
260
+ token_to_id: ``{token: id}``. ``None`` when *token*
267
261
  is already a CURIE (ID mode).
268
262
  accepted_predicates: Equivalence predicates to accept. ``None``
269
263
  accepts any predicate.