mapkgsutils 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapkgsutils
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Utils shared by several mapping set generation tools for biomedical databases
5
5
  Keywords: snekpack,cookiecutter
6
6
  Author: Javier Millan Acosta
@@ -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.3"
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.3"
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}",
@@ -0,0 +1,368 @@
1
+ """Generic mapping-date consolidation: cache I/O and the release/date walk skeleton.
2
+
3
+ A versioned datasource's per-row deprecation/change date isn't always
4
+ recorded anywhere in a single release -- recovering it means walking every
5
+ historical release once and recording the first/last release each mapping
6
+ (keyed by its version-independent pair hash) was seen in. This module is the
7
+ datasource-agnostic half of that: cache I/O, the release-walk and
8
+ single-pass-date loop shapes, and materializing the cache back into a real
9
+ SSSOM mapping set. Per-datasource dispatch (which versions exist, how to
10
+ download/parse one, which mapping-set class to build) is supplied by the
11
+ caller as plain callables/values -- see :mod:`pysec2pri.consolidate` for a
12
+ concrete binding.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Callable, Iterable, Mapping
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from mapkgsutils.logging import logger
24
+ from mapkgsutils.parsers.base import _cmp_versions
25
+
26
+ __all__ = [
27
+ "CACHE_COLUMNS",
28
+ "build_consolidated_mapping_set",
29
+ "consolidate_by_date",
30
+ "consolidate_by_release",
31
+ "load_mapping_dates",
32
+ "read_cache",
33
+ "read_meta",
34
+ "sssom_output_path",
35
+ "write_cache",
36
+ "write_consolidated_sssom",
37
+ "write_meta",
38
+ ]
39
+
40
+ CACHE_COLUMNS = (
41
+ "record_id",
42
+ "first_seen_version",
43
+ "first_seen_date",
44
+ "last_seen_version",
45
+ "last_seen_date",
46
+ # JSON-encoded snapshot of the mapping's own fields (subject_id,
47
+ # object_id, predicate_id, ...) as last seen -- used to materialize the
48
+ # consolidated index as a real SSSOM mapping set (see
49
+ # build_consolidated_mapping_set). Empty for legacy/hand-built rows.
50
+ "fields_json",
51
+ )
52
+
53
+
54
+ def sssom_output_path(cache_path: Path) -> Path:
55
+ """Return the companion SSSOM mapping-set path for a consolidated cache file."""
56
+ return cache_path.with_name(cache_path.stem + "_sssom.tsv")
57
+
58
+
59
+ def read_cache(cache_path: Path) -> dict[str, dict[str, str]]:
60
+ """Read a consolidated mapping-date cache TSV into a dict keyed by record_id."""
61
+ if not cache_path.exists():
62
+ return {}
63
+
64
+ import polars as pl
65
+
66
+ df = pl.read_csv(cache_path, separator="\t", schema_overrides={"record_id": pl.Utf8})
67
+ cols = [c for c in CACHE_COLUMNS[1:] if c in df.columns]
68
+ return {
69
+ str(row["record_id"]): {col: str(row[col]) for col in cols}
70
+ for row in df.iter_rows(named=True)
71
+ }
72
+
73
+
74
+ def write_cache(cache_path: Path, records: dict[str, dict[str, str]]) -> None:
75
+ """Write the merged ``record_id -> first/last seen`` dict to a TSV file."""
76
+ import polars as pl
77
+
78
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
79
+ rows = [
80
+ {"record_id": rid, **{c: fields.get(c, "") for c in CACHE_COLUMNS[1:]}}
81
+ for rid, fields in records.items()
82
+ ]
83
+ schema = list(CACHE_COLUMNS)
84
+ df = pl.DataFrame(rows, schema=schema) if rows else pl.DataFrame(schema=schema)
85
+ df.write_csv(cache_path, separator="\t")
86
+
87
+
88
+ def read_meta(meta_path: Path) -> str | None:
89
+ """Read the ``last_version`` sidecar, or ``None`` if absent/unreadable."""
90
+ if not meta_path.exists():
91
+ return None
92
+ try:
93
+ data: dict[str, Any] = json.loads(meta_path.read_text(encoding="utf-8"))
94
+ except (OSError, json.JSONDecodeError):
95
+ return None
96
+ last_version = data.get("last_version")
97
+ return str(last_version) if last_version is not None else None
98
+
99
+
100
+ def write_meta(meta_path: Path, last_version: str) -> None:
101
+ """Write the ``last_version`` sidecar used to resume an interrupted walk."""
102
+ meta_path.parent.mkdir(parents=True, exist_ok=True)
103
+ meta_path.write_text(json.dumps({"last_version": last_version}), encoding="utf-8")
104
+
105
+
106
+ def load_mapping_dates(cache_path: Path) -> dict[str, str]:
107
+ """Load the consolidated ``record_id -> first_seen_date`` index at *cache_path*.
108
+
109
+ Safe to call even when the index hasn't been built yet: returns ``{}``.
110
+
111
+ Args:
112
+ cache_path: Path to the cache TSV (see :func:`write_cache`).
113
+
114
+ Returns:
115
+ Dict mapping each ``record_id`` to its first-seen ISO date string.
116
+ Records walked but never assigned a real release date (e.g. an
117
+ unresolvable ``Last-Modified``) are omitted, leaving their
118
+ ``mapping_date`` unset rather than passing through a non-date value.
119
+ """
120
+ records = read_cache(cache_path)
121
+ return {
122
+ rid: fields["first_seen_date"]
123
+ for rid, fields in records.items()
124
+ if fields.get("first_seen_date")
125
+ }
126
+
127
+
128
+ def _mapping_fields_json(m: Any) -> str:
129
+ """JSON-encode a mapping's own fields (excluding mapping_date/record_id).
130
+
131
+ Snapshots a mapping's current shape (subject_id, object_id,
132
+ predicate_id, ...) so the consolidated index can later be materialized
133
+ as a real SSSOM mapping set (see :func:`build_consolidated_mapping_set`).
134
+ ``default=str`` handles enum-valued fields like ``mapping_cardinality``,
135
+ which round-trip cleanly back through ``Mapping(**fields)``. Returns
136
+ ``"{}"`` for non-dataclass stand-ins (e.g. test doubles) that don't carry
137
+ a full field set -- callers skip materializing those rows.
138
+ """
139
+ from dataclasses import fields as dataclass_fields
140
+ from dataclasses import is_dataclass
141
+
142
+ if not is_dataclass(m):
143
+ return "{}"
144
+ fields = {
145
+ f.name: getattr(m, f.name)
146
+ for f in dataclass_fields(m)
147
+ if f.name not in ("mapping_date", "record_id") and getattr(m, f.name, None) is not None
148
+ }
149
+ return json.dumps(fields, default=str)
150
+
151
+
152
+ def consolidate_by_date(
153
+ cache_path: Path,
154
+ meta_path: Path,
155
+ *,
156
+ run_one_version: Callable[[], Any],
157
+ ) -> bool:
158
+ """Single-pass "date" mode: capture each row's own real ``mapping_date``.
159
+
160
+ Args:
161
+ cache_path: Path to the cache TSV to write.
162
+ meta_path: Path to the ``last_version`` sidecar to write.
163
+ run_one_version: Zero-arg callable returning a freshly parsed
164
+ mapping set (the current/latest release, already bound to
165
+ whatever datasource/mapping-kind/kwargs the caller needs).
166
+
167
+ Returns:
168
+ ``True`` and writes the cache when the mapping set produced at
169
+ least one real per-row date; ``False`` (cache untouched) when it
170
+ produced none, so the caller can fall back to a release-walk.
171
+ """
172
+ mapping_set = run_one_version()
173
+ dated = [m for m in (mapping_set.mappings or []) if getattr(m, "mapping_date", None)]
174
+ if not dated:
175
+ return False
176
+
177
+ version_label = str(getattr(mapping_set, "mapping_set_version", None) or "current")
178
+ records: dict[str, dict[str, str]] = {}
179
+ for m in dated:
180
+ # record_id is release-scoped; its trailing 16 hex chars are always
181
+ # the version-independent pair hash (see mapkgsutils.parsers.base.pair_hash),
182
+ # which is what must match across releases.
183
+ pair_key = str(getattr(m, "record_id", None) or "")[-16:]
184
+ if not pair_key:
185
+ continue
186
+ date_str = str(m.mapping_date)
187
+ records[pair_key] = {
188
+ "first_seen_version": version_label,
189
+ "first_seen_date": date_str,
190
+ "last_seen_version": version_label,
191
+ "last_seen_date": date_str,
192
+ "fields_json": _mapping_fields_json(m),
193
+ }
194
+ write_cache(cache_path, records)
195
+ write_meta(meta_path, version_label)
196
+ return True
197
+
198
+
199
+ def build_consolidated_mapping_set(
200
+ records: dict[str, dict[str, str]],
201
+ last_version: str | None,
202
+ *,
203
+ mapping_set_class: type[Any],
204
+ record_namespace: str,
205
+ mapping_set_metadata: Mapping[str, Any],
206
+ ) -> Any:
207
+ """Materialize the consolidated index as a real SSSOM mapping set.
208
+
209
+ Each record's stored field snapshot (from the most recent release where
210
+ it was seen) is rebuilt into a ``Mapping``, with ``mapping_date``
211
+ overridden to its ``first_seen_date`` -- the date of appearance, rather
212
+ than whatever date the snapshot's own release happened to carry -- and
213
+ ``record_id`` re-scoped with a trailing ``consolidate`` segment, marking
214
+ the IRI as a derived, cross-release product.
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
+
225
+ Returns:
226
+ A ``mapping_set_class`` instance with cardinalities computed.
227
+ """
228
+ from sssom_schema import Mapping as SSSOMMapping
229
+
230
+ version_label = str(last_version) if last_version else "current"
231
+
232
+ mappings = []
233
+ for pair_key, fields in records.items():
234
+ fields_json = fields.get("fields_json") or ""
235
+ if not fields_json:
236
+ continue
237
+ try:
238
+ row_fields = json.loads(fields_json)
239
+ except json.JSONDecodeError:
240
+ continue
241
+ if not row_fields:
242
+ # Non-dataclass stand-ins (see _mapping_fields_json) have
243
+ # nothing to materialize into a real Mapping; date bookkeeping
244
+ # for them still lives in the TSV cache, just not in the SSSOM.
245
+ continue
246
+ row_fields["mapping_date"] = fields.get("first_seen_date") or None
247
+ row_fields["record_id"] = f"{record_namespace}{version_label}/consolidate/{pair_key}"
248
+ mappings.append(SSSOMMapping(**row_fields))
249
+
250
+ base_ms_id = str(mapping_set_metadata.get("mapping_set_id") or "")
251
+ mapping_set = mapping_set_class(
252
+ mappings=mappings,
253
+ curie_map=mapping_set_metadata.get("curie_map") or {},
254
+ mapping_set_id=f"{base_ms_id}/{version_label}/consolidate",
255
+ mapping_set_version=version_label,
256
+ mapping_set_title=mapping_set_metadata.get("mapping_set_title"),
257
+ mapping_set_description=mapping_set_metadata.get("mapping_set_description"),
258
+ license=mapping_set_metadata.get("license"),
259
+ )
260
+ mapping_set.compute_cardinalities()
261
+ return mapping_set
262
+
263
+
264
+ def write_consolidated_sssom(
265
+ cache_path: Path,
266
+ meta_path: Path,
267
+ *,
268
+ mapping_set_class: type[Any],
269
+ record_namespace: str,
270
+ mapping_set_metadata: Mapping[str, Any],
271
+ ) -> Path:
272
+ """Build and save the companion SSSOM mapping set next to the cache file.
273
+
274
+ Args:
275
+ cache_path: Path to the cache TSV (see :func:`read_cache`).
276
+ meta_path: Path to the ``last_version`` sidecar (see :func:`read_meta`).
277
+ mapping_set_class: Concrete ``BaseMappingSet`` subclass to build.
278
+ record_namespace: Base IRI namespace for rebuilt ``record_id`` values.
279
+ mapping_set_metadata: ``mapping_set_id``/``curie_map``/``license``/...
280
+ metadata for the resulting mapping set.
281
+
282
+ Returns:
283
+ Path to the written SSSOM TSV (see :func:`sssom_output_path`).
284
+ """
285
+ records = read_cache(cache_path)
286
+ last_version = read_meta(meta_path)
287
+ mapping_set = build_consolidated_mapping_set(
288
+ records,
289
+ last_version,
290
+ mapping_set_class=mapping_set_class,
291
+ record_namespace=record_namespace,
292
+ mapping_set_metadata=mapping_set_metadata,
293
+ )
294
+ output_path = sssom_output_path(cache_path)
295
+ mapping_set.save("sssom", output_path)
296
+ return output_path
297
+
298
+
299
+ def consolidate_by_release(
300
+ cache_path: Path,
301
+ meta_path: Path,
302
+ *,
303
+ label: str,
304
+ list_versions: Callable[[], list[str]],
305
+ run_one_version: Callable[[str], Any],
306
+ resolve_release_date: Callable[[str], datetime | None],
307
+ show_progress: bool = True,
308
+ force: bool = False,
309
+ ) -> None:
310
+ """Historical-walk "release" mode: track first/last-seen release per mapping.
311
+
312
+ Args:
313
+ cache_path: Path to the cache TSV to read/write.
314
+ meta_path: Path to the ``last_version`` sidecar to read/write.
315
+ label: Datasource name, used only for the progress bar/log messages.
316
+ list_versions: Zero-arg callable returning every available version,
317
+ oldest first.
318
+ run_one_version: Callable taking a version string and returning a
319
+ freshly downloaded+parsed mapping set for it.
320
+ resolve_release_date: Callable taking a version string and
321
+ returning its upstream release date, or ``None`` when
322
+ unresolvable.
323
+ show_progress: Whether to show a progress bar over releases.
324
+ force: Re-scan every release from scratch, ignoring any existing
325
+ cache/resume state.
326
+ """
327
+ records: dict[str, dict[str, str]] = {} if force else read_cache(cache_path)
328
+ last_version = None if force else read_meta(meta_path)
329
+
330
+ versions = list_versions()
331
+ if last_version is not None:
332
+ versions = [v for v in versions if _cmp_versions(v, last_version) > 0]
333
+
334
+ iterator: Iterable[str] = versions
335
+ if show_progress:
336
+ from tqdm import tqdm
337
+
338
+ iterator = tqdm(versions, desc=f"Consolidating {label.upper()} mapping dates")
339
+
340
+ for v in iterator:
341
+ try:
342
+ mapping_set = run_one_version(v)
343
+ release_date = resolve_release_date(v)
344
+ # Empty (not the version label v) when no real release date is
345
+ # resolvable -- v is a version, not a date, and isn't always
346
+ # date-shaped (e.g. ChEBI's plain release numbers).
347
+ date_str = release_date.date().isoformat() if release_date else ""
348
+
349
+ for m in mapping_set.mappings or []:
350
+ # record_id is release-scoped; match across releases on its
351
+ # trailing pair hash instead (see mapkgsutils.parsers.base.pair_hash).
352
+ pair_key = str(getattr(m, "record_id", None) or "")[-16:]
353
+ if not pair_key:
354
+ continue
355
+ entry = records.setdefault(
356
+ pair_key,
357
+ {"first_seen_version": v, "first_seen_date": date_str},
358
+ )
359
+ entry["last_seen_version"] = v
360
+ entry["last_seen_date"] = date_str
361
+ entry["fields_json"] = _mapping_fields_json(m)
362
+ except Exception:
363
+ logger.warning("Skipping %s version %s during consolidation", label, v, exc_info=True)
364
+ continue
365
+
366
+ last_version = v
367
+ write_cache(cache_path, records)
368
+ write_meta(meta_path, last_version)
@@ -12,7 +12,7 @@ __all__ = [
12
12
  "get_version",
13
13
  ]
14
14
 
15
- VERSION = "0.0.2"
15
+ VERSION = "0.0.3"
16
16
 
17
17
 
18
18
  def get_git_hash() -> str:
File without changes
File without changes