mapkgsutils 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapkgsutils/__init__.py +1 -0
- mapkgsutils/config/__init__.py +1 -0
- mapkgsutils/config/schema.py +170 -0
- mapkgsutils/context.py +358 -0
- mapkgsutils/diff.py +338 -0
- mapkgsutils/download.py +661 -0
- mapkgsutils/exports.py +180 -0
- mapkgsutils/logging.py +65 -0
- mapkgsutils/parsers/__init__.py +1 -0
- mapkgsutils/parsers/base.py +1899 -0
- mapkgsutils/py.typed +1 -0
- mapkgsutils/version.py +39 -0
- mapkgsutils-0.0.2.dist-info/METADATA +385 -0
- mapkgsutils-0.0.2.dist-info/RECORD +16 -0
- mapkgsutils-0.0.2.dist-info/WHEEL +4 -0
- mapkgsutils-0.0.2.dist-info/licenses/LICENSE +21 -0
mapkgsutils/__init__.py
ADDED
|
@@ -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
|
+
]
|