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 @@
1
+ """Utils shared by several mapping set generation tools for biomedical databases."""
@@ -0,0 +1 @@
1
+ """Datasource configuration loading and validation."""
@@ -0,0 +1,170 @@
1
+ """Schema validation for ``config/<datasource>.yaml`` files.
2
+
3
+ Validates every key used in :class:`~mapkgsutils.parsers.base.DatasourceConfig`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+
16
+ class ConfigValidationError(ValueError):
17
+ """Raised when a datasource config YAML fails schema validation."""
18
+
19
+ def __init__(self, file_name: str, message: str) -> None:
20
+ """Store *file_name* so callers can report which config failed."""
21
+ self.file_name = file_name
22
+ super().__init__(f"{file_name}: {message}")
23
+
24
+
25
+ class XrefSourceSchema(BaseModel):
26
+ """Schema for one entry in ``xref_sources``."""
27
+
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+ id: str
31
+ name: str = ""
32
+ url: str = ""
33
+ format: str = "tsv"
34
+ object_id_col: str = "object_id"
35
+ object_label_col: str = "object_label"
36
+ subject_id_cols: dict[str, str] = Field(default_factory=dict)
37
+ note: str = ""
38
+
39
+
40
+ class MappingSetEntrySchema(BaseModel):
41
+ """Schema for one entry (e.g. ``ids``/``labels``) in ``mapping_sets``."""
42
+
43
+ model_config = ConfigDict(extra="allow")
44
+
45
+ method: str | None = None
46
+ primary_input: str | None = None
47
+ required_inputs: list[str] = Field(default_factory=list)
48
+ formats: list[str] = Field(default_factory=list)
49
+
50
+
51
+ class DistributionEraSchema(BaseModel):
52
+ """Schema for one entry in ``distribution_eras``."""
53
+
54
+ model_config = ConfigDict(extra="allow")
55
+
56
+ id: str = ""
57
+ download_urls: dict[str, str] = Field(default_factory=dict)
58
+ archive_url: str = ""
59
+ format: str | None = None
60
+ from_version: str | None = None
61
+ to_version: str | None = None
62
+ wayback: bool = False
63
+
64
+
65
+ class DatasourceConfigSchema(BaseModel):
66
+ """Top-level schema for a ``config/<datasource>.yaml`` file.
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.
73
+ """
74
+
75
+ model_config = ConfigDict(extra="allow")
76
+
77
+ name: str
78
+ prefix: str
79
+ curie_base_url: str
80
+ config_id: str = ""
81
+ datasource_id: str = ""
82
+ parser_class: str = ""
83
+ entity_types: list[str] = Field(default_factory=list)
84
+ parse_options: dict[str, Any] = Field(default_factory=dict)
85
+ mapping_sets: dict[str, MappingSetEntrySchema] = Field(default_factory=dict)
86
+ available_outputs: list[str] = Field(default_factory=list)
87
+ default_output_filename: str = ""
88
+ download_urls: dict[str, Any] = Field(default_factory=dict)
89
+ primary_file_key: str = ""
90
+ id_pattern: str = ""
91
+ archive_url: str | None = ""
92
+ input_file_types: list[str] = Field(default_factory=list)
93
+ source: str = ""
94
+ homepage: str = ""
95
+ data_license: str = ""
96
+ sparql_endpoint: str = ""
97
+ queries: dict[str, str] = Field(default_factory=dict)
98
+ new_format_version: int | None = None
99
+ distribution_eras: list[DistributionEraSchema] = Field(default_factory=list)
100
+ xref_sources: list[XrefSourceSchema] = Field(default_factory=list)
101
+ species: dict[str, Any] = Field(default_factory=dict)
102
+ genome_build: dict[str, Any] = Field(default_factory=dict)
103
+ subset: dict[str, Any] = Field(default_factory=dict)
104
+ mappingset: dict[str, Any] = Field(default_factory=dict)
105
+ mapping: dict[str, Any] = Field(default_factory=dict)
106
+
107
+
108
+ _KNOWN_TOP_LEVEL_KEYS = set(DatasourceConfigSchema.model_fields)
109
+
110
+
111
+ def validate_config_dict(raw: dict[str, Any], file_name: str) -> DatasourceConfigSchema:
112
+ """Validate a loaded config dict against :class:`DatasourceConfigSchema`.
113
+
114
+ Args:
115
+ raw: The dict loaded from a ``config/<datasource>.yaml`` file.
116
+ file_name: Name to attribute errors/warnings to (e.g. ``"hgnc.yaml"``).
117
+
118
+ Returns:
119
+ The validated, parsed model.
120
+
121
+ Raises:
122
+ ConfigValidationError: When a required field is missing or a known
123
+ field has the wrong shape.
124
+ """
125
+ from mapkgsutils.logging import logger
126
+
127
+ unknown = set(raw) - _KNOWN_TOP_LEVEL_KEYS
128
+ if unknown:
129
+ logger.warning(
130
+ "%s: unrecognized top-level key(s) %s (not validated)",
131
+ file_name,
132
+ ", ".join(sorted(unknown)),
133
+ )
134
+ try:
135
+ return DatasourceConfigSchema.model_validate(raw)
136
+ except ValidationError as exc:
137
+ raise ConfigValidationError(file_name, str(exc)) from exc
138
+
139
+
140
+ def validate_config_file(path: Path | str) -> DatasourceConfigSchema:
141
+ """Load and validate a single config YAML file.
142
+
143
+ Args:
144
+ path: Path to the YAML file.
145
+
146
+ Returns:
147
+ The validated, parsed model.
148
+
149
+ Raises:
150
+ ConfigValidationError: When the file fails schema validation.
151
+ """
152
+ from pathlib import Path as _Path
153
+
154
+ import yaml
155
+
156
+ path = _Path(path)
157
+ with path.open("r", encoding="utf-8") as f:
158
+ raw = yaml.safe_load(f) or {}
159
+ return validate_config_dict(raw, path.name)
160
+
161
+
162
+ __all__ = [
163
+ "ConfigValidationError",
164
+ "DatasourceConfigSchema",
165
+ "DistributionEraSchema",
166
+ "MappingSetEntrySchema",
167
+ "XrefSourceSchema",
168
+ "validate_config_dict",
169
+ "validate_config_file",
170
+ ]
mapkgsutils/context.py ADDED
@@ -0,0 +1,358 @@
1
+ """Disambiguate ambiguous secondary with context: label, id, and xref evidence.
2
+
3
+ :class:`ContextSpec` describes a per-row piece of evidence that can
4
+ be used to decide which of those two entities a given ambiguous cell actually
5
+ means:
6
+
7
+ * ``label``: an alias/synonym string.
8
+ * ``id`` : a related/foreign identifier string.
9
+ * ``xref`` : a cross-reference token (e.g. an Ensembl ID) resolved through
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
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Literal
23
+
24
+ if TYPE_CHECKING:
25
+ from mapkgsutils.parsers.base import XrefSource
26
+
27
+ #: Default for :func:`resolve_ambiguous_with_xref`'s ``trust_unannotated``:
28
+ #: an xref record with no ``predicate_id`` is accepted as an equivalence.
29
+ DEFAULT_TRUST_UNANNOTATED = True
30
+
31
+
32
+ @dataclass
33
+ class XrefRecord:
34
+ """One row of a cross-reference crosswalk table.
35
+
36
+ Args:
37
+ 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"``.
40
+ predicate_id: The equivalence predicate, e.g. ``"skos:exactMatch"``.
41
+ ``None`` when the source table carries no predicate column.
42
+ """
43
+
44
+ subject_id: str
45
+ object_id: str
46
+ object_label: str | None = None
47
+ predicate_id: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class XrefMapping:
52
+ """A loaded crosswalk table, indexable by subject (cross-reference) token."""
53
+
54
+ records: list[XrefRecord]
55
+ _index: dict[str, list[XrefRecord]] | None = field(
56
+ default=None, init=False, repr=False, compare=False
57
+ )
58
+
59
+ def by_subject(self) -> dict[str, list[XrefRecord]]:
60
+ """Return a ``{subject_id: [records]}`` index, built once and cached."""
61
+ if self._index is None:
62
+ index: dict[str, list[XrefRecord]] = {}
63
+ for record in self.records:
64
+ index.setdefault(record.subject_id, []).append(record)
65
+ self._index = index
66
+ return self._index
67
+
68
+
69
+ def _clean(value: object) -> str | None:
70
+ """Return *value* as a stripped string, or ``None`` for empty/NaN cells."""
71
+ if value is None:
72
+ return None
73
+ if isinstance(value, float) and value != value:
74
+ return None
75
+ text = str(value).strip()
76
+ return text or None
77
+
78
+
79
+ def load_xref_mapping(
80
+ path: Path | str,
81
+ *,
82
+ subject_col: str = "subject_id",
83
+ object_col: str = "object_id",
84
+ object_label_col: str = "object_label",
85
+ predicate_col: str = "predicate_id",
86
+ sep: str | None = None,
87
+ ) -> XrefMapping:
88
+ r"""Load a crosswalk table as an :class:`XrefMapping`.
89
+
90
+ Reads either a real SSSOM TSV (a ``#``-prefixed metadata header is
91
+ skipped automatically) or a plain subject/object table.
92
+
93
+ Args:
94
+ path: Path to the crosswalk file.
95
+ 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).
98
+ predicate_col: Column with the equivalence predicate (optional).
99
+ sep: Field delimiter.
100
+
101
+ Returns:
102
+ An :class:`XrefMapping` with one :class:`XrefRecord` per non-empty
103
+ subject row.
104
+ """
105
+ import pandas as pd
106
+
107
+ path = Path(path)
108
+ if sep is None:
109
+ sep = "\t" if path.suffix.lower() == ".tsv" else ","
110
+ df = pd.read_csv(path, sep=sep, dtype=str, comment="#")
111
+
112
+ records: list[XrefRecord] = []
113
+ for _, row in df.iterrows():
114
+ subject_id = _clean(row.get(subject_col))
115
+ if not subject_id:
116
+ continue
117
+ records.append(
118
+ XrefRecord(
119
+ subject_id=subject_id,
120
+ object_id=_clean(row.get(object_col)) or "",
121
+ object_label=_clean(row.get(object_label_col)),
122
+ predicate_id=_clean(row.get(predicate_col)),
123
+ )
124
+ )
125
+ return XrefMapping(records=records)
126
+
127
+
128
+ def download_xref_source(
129
+ src: XrefSource,
130
+ subject_col: str,
131
+ *,
132
+ show_progress: bool = True,
133
+ ) -> XrefMapping:
134
+ """Download *src* and build a :class:`XrefMapping` for *subject_col*.
135
+
136
+ Args:
137
+ src: A datasource config's suggested crosswalk source (see
138
+ :class:`~mapkgsutils.parsers.base.XrefSource`).
139
+ subject_col: Which column of the downloaded table to use as the
140
+ ``subject_id`` (one of ``src.subject_id_cols.values()``).
141
+ show_progress: Whether to show a download progress bar.
142
+
143
+ Returns:
144
+ An :class:`XrefMapping` built from the downloaded table.
145
+ """
146
+ import tempfile
147
+
148
+ import pandas as pd
149
+
150
+ from mapkgsutils.download import download_file
151
+
152
+ with tempfile.TemporaryDirectory() as tmp_dir:
153
+ dest = Path(tmp_dir) / f"{src.id}.{src.format}"
154
+ downloaded = download_file(src.url, dest, show_progress=show_progress)
155
+ sep = "\t" if src.format == "tsv" else ","
156
+ df = pd.read_csv(downloaded, sep=sep, dtype=str)
157
+
158
+ if subject_col not in df.columns:
159
+ raise ValueError(f"Column {subject_col!r} not found in downloaded xref source {src.id!r}.")
160
+
161
+ records = [
162
+ XrefRecord(
163
+ subject_id=str(row[subject_col]).strip(),
164
+ object_id=str(row.get(src.object_id_col, "") or "").strip(),
165
+ object_label=str(row.get(src.object_label_col, "") or "").strip() or None,
166
+ )
167
+ for _, row in df.iterrows()
168
+ if str(row[subject_col]).strip() not in ("", "nan")
169
+ ]
170
+ return XrefMapping(records=records)
171
+
172
+
173
+ @dataclass
174
+ class ContextSpec:
175
+ """One source of per-row evidence used to disambiguate a flagged-ambiguous cell.
176
+
177
+ Args:
178
+ kind: ``"label"`` (alias/synonym string, matched via an alias
179
+ index), ``"id"`` (a related identifier string, also matched via
180
+ an alias index), or ``"xref"`` (a cross-reference token, matched
181
+ via an :class:`XrefMapping` crosswalk table).
182
+ column: Name of the DataFrame column carrying this evidence.
183
+ xref_mapping: Required when ``kind == "xref"``.
184
+ predicates: Accepted equivalence predicates for ``kind == "xref"``.
185
+ ``None`` means no restriction (any predicate is accepted).
186
+ """
187
+
188
+ kind: Literal["label", "id", "xref"]
189
+ column: str
190
+ xref_mapping: XrefMapping | None = None
191
+ predicates: set[str] | None = None
192
+
193
+
194
+ @dataclass
195
+ class DecisionRecord:
196
+ """One disambiguation attempt, for an auditable decision log.
197
+
198
+ Args:
199
+ stage: Which context kind made the attempt (``"xref_filter"``,
200
+ ``"label"``, or ``"id"``).
201
+ token: The evidence token that was looked up.
202
+ predicate_id: The candidate's predicate, when applicable.
203
+ candidate: The candidate entity the evidence pointed to.
204
+ accepted: Whether the attempt resolved the ambiguity.
205
+ reason: Human-readable explanation of the outcome.
206
+ """
207
+
208
+ stage: str
209
+ token: str
210
+ predicate_id: str | None
211
+ candidate: str | None
212
+ accepted: bool
213
+ reason: str
214
+
215
+
216
+ def write_decision_log(records: list[DecisionRecord], path: Path | str) -> None:
217
+ """Write records to path as a TSV log.
218
+
219
+ Columns are ``stage, token, predicate_id, candidate, accepted, reason``.
220
+
221
+ Args:
222
+ records: Decision records accumulated during resolution.
223
+ path: Destination TSV path.
224
+ """
225
+ import pandas as pd
226
+
227
+ columns = ["stage", "token", "predicate_id", "candidate", "accepted", "reason"]
228
+ df = pd.DataFrame(
229
+ [
230
+ {
231
+ "stage": r.stage,
232
+ "token": r.token,
233
+ "predicate_id": r.predicate_id,
234
+ "candidate": r.candidate,
235
+ "accepted": r.accepted,
236
+ "reason": r.reason,
237
+ }
238
+ for r in records
239
+ ],
240
+ columns=columns,
241
+ )
242
+ path = Path(path)
243
+ path.parent.mkdir(parents=True, exist_ok=True)
244
+ df.to_csv(path, sep="\t", index=False)
245
+
246
+
247
+ def resolve_ambiguous_with_xref(
248
+ token: str,
249
+ xref_token: str,
250
+ lkp: dict[str, str],
251
+ xref_index: dict[str, list[XrefRecord]],
252
+ token_to_id: dict[str, str] | None,
253
+ *,
254
+ accepted_predicates: set[str] | None = None,
255
+ trust_unannotated: bool = DEFAULT_TRUST_UNANNOTATED,
256
+ ) -> tuple[str, str | None, DecisionRecord]:
257
+ """Attempt to resolve an ambiguous token using a cross-reference token.
258
+
259
+ Args:
260
+ token: The ambiguous label or ID (appears both as a current primary
261
+ entry and as a secondary one pointing elsewhere).
262
+ xref_token: The row's cross-reference token, e.g. an Ensembl ID.
263
+ lkp: ``{secondary_token: resolved_token}`` lookup.
264
+ xref_index: ``{xref_token: [XrefRecord, ...]}`` (see
265
+ :meth:`XrefMapping.by_subject`).
266
+ token_to_id: ``{primary_token: primary_id}``. ``None`` when *token*
267
+ is already a CURIE (ID mode).
268
+ accepted_predicates: Equivalence predicates to accept. ``None``
269
+ accepts any predicate.
270
+ trust_unannotated: Whether an xref record with no ``predicate_id``
271
+ is accepted as an equivalence.
272
+
273
+ Returns:
274
+ A ``(resolved_token, resolved_id, decision)`` tuple. *resolved_token*
275
+ is ``""`` and *resolved_id* is ``None`` when unresolved.
276
+ """
277
+ stage = "xref_filter"
278
+ candidates = xref_index.get(xref_token) or []
279
+ if not candidates:
280
+ return (
281
+ "",
282
+ None,
283
+ DecisionRecord(stage, xref_token, None, None, False, "no crossreference entry"),
284
+ )
285
+
286
+ target_token = lkp.get(token, "")
287
+
288
+ def _id(tok: str) -> str:
289
+ return (token_to_id or {}).get(tok, tok) if tok else ""
290
+
291
+ target_id = _id(target_token)
292
+ own_id = _id(token)
293
+
294
+ gate_failure: DecisionRecord | None = None
295
+ mismatch: DecisionRecord | None = None
296
+
297
+ for record in candidates:
298
+ candidate_label = record.object_label or record.object_id
299
+ if record.predicate_id is None:
300
+ predicate_ok = trust_unannotated
301
+ reason_ok = "no predicate given, assumed equivalence"
302
+ reason_bad = "no predicate given, trust_unannotated is False"
303
+ else:
304
+ predicate_ok = accepted_predicates is None or record.predicate_id in accepted_predicates
305
+ reason_ok = "predicate accepted"
306
+ reason_bad = f"predicate {record.predicate_id} not in accepted set"
307
+
308
+ if not predicate_ok:
309
+ gate_failure = DecisionRecord(
310
+ stage, xref_token, record.predicate_id, candidate_label, False, reason_bad
311
+ )
312
+ continue
313
+
314
+ if target_token and (record.object_id == target_id or record.object_label == target_token):
315
+ return (
316
+ target_token,
317
+ target_id or None,
318
+ DecisionRecord(
319
+ stage, xref_token, record.predicate_id, candidate_label, True, reason_ok
320
+ ),
321
+ )
322
+ if record.object_id == own_id or record.object_label == token:
323
+ return (
324
+ token,
325
+ own_id or None,
326
+ DecisionRecord(
327
+ stage, xref_token, record.predicate_id, candidate_label, True, reason_ok
328
+ ),
329
+ )
330
+
331
+ mismatch = DecisionRecord(
332
+ stage,
333
+ xref_token,
334
+ record.predicate_id,
335
+ candidate_label,
336
+ False,
337
+ "crossreference points to a third entity",
338
+ )
339
+
340
+ decision = mismatch or gate_failure
341
+ if decision is None:
342
+ # Unreachable: candidates is non-empty, so the loop above always sets
343
+ # mismatch or gate_failure before falling through to here.
344
+ raise RuntimeError("no decision recorded for a non-empty candidate list")
345
+ return "", None, decision
346
+
347
+
348
+ __all__ = [
349
+ "DEFAULT_TRUST_UNANNOTATED",
350
+ "ContextSpec",
351
+ "DecisionRecord",
352
+ "XrefMapping",
353
+ "XrefRecord",
354
+ "download_xref_source",
355
+ "load_xref_mapping",
356
+ "resolve_ambiguous_with_xref",
357
+ "write_decision_log",
358
+ ]