lorebase-cli 0.2.3__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.
- lorebase_cli/__init__.py +3 -0
- lorebase_cli/__main__.py +5 -0
- lorebase_cli/batch_import.py +330 -0
- lorebase_cli/coercion.py +176 -0
- lorebase_cli/commands/__init__.py +1 -0
- lorebase_cli/commands/admin.py +254 -0
- lorebase_cli/commands/agents.py +244 -0
- lorebase_cli/commands/audit.py +51 -0
- lorebase_cli/commands/data.py +742 -0
- lorebase_cli/commands/documents.py +161 -0
- lorebase_cli/commands/domains.py +404 -0
- lorebase_cli/commands/history.py +221 -0
- lorebase_cli/commands/proposals.py +476 -0
- lorebase_cli/commands/schema.py +523 -0
- lorebase_cli/commands/sources.py +69 -0
- lorebase_cli/commands/trace.py +140 -0
- lorebase_cli/context.py +93 -0
- lorebase_cli/failures.py +195 -0
- lorebase_cli/importing.py +277 -0
- lorebase_cli/linkml.py +1607 -0
- lorebase_cli/main.py +156 -0
- lorebase_cli/options.py +56 -0
- lorebase_cli/output.py +169 -0
- lorebase_cli/py.typed +0 -0
- lorebase_cli/settings.py +169 -0
- lorebase_cli/sources.py +118 -0
- lorebase_cli-0.2.3.dist-info/METADATA +11 -0
- lorebase_cli-0.2.3.dist-info/RECORD +30 -0
- lorebase_cli-0.2.3.dist-info/WHEEL +4 -0
- lorebase_cli-0.2.3.dist-info/entry_points.txt +2 -0
lorebase_cli/linkml.py
ADDED
|
@@ -0,0 +1,1607 @@
|
|
|
1
|
+
"""LinkML interchange for schema documents.
|
|
2
|
+
|
|
3
|
+
`to_linkml` turns a Lorebase schema document (`lorebase.schema/v1`, parsed YAML) into a LinkML
|
|
4
|
+
schema; `from_linkml` turns a LinkML schema into a Lorebase schema document plus warnings about
|
|
5
|
+
what it could not keep. Both work on parsed YAML: the part of the LinkML metamodel they use
|
|
6
|
+
(classes, slots, attributes, slot_usage, mixins, enums, types, unique keys, annotations) is small,
|
|
7
|
+
so no LinkML library is needed. Reading and writing YAML needs PyYAML, an optional dependency
|
|
8
|
+
(`pip install "lorebase-cli[linkml]"`).
|
|
9
|
+
|
|
10
|
+
Mapping, both ways:
|
|
11
|
+
|
|
12
|
+
| Lorebase | LinkML |
|
|
13
|
+
|----------|--------|
|
|
14
|
+
| Domain | schema (`name`, `title`, `description`) |
|
|
15
|
+
| Kind | class |
|
|
16
|
+
| Trait | mixin class (`mixin: true`), Kinds list it in `mixins` |
|
|
17
|
+
| Attribute | class attribute (an inline slot) |
|
|
18
|
+
| type | `range`; arrays are `multivalued`; enums are enums |
|
|
19
|
+
| `required`, constraints, `default` | `required`, `minimum_value` and friends, `ifabsent` |
|
|
20
|
+
| primary key | `identifier: true` (one Attribute) or the unique key `primary_key` |
|
|
21
|
+
| `unique` | a unique key of one slot |
|
|
22
|
+
| Relation | a slot with a class range on each side, linked by `inverse` |
|
|
23
|
+
| `cardinality` | `multivalued` of the two slots |
|
|
24
|
+
| `id`, sensitivity and every other field | annotations `lorebase.<field>` |
|
|
25
|
+
| path Derivation | a slot on the first Kind, annotated with the steps; read back on import |
|
|
26
|
+
| Operations, other Derivations, Sources | schema annotations on export; skipped on import |
|
|
27
|
+
|
|
28
|
+
Annotation values are scalars; structured values are written as JSON text and read back as
|
|
29
|
+
JSON when the text is a JSON object or array.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import copy
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from collections.abc import Iterable, Mapping
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
#: Value of the `format` key of a Lorebase schema document.
|
|
42
|
+
LOREBASE_FORMAT = "lorebase.schema/v1"
|
|
43
|
+
#: Prefix of the annotation tags that carry Lorebase fields.
|
|
44
|
+
ANNOTATION_PREFIX = "lorebase."
|
|
45
|
+
#: Namespace and import of the LinkML built-in types.
|
|
46
|
+
LINKML_NAMESPACE = "https://w3id.org/linkml/"
|
|
47
|
+
LINKML_TYPES_IMPORT = "linkml:types"
|
|
48
|
+
|
|
49
|
+
#: Sections of a Lorebase document kept only as annotations in LinkML.
|
|
50
|
+
EXPORT_ONLY_SECTIONS = ("operations", "derivations", "sources")
|
|
51
|
+
#: Annotation tag value that marks a slot as a path Derivation, not an Attribute or Relation.
|
|
52
|
+
_PATH_MARK = "path"
|
|
53
|
+
#: Path Derivation fields that travel as annotations of its slot, besides the id.
|
|
54
|
+
_PATH_FIELDS = ("steps", "inverse_api_name", "materialized")
|
|
55
|
+
#: Attribute fields that name elements of those sections, dropped on import.
|
|
56
|
+
_SECTION_REFERENCES = {"derivation": "derivations", "authority": "sources"}
|
|
57
|
+
_LOREBASE_ROOT_KEYS = frozenset(
|
|
58
|
+
{"format", "domain", "kinds", "relations", "traits", *EXPORT_ONLY_SECTIONS}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
#: Lorebase types with a LinkML range of the same meaning.
|
|
62
|
+
_NATIVE_RANGES = {
|
|
63
|
+
"string": "string",
|
|
64
|
+
"uri": "uri",
|
|
65
|
+
"integer": "integer",
|
|
66
|
+
"float": "float",
|
|
67
|
+
"boolean": "boolean",
|
|
68
|
+
"date": "date",
|
|
69
|
+
"timestamp": "datetime",
|
|
70
|
+
}
|
|
71
|
+
#: LinkML built-in types and their Lorebase types.
|
|
72
|
+
_LINKML_TYPES = {
|
|
73
|
+
"string": "string",
|
|
74
|
+
"integer": "integer",
|
|
75
|
+
"boolean": "boolean",
|
|
76
|
+
"float": "float",
|
|
77
|
+
"double": "float",
|
|
78
|
+
"date": "date",
|
|
79
|
+
"datetime": "timestamp",
|
|
80
|
+
"uri": "uri",
|
|
81
|
+
"uriorcurie": "uri",
|
|
82
|
+
"curie": "string",
|
|
83
|
+
"ncname": "string",
|
|
84
|
+
"objectidentifier": "string",
|
|
85
|
+
"nodeidentifier": "string",
|
|
86
|
+
"jsonpointer": "string",
|
|
87
|
+
"jsonpath": "string",
|
|
88
|
+
"sparqlpath": "string",
|
|
89
|
+
}
|
|
90
|
+
#: LinkML built-in types without an exact Lorebase type: the closest one.
|
|
91
|
+
_APPROXIMATE_LINKML_TYPES = {"time": "string", "date_or_datetime": "timestamp"}
|
|
92
|
+
|
|
93
|
+
_DECIMAL = re.compile(r"decimal\(\s*(\d+)\s*,\s*(\d+)\s*\)")
|
|
94
|
+
_REF = re.compile(r"ref\(\s*([^()\s]+)\s*\)")
|
|
95
|
+
_ARRAY = re.compile(r"array<(.+)>")
|
|
96
|
+
_IFABSENT = re.compile(r"(string|int|float|decimal|date|datetime)\((.*)\)", re.DOTALL)
|
|
97
|
+
#: Constraints with a LinkML slot field of the same meaning.
|
|
98
|
+
_CONSTRAINT_SLOTS = (
|
|
99
|
+
("min", "minimum_value"),
|
|
100
|
+
("max", "maximum_value"),
|
|
101
|
+
("pattern", "pattern"),
|
|
102
|
+
("enum_values", "equals_string_in"),
|
|
103
|
+
)
|
|
104
|
+
#: Length constraints: LinkML cardinalities on arrays, annotations otherwise.
|
|
105
|
+
_LENGTH_SLOTS = (("min_length", "minimum_cardinality"), ("max_length", "maximum_cardinality"))
|
|
106
|
+
_PRIMARY_KEY = "primary_key"
|
|
107
|
+
#: Long annotation values (JSON text) stay on one line.
|
|
108
|
+
_LINE_WIDTH = 2**31
|
|
109
|
+
_INVERSE_MARK = "inverse"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class LinkmlOptions:
|
|
114
|
+
"""Choices the mapping cannot read from its input.
|
|
115
|
+
|
|
116
|
+
`schema_uri` is the `id` of exported schemas (`{domain}` is the Domain id). LinkML
|
|
117
|
+
`decimal` has no precision: imports without a `lorebase.type` annotation use
|
|
118
|
+
`decimal_precision` and `decimal_scale`. `default_range` applies when the LinkML schema
|
|
119
|
+
sets none.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
schema_uri: str = "urn:lorebase:schema:{domain}"
|
|
123
|
+
decimal_precision: int = 38
|
|
124
|
+
decimal_scale: int = 9
|
|
125
|
+
default_range: str = "string"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class ConversionWarning:
|
|
130
|
+
"""Something of the input that the output does not keep; `path` locates it in the input."""
|
|
131
|
+
|
|
132
|
+
path: str
|
|
133
|
+
message: str
|
|
134
|
+
|
|
135
|
+
def __str__(self) -> str:
|
|
136
|
+
return f"{self.path}: {self.message}" if self.path else self.message
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class LinkmlError(ValueError):
|
|
140
|
+
"""The input cannot be converted at all."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Conversion:
|
|
145
|
+
"""A converted document and what got lost on the way."""
|
|
146
|
+
|
|
147
|
+
document: dict[str, Any]
|
|
148
|
+
warnings: list[ConversionWarning] = field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# YAML -----------------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_yaml(text: str) -> Any:
|
|
155
|
+
"""Parse YAML text (safe loader)."""
|
|
156
|
+
_require_yaml()
|
|
157
|
+
import yaml
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
return yaml.safe_load(text)
|
|
161
|
+
except yaml.YAMLError as error:
|
|
162
|
+
raise LinkmlError(f"invalid YAML: {error}") from error
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def dump_yaml(document: Mapping[str, Any]) -> str:
|
|
166
|
+
"""Block style YAML in document order; empty values (enum permissible values) print blank."""
|
|
167
|
+
_require_yaml()
|
|
168
|
+
import yaml
|
|
169
|
+
|
|
170
|
+
class Dumper(yaml.SafeDumper):
|
|
171
|
+
def ignore_aliases(self, data: Any) -> bool:
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
def blank(dumper: yaml.SafeDumper, _: None) -> yaml.ScalarNode:
|
|
175
|
+
return dumper.represent_scalar("tag:yaml.org,2002:null", "")
|
|
176
|
+
|
|
177
|
+
Dumper.add_representer(type(None), blank)
|
|
178
|
+
return str(
|
|
179
|
+
yaml.dump(
|
|
180
|
+
dict(document), Dumper=Dumper, sort_keys=False, allow_unicode=True, width=_LINE_WIDTH
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _require_yaml() -> None:
|
|
186
|
+
try:
|
|
187
|
+
import yaml # noqa: F401
|
|
188
|
+
except ImportError: # pragma: no cover - installed in the workspace
|
|
189
|
+
raise LinkmlError("LinkML needs PyYAML: pip install 'lorebase-cli[linkml]'") from None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Shared helpers -------------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _encode(value: Any) -> Any:
|
|
196
|
+
"""Annotation value: scalars as they are, anything else as JSON text."""
|
|
197
|
+
if isinstance(value, str | bool | int | float):
|
|
198
|
+
return value
|
|
199
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _decode(value: Any) -> Any:
|
|
203
|
+
if isinstance(value, str) and value[:1] in ("{", "["):
|
|
204
|
+
try:
|
|
205
|
+
return json.loads(value)
|
|
206
|
+
except ValueError:
|
|
207
|
+
return value
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _annotations(fields: Mapping[str, Any]) -> dict[str, Any]:
|
|
212
|
+
return {
|
|
213
|
+
ANNOTATION_PREFIX + key: _encode(value)
|
|
214
|
+
for key, value in fields.items()
|
|
215
|
+
if value is not None
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _tags(annotations: Any) -> set[str]:
|
|
220
|
+
"""Tags of LinkML annotations, written as a mapping or as a list of tag and value."""
|
|
221
|
+
if isinstance(annotations, Mapping):
|
|
222
|
+
return {str(tag) for tag in annotations}
|
|
223
|
+
if isinstance(annotations, list):
|
|
224
|
+
return {str(item.get("tag")) for item in annotations if isinstance(item, Mapping)}
|
|
225
|
+
return set()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _put(target: dict[str, Any], key: str, value: Any) -> None:
|
|
229
|
+
if value not in (None, "", [], {}):
|
|
230
|
+
target[key] = value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _mapping(value: Any, path: str) -> dict[str, Any]:
|
|
234
|
+
if value is None:
|
|
235
|
+
return {}
|
|
236
|
+
if not isinstance(value, Mapping):
|
|
237
|
+
raise LinkmlError(f"{path}: expected a mapping")
|
|
238
|
+
return dict(value)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def snake_name(name: str) -> str:
|
|
242
|
+
"""`hasEmploymentHistory`, `has employment history` -> `has_employment_history`."""
|
|
243
|
+
text = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", str(name).strip())
|
|
244
|
+
text = re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()
|
|
245
|
+
if not text or not text[0].isalpha():
|
|
246
|
+
text = f"slot_{text}".rstrip("_")
|
|
247
|
+
return text
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def pascal_name(name: str) -> str:
|
|
251
|
+
"""`named thing`, `named_thing` -> `NamedThing`; already Pascal names stay as they are."""
|
|
252
|
+
parts = [part for part in re.split(r"[^A-Za-z0-9]+", str(name)) if part]
|
|
253
|
+
text = "".join(part[:1].upper() + part[1:] for part in parts)
|
|
254
|
+
if not text or not text[0].isalpha():
|
|
255
|
+
text = f"C{text}"
|
|
256
|
+
return text
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class _Type:
|
|
261
|
+
"""A parsed Lorebase type node."""
|
|
262
|
+
|
|
263
|
+
name: str
|
|
264
|
+
enum: tuple[str, ...] = ()
|
|
265
|
+
target: str = ""
|
|
266
|
+
element: _Type | None = None
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def scalar(self) -> _Type:
|
|
270
|
+
return self.element if self.element is not None else self
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_type(node: Any, path: str) -> _Type:
|
|
274
|
+
if isinstance(node, Mapping) and len(node) == 1:
|
|
275
|
+
((key, value),) = node.items()
|
|
276
|
+
if key == "enum" and isinstance(value, list):
|
|
277
|
+
return _Type("enum", enum=tuple(str(item) for item in value))
|
|
278
|
+
if key == "array":
|
|
279
|
+
return _Type("array", element=_parse_type(value, path))
|
|
280
|
+
if isinstance(node, str):
|
|
281
|
+
text = node.strip()
|
|
282
|
+
if match := _ARRAY.fullmatch(text):
|
|
283
|
+
return _Type("array", element=_parse_type(match[1], path))
|
|
284
|
+
if match := _REF.fullmatch(text):
|
|
285
|
+
return _Type("ref", target=match[1])
|
|
286
|
+
if _DECIMAL.fullmatch(text):
|
|
287
|
+
return _Type("decimal")
|
|
288
|
+
return _Type(text)
|
|
289
|
+
raise LinkmlError(f"{path}: unrecognised type {node!r}")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _ifabsent(value: Any) -> str | None:
|
|
293
|
+
"""LinkML `ifabsent` of a scalar default, None for other values."""
|
|
294
|
+
if isinstance(value, bool):
|
|
295
|
+
return "True" if value else "False"
|
|
296
|
+
if isinstance(value, int):
|
|
297
|
+
return f"int({value})"
|
|
298
|
+
if isinstance(value, float):
|
|
299
|
+
return f"float({value!r})"
|
|
300
|
+
if isinstance(value, str):
|
|
301
|
+
return f"string({value})"
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Export ---------------------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def to_linkml(document: Any, options: LinkmlOptions | None = None) -> dict[str, Any]:
|
|
309
|
+
"""The LinkML schema of a Lorebase schema document. Nothing is lost: what LinkML cannot
|
|
310
|
+
express travels as `lorebase.*` annotations."""
|
|
311
|
+
return _Exporter(document, options or LinkmlOptions()).schema()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class _Exporter:
|
|
315
|
+
def __init__(self, document: Any, options: LinkmlOptions) -> None:
|
|
316
|
+
if not isinstance(document, Mapping):
|
|
317
|
+
raise LinkmlError("a Lorebase schema document is a mapping")
|
|
318
|
+
found = document.get("format", LOREBASE_FORMAT)
|
|
319
|
+
if found != LOREBASE_FORMAT:
|
|
320
|
+
raise LinkmlError(f"format: expected {LOREBASE_FORMAT}, found {found!r}")
|
|
321
|
+
unknown = sorted(str(key) for key in document if key not in _LOREBASE_ROOT_KEYS)
|
|
322
|
+
if unknown:
|
|
323
|
+
raise LinkmlError(f"unknown sections: {', '.join(unknown)}")
|
|
324
|
+
self.document = document
|
|
325
|
+
self.options = options
|
|
326
|
+
self.kinds = _mapping(document.get("kinds"), "kinds")
|
|
327
|
+
self.traits = _mapping(document.get("traits"), "traits")
|
|
328
|
+
self.relations = _mapping(document.get("relations"), "relations")
|
|
329
|
+
self.enums: dict[str, dict[str, Any]] = {}
|
|
330
|
+
|
|
331
|
+
def schema(self) -> dict[str, Any]:
|
|
332
|
+
domain = _mapping(self.document.get("domain"), "domain")
|
|
333
|
+
name = domain.pop("id", None)
|
|
334
|
+
if not isinstance(name, str) or not name:
|
|
335
|
+
raise LinkmlError("domain: no id")
|
|
336
|
+
uri = self.options.schema_uri.format(domain=name)
|
|
337
|
+
schema: dict[str, Any] = {"id": uri, "name": name}
|
|
338
|
+
_put(schema, "title", domain.pop("display_name", None))
|
|
339
|
+
_put(schema, "description", domain.pop("description", None))
|
|
340
|
+
namespace = uri if uri.endswith(("/", "#")) else f"{uri}/"
|
|
341
|
+
schema["prefixes"] = {"linkml": LINKML_NAMESPACE, name: namespace}
|
|
342
|
+
schema["default_prefix"] = name
|
|
343
|
+
schema["default_range"] = "string"
|
|
344
|
+
schema["imports"] = [LINKML_TYPES_IMPORT]
|
|
345
|
+
annotations = {ANNOTATION_PREFIX + "format": LOREBASE_FORMAT, **_annotations(domain)}
|
|
346
|
+
schema["annotations"] = annotations
|
|
347
|
+
|
|
348
|
+
classes: dict[str, dict[str, Any]] = {}
|
|
349
|
+
for kind_name, kind in self.kinds.items():
|
|
350
|
+
classes[kind_name] = self.kind_class(kind_name, _mapping(kind, f"kinds/{kind_name}"))
|
|
351
|
+
for trait_name, trait in self.traits.items():
|
|
352
|
+
path = f"traits/{trait_name}"
|
|
353
|
+
classes[trait_name] = self.trait_class(trait_name, _mapping(trait, path))
|
|
354
|
+
for relation_name, relation in self.relations.items():
|
|
355
|
+
path = f"relations/{relation_name}"
|
|
356
|
+
self.relation_slots(relation_name, _mapping(relation, path), classes, path)
|
|
357
|
+
derivations = self.path_slots(classes, self.document.get("derivations"))
|
|
358
|
+
for section in EXPORT_ONLY_SECTIONS:
|
|
359
|
+
value = derivations if section == "derivations" else self.document.get(section)
|
|
360
|
+
if value:
|
|
361
|
+
annotations[ANNOTATION_PREFIX + section] = _encode(value)
|
|
362
|
+
_put(schema, "classes", classes)
|
|
363
|
+
_put(schema, "enums", self.enums)
|
|
364
|
+
return schema
|
|
365
|
+
|
|
366
|
+
def path_slots(self, classes: dict[str, dict[str, Any]], derivations: Any) -> dict[str, Any]:
|
|
367
|
+
"""Puts each path Derivation on the class where it starts, as a slot that ends at the
|
|
368
|
+
class where it ends; returns the other Derivations, which stay in the schema annotation."""
|
|
369
|
+
kept: dict[str, Any] = {}
|
|
370
|
+
for name, value in _mapping(derivations, "derivations").items():
|
|
371
|
+
path = f"derivations/{name}"
|
|
372
|
+
derivation = _mapping(value, path)
|
|
373
|
+
ends = self.path_ends(derivation)
|
|
374
|
+
if ends is None:
|
|
375
|
+
kept[name] = value
|
|
376
|
+
continue
|
|
377
|
+
start, end, many = ends
|
|
378
|
+
fields = dict(derivation)
|
|
379
|
+
definition = _mapping(fields.pop("definition", None), f"{path}/definition")
|
|
380
|
+
slot: dict[str, Any] = {}
|
|
381
|
+
annotations: dict[str, Any] = {ANNOTATION_PREFIX + "derivation_variant": _PATH_MARK}
|
|
382
|
+
_put(annotations, ANNOTATION_PREFIX + "id", fields.pop("id", None))
|
|
383
|
+
_put(slot, "description", fields.pop("description", None))
|
|
384
|
+
_put(slot, "title", fields.pop("display_name", None))
|
|
385
|
+
slot["range"] = end
|
|
386
|
+
if many:
|
|
387
|
+
slot["multivalued"] = True
|
|
388
|
+
annotations.update(_annotations({key: definition.get(key) for key in _PATH_FIELDS}))
|
|
389
|
+
annotations.update(_annotations(fields))
|
|
390
|
+
slot["annotations"] = annotations
|
|
391
|
+
self.add_slot(classes, start, name, slot, path)
|
|
392
|
+
return kept
|
|
393
|
+
|
|
394
|
+
def path_ends(self, derivation: Mapping[str, Any]) -> tuple[str, str, bool] | None:
|
|
395
|
+
"""Kind where a path Derivation starts, Kind where it ends and whether it can reach
|
|
396
|
+
several Records; None when it is not a path or its steps do not chain through Kinds."""
|
|
397
|
+
definition = derivation.get("definition")
|
|
398
|
+
if not isinstance(definition, Mapping) or definition.get("variant") != _PATH_MARK:
|
|
399
|
+
return None
|
|
400
|
+
if set(definition) - {"variant", *_PATH_FIELDS}:
|
|
401
|
+
return None # fields a slot cannot carry: the annotation keeps the whole Derivation
|
|
402
|
+
start: str | None = None
|
|
403
|
+
end: str | None = None
|
|
404
|
+
many = False
|
|
405
|
+
for step in definition.get("steps") or []:
|
|
406
|
+
relation = (
|
|
407
|
+
self.relations.get(str(step.get("relation"))) if isinstance(step, Mapping) else None
|
|
408
|
+
)
|
|
409
|
+
if not isinstance(relation, Mapping):
|
|
410
|
+
return None
|
|
411
|
+
from_kind, to_kind = str(relation.get("from_kind")), str(relation.get("to_kind"))
|
|
412
|
+
many_from, _, many_to = str(relation.get("cardinality", "many_to_many")).partition(
|
|
413
|
+
"_to_"
|
|
414
|
+
)
|
|
415
|
+
if step.get("direction") == "inverse":
|
|
416
|
+
origin, target, fans_out = to_kind, from_kind, many_from == "many"
|
|
417
|
+
else:
|
|
418
|
+
origin, target, fans_out = from_kind, to_kind, many_to == "many"
|
|
419
|
+
if origin not in self.kinds or (end is not None and origin != end):
|
|
420
|
+
return None
|
|
421
|
+
start = start or origin
|
|
422
|
+
end = target
|
|
423
|
+
many = many or fans_out
|
|
424
|
+
if start is None or end not in self.kinds:
|
|
425
|
+
return None
|
|
426
|
+
return start, end, many
|
|
427
|
+
|
|
428
|
+
def kind_class(self, name: str, kind: dict[str, Any]) -> dict[str, Any]:
|
|
429
|
+
path = f"kinds/{name}"
|
|
430
|
+
cls: dict[str, Any] = {}
|
|
431
|
+
annotations: dict[str, Any] = {}
|
|
432
|
+
_put(annotations, ANNOTATION_PREFIX + "id", kind.pop("id", None))
|
|
433
|
+
_put(cls, "description", kind.pop("description", None))
|
|
434
|
+
_put(cls, "title", kind.pop("display_name", None))
|
|
435
|
+
primary_key = list(kind.pop("primary_key", None) or [])
|
|
436
|
+
traits = list(kind.pop("traits", None) or [])
|
|
437
|
+
attributes = _mapping(kind.pop("attributes", None), f"{path}/attributes")
|
|
438
|
+
if traits:
|
|
439
|
+
cls["mixins"] = [str(_mapping(item, f"{path}/traits").get("trait")) for item in traits]
|
|
440
|
+
if not self.identity_traits(traits, attributes):
|
|
441
|
+
annotations[ANNOTATION_PREFIX + "traits"] = _encode(traits)
|
|
442
|
+
slots: dict[str, Any] = {}
|
|
443
|
+
unique_keys: dict[str, Any] = {}
|
|
444
|
+
if len(primary_key) > 1:
|
|
445
|
+
unique_keys[_PRIMARY_KEY] = {"unique_key_slots": primary_key}
|
|
446
|
+
for attribute_name, attribute in attributes.items():
|
|
447
|
+
identifier = primary_key == [attribute_name]
|
|
448
|
+
slot, unique = self.attribute_slot(
|
|
449
|
+
name, attribute_name, attribute, f"{path}/attributes/{attribute_name}", identifier
|
|
450
|
+
)
|
|
451
|
+
slots[attribute_name] = slot
|
|
452
|
+
if unique:
|
|
453
|
+
unique_keys[f"{attribute_name}_unique"] = {"unique_key_slots": [attribute_name]}
|
|
454
|
+
annotations.update(_annotations(kind))
|
|
455
|
+
cls["attributes"] = slots
|
|
456
|
+
_put(cls, "unique_keys", unique_keys)
|
|
457
|
+
cls["annotations"] = annotations
|
|
458
|
+
return cls
|
|
459
|
+
|
|
460
|
+
def identity_traits(self, traits: list[Any], attributes: Mapping[str, Any]) -> bool:
|
|
461
|
+
"""Whether `mixins` alone say it all: every map pairs equal names present in the Kind."""
|
|
462
|
+
for item in traits:
|
|
463
|
+
if not isinstance(item, Mapping) or set(item) - {"trait", "attribute_map"}:
|
|
464
|
+
return False
|
|
465
|
+
trait = self.traits.get(str(item.get("trait")))
|
|
466
|
+
names = _mapping(trait, "traits").get("attributes") or {}
|
|
467
|
+
expected = {name: name for name in names if name in attributes}
|
|
468
|
+
if dict(item.get("attribute_map") or {}) != expected:
|
|
469
|
+
return False
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
def trait_class(self, name: str, trait: dict[str, Any]) -> dict[str, Any]:
|
|
473
|
+
path = f"traits/{name}"
|
|
474
|
+
cls: dict[str, Any] = {}
|
|
475
|
+
annotations: dict[str, Any] = {}
|
|
476
|
+
_put(annotations, ANNOTATION_PREFIX + "id", trait.pop("id", None))
|
|
477
|
+
_put(cls, "description", trait.pop("description", None))
|
|
478
|
+
_put(cls, "title", trait.pop("display_name", None))
|
|
479
|
+
cls["mixin"] = True
|
|
480
|
+
slots = {}
|
|
481
|
+
attributes = _mapping(trait.pop("attributes", None), f"{path}/attributes")
|
|
482
|
+
for attribute_name, attribute in attributes.items():
|
|
483
|
+
attribute_path = f"{path}/attributes/{attribute_name}"
|
|
484
|
+
slots[attribute_name], _ = self.attribute_slot(
|
|
485
|
+
name, attribute_name, attribute, attribute_path, False
|
|
486
|
+
)
|
|
487
|
+
annotations.update(_annotations(trait))
|
|
488
|
+
cls["attributes"] = slots
|
|
489
|
+
cls["annotations"] = annotations
|
|
490
|
+
return cls
|
|
491
|
+
|
|
492
|
+
def attribute_slot(
|
|
493
|
+
self, owner: str, name: str, attribute: Any, path: str, identifier: bool
|
|
494
|
+
) -> tuple[dict[str, Any], bool]:
|
|
495
|
+
"""The slot of an Attribute and whether the Attribute is unique."""
|
|
496
|
+
fields = _mapping(attribute, path)
|
|
497
|
+
slot: dict[str, Any] = {}
|
|
498
|
+
annotations: dict[str, Any] = {}
|
|
499
|
+
_put(annotations, ANNOTATION_PREFIX + "id", fields.pop("id", None))
|
|
500
|
+
_put(slot, "description", fields.pop("description", None))
|
|
501
|
+
_put(slot, "title", fields.pop("display_name", None))
|
|
502
|
+
if "type" not in fields:
|
|
503
|
+
raise LinkmlError(f"{path}: no type")
|
|
504
|
+
node = fields.pop("type")
|
|
505
|
+
parsed = _parse_type(node, path)
|
|
506
|
+
scalar = parsed.scalar
|
|
507
|
+
if scalar.name == "enum":
|
|
508
|
+
slot["range"] = self.enum_name(owner, name, scalar.enum)
|
|
509
|
+
elif scalar.name in _NATIVE_RANGES:
|
|
510
|
+
slot["range"] = _NATIVE_RANGES[scalar.name]
|
|
511
|
+
else: # the closest range, the exact type in an annotation
|
|
512
|
+
slot["range"] = {"decimal": "decimal", "ref": scalar.target}.get(scalar.name, "string")
|
|
513
|
+
annotations[ANNOTATION_PREFIX + "type"] = _encode(node)
|
|
514
|
+
if parsed.name == "array":
|
|
515
|
+
slot["multivalued"] = True
|
|
516
|
+
required = bool(fields.pop("required", False))
|
|
517
|
+
if required or identifier:
|
|
518
|
+
slot["required"] = required
|
|
519
|
+
if identifier:
|
|
520
|
+
slot["identifier"] = True
|
|
521
|
+
unique = bool(fields.pop("unique", False))
|
|
522
|
+
if "default" in fields:
|
|
523
|
+
default = fields.pop("default")
|
|
524
|
+
ifabsent = _ifabsent(default)
|
|
525
|
+
if ifabsent is not None:
|
|
526
|
+
slot["ifabsent"] = ifabsent
|
|
527
|
+
elif default is not None:
|
|
528
|
+
annotations[ANNOTATION_PREFIX + "default"] = _encode(default)
|
|
529
|
+
constraints = _mapping(fields.pop("constraints", None), f"{path}/constraints")
|
|
530
|
+
for key, target in _CONSTRAINT_SLOTS:
|
|
531
|
+
if constraints.get(key) is not None:
|
|
532
|
+
slot[target] = constraints.pop(key)
|
|
533
|
+
for key, target in _LENGTH_SLOTS:
|
|
534
|
+
if constraints.get(key) is not None:
|
|
535
|
+
value = constraints.pop(key)
|
|
536
|
+
if parsed.name == "array":
|
|
537
|
+
slot[target] = value
|
|
538
|
+
else:
|
|
539
|
+
annotations[ANNOTATION_PREFIX + key] = value
|
|
540
|
+
if constraints:
|
|
541
|
+
annotations[ANNOTATION_PREFIX + "constraints"] = _encode(constraints)
|
|
542
|
+
sensitivity = fields.pop("sensitivity", None)
|
|
543
|
+
if sensitivity:
|
|
544
|
+
annotations[ANNOTATION_PREFIX + "sensitivity"] = ",".join(map(str, sensitivity))
|
|
545
|
+
annotations.update(_annotations(fields))
|
|
546
|
+
_put(slot, "annotations", annotations)
|
|
547
|
+
return slot, unique
|
|
548
|
+
|
|
549
|
+
def enum_name(self, owner: str, attribute: str, values: tuple[str, ...]) -> str:
|
|
550
|
+
"""A LinkML enum per Attribute, `OwnerAttribute`; equal value lists share a name."""
|
|
551
|
+
base = f"{owner}{pascal_name(attribute)}"
|
|
552
|
+
candidate, number = base, 1
|
|
553
|
+
taken = set(self.kinds) | set(self.traits)
|
|
554
|
+
while candidate in taken or (
|
|
555
|
+
candidate in self.enums and tuple(self.enums[candidate]["permissible_values"]) != values
|
|
556
|
+
):
|
|
557
|
+
number += 1
|
|
558
|
+
candidate = f"{base}{number}"
|
|
559
|
+
self.enums[candidate] = {"permissible_values": dict.fromkeys(values)}
|
|
560
|
+
return candidate
|
|
561
|
+
|
|
562
|
+
def relation_slots(
|
|
563
|
+
self, name: str, relation: dict[str, Any], classes: dict[str, dict[str, Any]], path: str
|
|
564
|
+
) -> None:
|
|
565
|
+
from_kind = str(relation.pop("from_kind", ""))
|
|
566
|
+
to_kind = str(relation.pop("to_kind", ""))
|
|
567
|
+
inverse = str(relation.pop("inverse_api_name", ""))
|
|
568
|
+
if not inverse:
|
|
569
|
+
raise LinkmlError(f"{path}: no inverse_api_name")
|
|
570
|
+
cardinality = str(relation.pop("cardinality", "many_to_many"))
|
|
571
|
+
many_from, _, many_to = cardinality.partition("_to_")
|
|
572
|
+
if {many_from, many_to} - {"one", "many"}:
|
|
573
|
+
raise LinkmlError(f"{path}: unknown cardinality {cardinality!r}")
|
|
574
|
+
forward: dict[str, Any] = {}
|
|
575
|
+
annotations: dict[str, Any] = {}
|
|
576
|
+
_put(annotations, ANNOTATION_PREFIX + "id", relation.pop("id", None))
|
|
577
|
+
_put(forward, "description", relation.pop("description", None))
|
|
578
|
+
_put(forward, "title", relation.pop("display_name", None))
|
|
579
|
+
forward["range"] = to_kind
|
|
580
|
+
if many_to == "many":
|
|
581
|
+
forward["multivalued"] = True
|
|
582
|
+
if relation.pop("required_from", False):
|
|
583
|
+
forward["required"] = True
|
|
584
|
+
forward["inverse"] = inverse
|
|
585
|
+
annotations.update(_annotations(relation))
|
|
586
|
+
_put(forward, "annotations", annotations)
|
|
587
|
+
self.add_slot(classes, from_kind, name, forward, path)
|
|
588
|
+
if inverse == name and from_kind == to_kind:
|
|
589
|
+
return # symmetric: one slot is its own inverse
|
|
590
|
+
back: dict[str, Any] = {"range": from_kind}
|
|
591
|
+
if many_from == "many":
|
|
592
|
+
back["multivalued"] = True
|
|
593
|
+
back["inverse"] = name
|
|
594
|
+
back["annotations"] = {ANNOTATION_PREFIX + "relation": _INVERSE_MARK}
|
|
595
|
+
self.add_slot(classes, to_kind, inverse, back, path)
|
|
596
|
+
|
|
597
|
+
def add_slot(
|
|
598
|
+
self,
|
|
599
|
+
classes: dict[str, dict[str, Any]],
|
|
600
|
+
kind: str,
|
|
601
|
+
name: str,
|
|
602
|
+
slot: dict[str, Any],
|
|
603
|
+
path: str,
|
|
604
|
+
) -> None:
|
|
605
|
+
if kind not in self.kinds:
|
|
606
|
+
raise LinkmlError(f"{path}: unknown Kind {kind!r}")
|
|
607
|
+
slots = classes[kind].setdefault("attributes", {})
|
|
608
|
+
if name in slots:
|
|
609
|
+
raise LinkmlError(f"{path}: Kind {kind} already has an attribute or relation {name}")
|
|
610
|
+
slots[name] = slot
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# Import ---------------------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def from_linkml(schema: Any, options: LinkmlOptions | None = None) -> Conversion:
|
|
617
|
+
"""The Lorebase schema document of a LinkML schema, with warnings for what it leaves out."""
|
|
618
|
+
return _Importer(schema, options or LinkmlOptions()).run()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
#: Keys of a LinkML schema the import reads.
|
|
622
|
+
_SCHEMA_KEYS = frozenset(
|
|
623
|
+
{
|
|
624
|
+
"id",
|
|
625
|
+
"name",
|
|
626
|
+
"title",
|
|
627
|
+
"description",
|
|
628
|
+
"prefixes",
|
|
629
|
+
"default_prefix",
|
|
630
|
+
"default_range",
|
|
631
|
+
"imports",
|
|
632
|
+
"annotations",
|
|
633
|
+
"classes",
|
|
634
|
+
"slots",
|
|
635
|
+
"enums",
|
|
636
|
+
"types",
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
_CLASS_KEYS = frozenset(
|
|
640
|
+
{
|
|
641
|
+
"description",
|
|
642
|
+
"title",
|
|
643
|
+
"is_a",
|
|
644
|
+
"mixins",
|
|
645
|
+
"mixin",
|
|
646
|
+
"abstract",
|
|
647
|
+
"tree_root",
|
|
648
|
+
"slots",
|
|
649
|
+
"attributes",
|
|
650
|
+
"slot_usage",
|
|
651
|
+
"unique_keys",
|
|
652
|
+
"annotations",
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
_ATTRIBUTE_KEYS = frozenset(
|
|
656
|
+
{
|
|
657
|
+
"description",
|
|
658
|
+
"title",
|
|
659
|
+
"range",
|
|
660
|
+
"multivalued",
|
|
661
|
+
"required",
|
|
662
|
+
"identifier",
|
|
663
|
+
"key",
|
|
664
|
+
"ifabsent",
|
|
665
|
+
"minimum_value",
|
|
666
|
+
"maximum_value",
|
|
667
|
+
"pattern",
|
|
668
|
+
"equals_string_in",
|
|
669
|
+
"minimum_cardinality",
|
|
670
|
+
"maximum_cardinality",
|
|
671
|
+
"annotations",
|
|
672
|
+
}
|
|
673
|
+
)
|
|
674
|
+
_RELATION_KEYS = frozenset(
|
|
675
|
+
{
|
|
676
|
+
"description",
|
|
677
|
+
"title",
|
|
678
|
+
"range",
|
|
679
|
+
"multivalued",
|
|
680
|
+
"required",
|
|
681
|
+
"inverse",
|
|
682
|
+
"inlined",
|
|
683
|
+
"inlined_as_list",
|
|
684
|
+
"annotations",
|
|
685
|
+
}
|
|
686
|
+
)
|
|
687
|
+
_INVERSE_SLOT_KEYS = frozenset({"range", "multivalued", "inverse", "annotations"})
|
|
688
|
+
_TRAIT_ATTRIBUTE_KEYS = ("id", "display_name", "description", "type", "required")
|
|
689
|
+
_TYPE_KEYS = frozenset({"typeof", "uri", "base", "repr", "description"})
|
|
690
|
+
_ENUM_KEYS = frozenset({"description", "title", "permissible_values", "enum_uri"})
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@dataclass
|
|
694
|
+
class _Candidate:
|
|
695
|
+
"""A slot with a class range: one side of a Relation."""
|
|
696
|
+
|
|
697
|
+
owner: str
|
|
698
|
+
slot_name: str
|
|
699
|
+
slot: dict[str, Any]
|
|
700
|
+
target: str
|
|
701
|
+
path: str
|
|
702
|
+
annotations: dict[str, Any]
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class _Importer:
|
|
706
|
+
def __init__(self, schema: Any, options: LinkmlOptions) -> None:
|
|
707
|
+
if not isinstance(schema, Mapping):
|
|
708
|
+
raise LinkmlError("a LinkML schema is a mapping")
|
|
709
|
+
self.schema = schema
|
|
710
|
+
self.options = options
|
|
711
|
+
self.warnings: list[ConversionWarning] = []
|
|
712
|
+
self.classes = _mapping(schema.get("classes"), "classes")
|
|
713
|
+
self.slots = _mapping(schema.get("slots"), "slots")
|
|
714
|
+
self.enums = _mapping(schema.get("enums"), "enums")
|
|
715
|
+
self.types = _mapping(schema.get("types"), "types")
|
|
716
|
+
self.default_range = str(schema.get("default_range") or options.default_range)
|
|
717
|
+
self.class_names: dict[str, str] = {}
|
|
718
|
+
self.derivations: dict[str, Any] = {}
|
|
719
|
+
self.skipped_paths: set[str] = set()
|
|
720
|
+
self.trait_attributes: dict[str, list[str]] = {}
|
|
721
|
+
self._enum_values: dict[str, tuple[str, ...] | None] = {}
|
|
722
|
+
self._type_names: dict[str, str] = {}
|
|
723
|
+
self._inherited: dict[str, dict[str, dict[str, Any]]] = {}
|
|
724
|
+
|
|
725
|
+
def warn(self, path: str, message: str) -> None:
|
|
726
|
+
warning = ConversionWarning(path, message)
|
|
727
|
+
if warning not in self.warnings:
|
|
728
|
+
self.warnings.append(warning)
|
|
729
|
+
|
|
730
|
+
def leftovers(self, mapping: Mapping[str, Any], handled: Iterable[str], path: str) -> None:
|
|
731
|
+
known = set(handled)
|
|
732
|
+
extra = sorted(str(key) for key in mapping if key not in known)
|
|
733
|
+
if extra:
|
|
734
|
+
self.warn(path, f"not supported, ignored: {', '.join(extra)}")
|
|
735
|
+
|
|
736
|
+
def annotations(self, value: Any, path: str) -> dict[str, Any]:
|
|
737
|
+
"""The `lorebase.*` annotations without prefix, values decoded; warns about others."""
|
|
738
|
+
pairs: list[tuple[str, Any]] = []
|
|
739
|
+
if isinstance(value, Mapping):
|
|
740
|
+
for tag, item in value.items():
|
|
741
|
+
if isinstance(item, Mapping) and "value" in item:
|
|
742
|
+
item = item["value"]
|
|
743
|
+
pairs.append((str(tag), item))
|
|
744
|
+
elif isinstance(value, list):
|
|
745
|
+
pairs = [
|
|
746
|
+
(str(item.get("tag")), item.get("value"))
|
|
747
|
+
for item in value
|
|
748
|
+
if isinstance(item, Mapping)
|
|
749
|
+
]
|
|
750
|
+
lorebase = {}
|
|
751
|
+
foreign = []
|
|
752
|
+
for tag, item in pairs:
|
|
753
|
+
if tag.startswith(ANNOTATION_PREFIX):
|
|
754
|
+
lorebase[tag.removeprefix(ANNOTATION_PREFIX)] = _decode(item)
|
|
755
|
+
else:
|
|
756
|
+
foreign.append(tag)
|
|
757
|
+
if foreign:
|
|
758
|
+
self.warn(f"{path}/annotations", f"not kept: {', '.join(sorted(foreign))}")
|
|
759
|
+
return lorebase
|
|
760
|
+
|
|
761
|
+
# Schema -----------------------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
def run(self) -> Conversion:
|
|
764
|
+
domain = self.domain()
|
|
765
|
+
self.leftovers(self.schema, _SCHEMA_KEYS, "schema")
|
|
766
|
+
imports = [str(item) for item in self.schema.get("imports") or []]
|
|
767
|
+
unresolved = [item for item in imports if item != LINKML_TYPES_IMPORT]
|
|
768
|
+
if unresolved:
|
|
769
|
+
self.warn("imports", f"not resolved, ignored: {', '.join(unresolved)}")
|
|
770
|
+
|
|
771
|
+
kinds_source, traits_source = self.class_roles()
|
|
772
|
+
traits: dict[str, Any] = {}
|
|
773
|
+
for name in traits_source:
|
|
774
|
+
traits[self.class_names[name]] = self.trait(name)
|
|
775
|
+
kinds: dict[str, Any] = {}
|
|
776
|
+
candidates: list[_Candidate] = []
|
|
777
|
+
for name in kinds_source:
|
|
778
|
+
kinds[self.class_names[name]] = self.kind(name, candidates)
|
|
779
|
+
relations = self.relations(candidates, set(kinds_source))
|
|
780
|
+
|
|
781
|
+
document: dict[str, Any] = {"format": LOREBASE_FORMAT, "domain": domain}
|
|
782
|
+
_put(document, "kinds", kinds)
|
|
783
|
+
_put(document, "relations", relations)
|
|
784
|
+
_put(document, "traits", traits)
|
|
785
|
+
_put(document, "derivations", self.derivations)
|
|
786
|
+
return Conversion(document, self.warnings)
|
|
787
|
+
|
|
788
|
+
def domain(self) -> dict[str, Any]:
|
|
789
|
+
name = self.schema.get("name")
|
|
790
|
+
if not isinstance(name, str) or not name.strip():
|
|
791
|
+
raise LinkmlError("schema: no name")
|
|
792
|
+
slug = re.sub(r"[^a-z0-9_-]+", "_", name.strip().lower()).strip("_-")
|
|
793
|
+
if not slug or not slug[0].isalpha():
|
|
794
|
+
slug = f"d_{slug}".rstrip("_")
|
|
795
|
+
if slug != name:
|
|
796
|
+
self.warn("name", f"Domain id {slug!r} made from {name!r}")
|
|
797
|
+
domain: dict[str, Any] = {"id": slug}
|
|
798
|
+
_put(domain, "display_name", self.schema.get("title"))
|
|
799
|
+
_put(domain, "description", self.schema.get("description"))
|
|
800
|
+
lorebase = self.annotations(self.schema.get("annotations"), "schema")
|
|
801
|
+
lorebase.pop("format", None)
|
|
802
|
+
for section in EXPORT_ONLY_SECTIONS:
|
|
803
|
+
if lorebase.pop(section, None) is not None:
|
|
804
|
+
self.warn(
|
|
805
|
+
f"annotations/{ANNOTATION_PREFIX}{section}",
|
|
806
|
+
f"{section} are not imported; define them again in Lorebase",
|
|
807
|
+
)
|
|
808
|
+
domain.update(lorebase)
|
|
809
|
+
return domain
|
|
810
|
+
|
|
811
|
+
def class_roles(self) -> tuple[list[str], list[str]]:
|
|
812
|
+
"""LinkML classes that become Kinds and Traits; others are reported."""
|
|
813
|
+
kinds: list[str] = []
|
|
814
|
+
traits: list[str] = []
|
|
815
|
+
for name, definition in self.classes.items():
|
|
816
|
+
cls = _mapping(definition, f"classes/{name}")
|
|
817
|
+
self.classes[name] = cls
|
|
818
|
+
path = f"classes/{name}"
|
|
819
|
+
if cls.get("mixin"):
|
|
820
|
+
traits.append(name)
|
|
821
|
+
elif cls.get("abstract"):
|
|
822
|
+
self.warn(path, "abstract class is not a Kind; classes inheriting it get its slots")
|
|
823
|
+
continue
|
|
824
|
+
elif cls.get("tree_root"):
|
|
825
|
+
self.warn(path, "tree_root container class skipped with its slots")
|
|
826
|
+
continue
|
|
827
|
+
else:
|
|
828
|
+
kinds.append(name)
|
|
829
|
+
api_name = pascal_name(name)
|
|
830
|
+
if api_name in self.class_names.values():
|
|
831
|
+
raise LinkmlError(f"{path}: name {api_name!r} is already taken")
|
|
832
|
+
if api_name != name:
|
|
833
|
+
self.warn(path, f"renamed to {api_name}")
|
|
834
|
+
self.class_names[name] = api_name
|
|
835
|
+
return kinds, traits
|
|
836
|
+
|
|
837
|
+
def effective_slots(self, name: str, seen: tuple[str, ...] = ()) -> dict[str, dict[str, Any]]:
|
|
838
|
+
"""Slots of a class with inheritance flattened: `is_a`, then mixins, then its own
|
|
839
|
+
`slots` and `attributes` (each replacing an inherited slot of the same name), then
|
|
840
|
+
`slot_usage` refining them."""
|
|
841
|
+
if name in self._inherited:
|
|
842
|
+
return self._inherited[name]
|
|
843
|
+
if name in seen:
|
|
844
|
+
raise LinkmlError(f"classes/{name}: inheritance cycle")
|
|
845
|
+
cls = _mapping(self.classes.get(name), f"classes/{name}")
|
|
846
|
+
path = f"classes/{name}"
|
|
847
|
+
slots: dict[str, dict[str, Any]] = {}
|
|
848
|
+
parents = [cls["is_a"]] if cls.get("is_a") else []
|
|
849
|
+
if ANNOTATION_PREFIX + "traits" not in _tags(cls.get("annotations")):
|
|
850
|
+
# With explicit Trait maps (an export), the class already lists every attribute.
|
|
851
|
+
parents.extend(cls.get("mixins") or [])
|
|
852
|
+
for parent in parents:
|
|
853
|
+
if parent not in self.classes:
|
|
854
|
+
self.warn(path, f"unknown parent class {parent}, ignored")
|
|
855
|
+
continue
|
|
856
|
+
for slot_name, slot in self.effective_slots(parent, (*seen, name)).items():
|
|
857
|
+
slots.setdefault(slot_name, dict(slot))
|
|
858
|
+
for slot_name in cls.get("slots") or []:
|
|
859
|
+
if slot_name not in self.slots:
|
|
860
|
+
self.warn(f"{path}/slots", f"unknown slot {slot_name}, typed {self.default_range}")
|
|
861
|
+
slots.pop(slot_name, None) # own slots keep their declared order
|
|
862
|
+
slots[slot_name] = _mapping(self.slots.get(slot_name), f"slots/{slot_name}")
|
|
863
|
+
for slot_name, slot in _mapping(cls.get("attributes"), f"{path}/attributes").items():
|
|
864
|
+
slots.pop(slot_name, None)
|
|
865
|
+
slots[slot_name] = _mapping(slot, f"{path}/attributes/{slot_name}")
|
|
866
|
+
for slot_name, usage in _mapping(cls.get("slot_usage"), f"{path}/slot_usage").items():
|
|
867
|
+
usage_path = f"{path}/slot_usage/{slot_name}"
|
|
868
|
+
slots[slot_name] = {**slots.get(slot_name, {}), **_mapping(usage, usage_path)}
|
|
869
|
+
self._inherited[name] = slots
|
|
870
|
+
return slots
|
|
871
|
+
|
|
872
|
+
def note_inheritance(self, name: str, cls: Mapping[str, Any]) -> None:
|
|
873
|
+
path = f"classes/{name}"
|
|
874
|
+
if cls.get("is_a"):
|
|
875
|
+
self.warn(path, f"is_a {cls['is_a']} flattened: inherited slots are copied")
|
|
876
|
+
for mixin in cls.get("mixins") or []:
|
|
877
|
+
if mixin in self.classes and not self.classes[mixin].get("mixin"):
|
|
878
|
+
self.warn(path, f"{mixin} is not a mixin class: its slots are copied")
|
|
879
|
+
|
|
880
|
+
# Classes ----------------------------------------------------------------------------------
|
|
881
|
+
|
|
882
|
+
def trait(self, name: str) -> dict[str, Any]:
|
|
883
|
+
cls = self.classes[name]
|
|
884
|
+
path = f"classes/{name}"
|
|
885
|
+
self.leftovers(cls, _CLASS_KEYS - {"unique_keys", "tree_root"}, path)
|
|
886
|
+
self.note_inheritance(name, cls)
|
|
887
|
+
lorebase = self.annotations(cls.get("annotations"), path)
|
|
888
|
+
trait: dict[str, Any] = {}
|
|
889
|
+
_put(trait, "id", lorebase.pop("id", None))
|
|
890
|
+
_put(trait, "display_name", cls.get("title"))
|
|
891
|
+
_put(trait, "description", cls.get("description"))
|
|
892
|
+
attributes: dict[str, Any] = {}
|
|
893
|
+
for slot_name, slot in self.effective_slots(name).items():
|
|
894
|
+
slot_path = f"{path}/attributes/{slot_name}"
|
|
895
|
+
if self.relation_target(slot) is not None:
|
|
896
|
+
self.warn(slot_path, "a slot with a class range is not a Trait attribute, skipped")
|
|
897
|
+
continue
|
|
898
|
+
attribute = self.attribute(slot, slot_path, unique=False, identifier=False)
|
|
899
|
+
dropped = sorted(set(attribute) - set(_TRAIT_ATTRIBUTE_KEYS))
|
|
900
|
+
if dropped:
|
|
901
|
+
self.warn(slot_path, f"not kept on a Trait attribute: {', '.join(dropped)}")
|
|
902
|
+
attributes[self.attribute_name(slot_name, slot_path)] = {
|
|
903
|
+
key: attribute[key] for key in _TRAIT_ATTRIBUTE_KEYS if key in attribute
|
|
904
|
+
}
|
|
905
|
+
self.trait_attributes[name] = list(attributes)
|
|
906
|
+
trait.update(lorebase)
|
|
907
|
+
_put(trait, "attributes", attributes)
|
|
908
|
+
return trait
|
|
909
|
+
|
|
910
|
+
def kind(self, name: str, candidates: list[_Candidate]) -> dict[str, Any]:
|
|
911
|
+
cls = self.classes[name]
|
|
912
|
+
path = f"classes/{name}"
|
|
913
|
+
self.leftovers(cls, _CLASS_KEYS, path)
|
|
914
|
+
self.note_inheritance(name, cls)
|
|
915
|
+
lorebase = self.annotations(cls.get("annotations"), path)
|
|
916
|
+
primary_key: list[str] = []
|
|
917
|
+
unique: set[str] = set()
|
|
918
|
+
for key_name, key in _mapping(cls.get("unique_keys"), f"{path}/unique_keys").items():
|
|
919
|
+
members = [str(item) for item in _mapping(key, path).get("unique_key_slots") or []]
|
|
920
|
+
if key_name == _PRIMARY_KEY:
|
|
921
|
+
primary_key = [snake_name(item) for item in members]
|
|
922
|
+
elif len(members) == 1:
|
|
923
|
+
unique.add(members[0])
|
|
924
|
+
else:
|
|
925
|
+
self.warn(f"{path}/unique_keys/{key_name}", "composite unique key not supported")
|
|
926
|
+
|
|
927
|
+
attributes: dict[str, Any] = {}
|
|
928
|
+
for slot_name, slot in self.effective_slots(name).items():
|
|
929
|
+
slot_path = f"{path}/attributes/{slot_name}"
|
|
930
|
+
if self.path_derivation(slot_name, slot, slot_path):
|
|
931
|
+
continue
|
|
932
|
+
target = self.relation_target(slot)
|
|
933
|
+
if target is not None:
|
|
934
|
+
if target in self.class_names and not self.classes[target].get("mixin"):
|
|
935
|
+
slot_annotations = self.annotations(slot.get("annotations"), slot_path)
|
|
936
|
+
candidates.append(
|
|
937
|
+
_Candidate(name, slot_name, slot, target, slot_path, slot_annotations)
|
|
938
|
+
)
|
|
939
|
+
else:
|
|
940
|
+
self.warn(slot_path, f"range {target} is not a Kind, slot skipped")
|
|
941
|
+
continue
|
|
942
|
+
attribute_name = self.attribute_name(slot_name, slot_path)
|
|
943
|
+
identifier = bool(slot.get("identifier") or slot.get("key"))
|
|
944
|
+
if identifier:
|
|
945
|
+
if primary_key and primary_key != [attribute_name]:
|
|
946
|
+
self.warn(slot_path, "a second identifier is not part of the primary key")
|
|
947
|
+
identifier = False
|
|
948
|
+
else:
|
|
949
|
+
primary_key = [attribute_name]
|
|
950
|
+
attributes[attribute_name] = self.attribute(
|
|
951
|
+
slot, slot_path, unique=slot_name in unique, identifier=identifier
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
kind: dict[str, Any] = {}
|
|
955
|
+
_put(kind, "id", lorebase.pop("id", None))
|
|
956
|
+
_put(kind, "display_name", cls.get("title"))
|
|
957
|
+
_put(kind, "description", cls.get("description"))
|
|
958
|
+
_put(kind, "primary_key", primary_key)
|
|
959
|
+
traits = lorebase.pop("traits", None)
|
|
960
|
+
kind.update(lorebase)
|
|
961
|
+
_put(kind, "attributes", attributes)
|
|
962
|
+
if traits is None:
|
|
963
|
+
traits = []
|
|
964
|
+
for mixin in cls.get("mixins") or []:
|
|
965
|
+
if mixin not in self.trait_attributes:
|
|
966
|
+
continue
|
|
967
|
+
names = self.trait_attributes[mixin]
|
|
968
|
+
item: dict[str, Any] = {"trait": self.class_names[mixin]}
|
|
969
|
+
_put(item, "attribute_map", {a: a for a in names if a in attributes})
|
|
970
|
+
traits.append(item)
|
|
971
|
+
_put(kind, "traits", traits)
|
|
972
|
+
self.draft_when_incomplete(kind, path)
|
|
973
|
+
return kind
|
|
974
|
+
|
|
975
|
+
def path_derivation(self, name: str, slot: Mapping[str, Any], path: str) -> bool:
|
|
976
|
+
"""Reads a slot marked as a path Derivation into `derivations`; False for other slots."""
|
|
977
|
+
lorebase = self.annotations(slot.get("annotations"), path)
|
|
978
|
+
if lorebase.pop("derivation_variant", None) != _PATH_MARK:
|
|
979
|
+
return False
|
|
980
|
+
if name in self.derivations or name in self.skipped_paths: # inherited: already read
|
|
981
|
+
return True
|
|
982
|
+
steps = lorebase.pop("steps", None)
|
|
983
|
+
if not isinstance(steps, list) or not steps:
|
|
984
|
+
self.skipped_paths.add(name)
|
|
985
|
+
self.warn(path, "path Derivation without steps, skipped")
|
|
986
|
+
return True
|
|
987
|
+
definition: dict[str, Any] = {"variant": _PATH_MARK, "steps": steps}
|
|
988
|
+
for key in _PATH_FIELDS[1:]:
|
|
989
|
+
_put(definition, key, lorebase.pop(key, None))
|
|
990
|
+
derivation: dict[str, Any] = {}
|
|
991
|
+
_put(derivation, "id", lorebase.pop("id", None))
|
|
992
|
+
_put(derivation, "display_name", slot.get("title"))
|
|
993
|
+
_put(derivation, "description", slot.get("description"))
|
|
994
|
+
derivation.update(lorebase)
|
|
995
|
+
derivation["definition"] = definition
|
|
996
|
+
self.derivations[name] = derivation
|
|
997
|
+
return True
|
|
998
|
+
|
|
999
|
+
def draft_when_incomplete(self, kind: dict[str, Any], path: str) -> None:
|
|
1000
|
+
"""Active Kinds need a title attribute and described Attributes, which LinkML schemas
|
|
1001
|
+
often lack: such a Kind is imported as a draft, to complete before activating it."""
|
|
1002
|
+
if "status" in kind:
|
|
1003
|
+
return
|
|
1004
|
+
missing = [] if kind.get("title_attribute") else ["a title_attribute"]
|
|
1005
|
+
undescribed = (
|
|
1006
|
+
[name for name, item in kind["attributes"].items() if not item.get("description")]
|
|
1007
|
+
if kind.get("attributes")
|
|
1008
|
+
else []
|
|
1009
|
+
)
|
|
1010
|
+
if undescribed:
|
|
1011
|
+
missing.append(f"descriptions of {', '.join(undescribed)}")
|
|
1012
|
+
if missing:
|
|
1013
|
+
kind["status"] = "draft"
|
|
1014
|
+
self.warn(path, f"imported as draft: add {' and '.join(missing)}, then activate it")
|
|
1015
|
+
|
|
1016
|
+
def attribute_name(self, slot_name: str, path: str) -> str:
|
|
1017
|
+
name = snake_name(slot_name)
|
|
1018
|
+
if name != slot_name:
|
|
1019
|
+
self.warn(path, f"renamed to {name}")
|
|
1020
|
+
return name
|
|
1021
|
+
|
|
1022
|
+
# Slots ------------------------------------------------------------------------------------
|
|
1023
|
+
|
|
1024
|
+
def relation_target(self, slot: Mapping[str, Any]) -> str | None:
|
|
1025
|
+
"""The class a slot points to, or None for slots with a type or enum range."""
|
|
1026
|
+
if ANNOTATION_PREFIX + "type" in _tags(slot.get("annotations")):
|
|
1027
|
+
return None
|
|
1028
|
+
target = str(slot.get("range") or self.default_range)
|
|
1029
|
+
return target if target in self.classes else None
|
|
1030
|
+
|
|
1031
|
+
def attribute(
|
|
1032
|
+
self, slot: Mapping[str, Any], path: str, *, unique: bool, identifier: bool
|
|
1033
|
+
) -> dict[str, Any]:
|
|
1034
|
+
self.leftovers(slot, _ATTRIBUTE_KEYS, path)
|
|
1035
|
+
lorebase = self.annotations(slot.get("annotations"), path)
|
|
1036
|
+
multivalued = bool(slot.get("multivalued"))
|
|
1037
|
+
attribute: dict[str, Any] = {}
|
|
1038
|
+
_put(attribute, "id", lorebase.pop("id", None))
|
|
1039
|
+
_put(attribute, "display_name", slot.get("title"))
|
|
1040
|
+
_put(attribute, "description", slot.get("description"))
|
|
1041
|
+
if "type" in lorebase:
|
|
1042
|
+
attribute["type"] = lorebase.pop("type")
|
|
1043
|
+
else:
|
|
1044
|
+
attribute["type"] = self.slot_type(slot, path, multivalued)
|
|
1045
|
+
required = slot.get("required")
|
|
1046
|
+
if required if required is not None else identifier:
|
|
1047
|
+
attribute["required"] = True
|
|
1048
|
+
if unique:
|
|
1049
|
+
attribute["unique"] = True
|
|
1050
|
+
if "ifabsent" in slot:
|
|
1051
|
+
default = self.default(slot["ifabsent"], path)
|
|
1052
|
+
if default is not _MISSING:
|
|
1053
|
+
attribute["default"] = default
|
|
1054
|
+
elif "default" in lorebase:
|
|
1055
|
+
attribute["default"] = lorebase.pop("default")
|
|
1056
|
+
constraints: dict[str, Any] = {}
|
|
1057
|
+
for key, source in _CONSTRAINT_SLOTS:
|
|
1058
|
+
if slot.get(source) is not None:
|
|
1059
|
+
constraints[key] = slot[source]
|
|
1060
|
+
for key, source in _LENGTH_SLOTS:
|
|
1061
|
+
if multivalued and slot.get(source) is not None:
|
|
1062
|
+
constraints[key] = slot[source]
|
|
1063
|
+
elif key in lorebase:
|
|
1064
|
+
constraints[key] = lorebase.pop(key)
|
|
1065
|
+
extra = lorebase.pop("constraints", None)
|
|
1066
|
+
if isinstance(extra, Mapping):
|
|
1067
|
+
constraints.update(extra)
|
|
1068
|
+
_put(attribute, "constraints", constraints)
|
|
1069
|
+
sensitivity = lorebase.pop("sensitivity", None)
|
|
1070
|
+
if sensitivity:
|
|
1071
|
+
tags = [tag.strip() for tag in str(sensitivity).split(",") if tag.strip()]
|
|
1072
|
+
_put(attribute, "sensitivity", tags)
|
|
1073
|
+
for key, section in _SECTION_REFERENCES.items():
|
|
1074
|
+
value = lorebase.pop(key, None)
|
|
1075
|
+
if value is not None:
|
|
1076
|
+
self.warn(path, f"{key} {value!r} dropped: {section} are not imported")
|
|
1077
|
+
attribute.update(lorebase)
|
|
1078
|
+
return attribute
|
|
1079
|
+
|
|
1080
|
+
def slot_type(self, slot: Mapping[str, Any], path: str, multivalued: bool) -> Any:
|
|
1081
|
+
scalar = self.scalar_type(str(slot.get("range") or self.default_range), path)
|
|
1082
|
+
if not multivalued:
|
|
1083
|
+
return scalar
|
|
1084
|
+
return f"array<{scalar}>" if isinstance(scalar, str) else {"array": scalar}
|
|
1085
|
+
|
|
1086
|
+
def scalar_type(self, range_name: str, path: str) -> Any:
|
|
1087
|
+
if range_name in self.enums:
|
|
1088
|
+
values = self.enum_values(range_name)
|
|
1089
|
+
return {"enum": list(values)} if values else "string"
|
|
1090
|
+
base = self.type_name(range_name, path)
|
|
1091
|
+
if base == "decimal":
|
|
1092
|
+
precision, scale = self.options.decimal_precision, self.options.decimal_scale
|
|
1093
|
+
self.warn(path, f"decimal without precision: decimal({precision},{scale}) assumed")
|
|
1094
|
+
return f"decimal({precision},{scale})"
|
|
1095
|
+
if base in _LINKML_TYPES:
|
|
1096
|
+
return _LINKML_TYPES[base]
|
|
1097
|
+
if base in _APPROXIMATE_LINKML_TYPES:
|
|
1098
|
+
closest = _APPROXIMATE_LINKML_TYPES[base]
|
|
1099
|
+
self.warn(path, f"range {base} has no Lorebase type, imported as {closest}")
|
|
1100
|
+
return closest
|
|
1101
|
+
self.warn(path, f"unknown range {range_name}, imported as string")
|
|
1102
|
+
return "string"
|
|
1103
|
+
|
|
1104
|
+
def type_name(self, name: str, path: str) -> str:
|
|
1105
|
+
"""Built-in type a range resolves to, following `typeof` of schema types."""
|
|
1106
|
+
if name in self._type_names:
|
|
1107
|
+
return self._type_names[name]
|
|
1108
|
+
current, seen = name.removeprefix("linkml:"), set()
|
|
1109
|
+
while current in self.types and current not in seen:
|
|
1110
|
+
seen.add(current)
|
|
1111
|
+
definition = _mapping(self.types[current], f"types/{current}")
|
|
1112
|
+
self.leftovers(definition, _TYPE_KEYS, f"types/{current}")
|
|
1113
|
+
parent = definition.get("typeof")
|
|
1114
|
+
if not parent:
|
|
1115
|
+
self.warn(f"types/{current}", "type without typeof, imported as string")
|
|
1116
|
+
current = "string"
|
|
1117
|
+
break
|
|
1118
|
+
current = str(parent).removeprefix("linkml:")
|
|
1119
|
+
self._type_names[name] = current
|
|
1120
|
+
return current
|
|
1121
|
+
|
|
1122
|
+
def enum_values(self, name: str) -> tuple[str, ...] | None:
|
|
1123
|
+
if name in self._enum_values:
|
|
1124
|
+
return self._enum_values[name]
|
|
1125
|
+
path = f"enums/{name}"
|
|
1126
|
+
definition = _mapping(self.enums[name], path)
|
|
1127
|
+
self.leftovers(definition, _ENUM_KEYS, path)
|
|
1128
|
+
permissible = definition.get("permissible_values") or {}
|
|
1129
|
+
values: list[str] = []
|
|
1130
|
+
detail: set[str] = set()
|
|
1131
|
+
if isinstance(permissible, Mapping):
|
|
1132
|
+
for text, meta in permissible.items():
|
|
1133
|
+
values.append(str(text))
|
|
1134
|
+
if isinstance(meta, Mapping):
|
|
1135
|
+
detail.update(str(key) for key in meta if key not in ("text", "description"))
|
|
1136
|
+
elif isinstance(permissible, list):
|
|
1137
|
+
for item in permissible:
|
|
1138
|
+
text = item.get("text") if isinstance(item, Mapping) else item
|
|
1139
|
+
values.append(str(text))
|
|
1140
|
+
if detail:
|
|
1141
|
+
self.warn(f"{path}/permissible_values", f"not kept: {', '.join(sorted(detail))}")
|
|
1142
|
+
result = tuple(values) or None
|
|
1143
|
+
if result is None:
|
|
1144
|
+
self.warn(path, "enum without permissible values, imported as string")
|
|
1145
|
+
self._enum_values[name] = result
|
|
1146
|
+
return result
|
|
1147
|
+
|
|
1148
|
+
def default(self, value: Any, path: str) -> Any:
|
|
1149
|
+
if isinstance(value, bool | int | float):
|
|
1150
|
+
return value
|
|
1151
|
+
if isinstance(value, str):
|
|
1152
|
+
text = value.strip()
|
|
1153
|
+
if text.lower() in ("true", "false"):
|
|
1154
|
+
return text.lower() == "true"
|
|
1155
|
+
if match := _IFABSENT.fullmatch(text):
|
|
1156
|
+
function, body = match.groups()
|
|
1157
|
+
try:
|
|
1158
|
+
if function == "int":
|
|
1159
|
+
return int(body)
|
|
1160
|
+
if function == "float":
|
|
1161
|
+
return float(body)
|
|
1162
|
+
except ValueError:
|
|
1163
|
+
pass
|
|
1164
|
+
else:
|
|
1165
|
+
return body
|
|
1166
|
+
self.warn(path, f"ifabsent {value!r} not supported, ignored")
|
|
1167
|
+
return _MISSING
|
|
1168
|
+
|
|
1169
|
+
# Relations --------------------------------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
def relations(self, candidates: list[_Candidate], kinds: set[str]) -> dict[str, Any]:
|
|
1172
|
+
"""Pair slots linked by `inverse` into Relations; unpaired slots get a made-up inverse."""
|
|
1173
|
+
by_slot = {(c.owner, c.slot_name): c for c in candidates}
|
|
1174
|
+
done: set[tuple[str, str]] = set()
|
|
1175
|
+
used: set[str] = set()
|
|
1176
|
+
relations: dict[str, Any] = {}
|
|
1177
|
+
for candidate in candidates:
|
|
1178
|
+
if (candidate.owner, candidate.slot_name) in done:
|
|
1179
|
+
continue
|
|
1180
|
+
forward, back = candidate, self.partner(candidate, by_slot, done)
|
|
1181
|
+
if (
|
|
1182
|
+
back is not None
|
|
1183
|
+
and back is not forward
|
|
1184
|
+
and forward.annotations.get("relation") == _INVERSE_MARK
|
|
1185
|
+
and back.annotations.get("relation") != _INVERSE_MARK
|
|
1186
|
+
):
|
|
1187
|
+
forward, back = back, forward
|
|
1188
|
+
done.add((forward.owner, forward.slot_name))
|
|
1189
|
+
if back is not None:
|
|
1190
|
+
done.add((back.owner, back.slot_name))
|
|
1191
|
+
name, relation = self.relation(forward, back, used)
|
|
1192
|
+
relations[name] = relation
|
|
1193
|
+
return relations
|
|
1194
|
+
|
|
1195
|
+
def partner(
|
|
1196
|
+
self,
|
|
1197
|
+
candidate: _Candidate,
|
|
1198
|
+
by_slot: Mapping[tuple[str, str], _Candidate],
|
|
1199
|
+
done: set[tuple[str, str]],
|
|
1200
|
+
) -> _Candidate | None:
|
|
1201
|
+
inverse = candidate.slot.get("inverse")
|
|
1202
|
+
if inverse is None:
|
|
1203
|
+
return None
|
|
1204
|
+
partner = by_slot.get((candidate.target, str(inverse)))
|
|
1205
|
+
if partner is None or partner.target != candidate.owner:
|
|
1206
|
+
self.warn(candidate.path, f"inverse {inverse} not found on {candidate.target}")
|
|
1207
|
+
return None
|
|
1208
|
+
if (partner.owner, partner.slot_name) in done:
|
|
1209
|
+
return None
|
|
1210
|
+
return partner
|
|
1211
|
+
|
|
1212
|
+
def relation(
|
|
1213
|
+
self, forward: _Candidate, back: _Candidate | None, used: set[str]
|
|
1214
|
+
) -> tuple[str, dict[str, Any]]:
|
|
1215
|
+
self.leftovers(forward.slot, _RELATION_KEYS, forward.path)
|
|
1216
|
+
owner, target = self.class_names[forward.owner], self.class_names[forward.target]
|
|
1217
|
+
if forward.slot.get("inlined") or forward.slot.get("inlined_as_list"):
|
|
1218
|
+
self.warn(forward.path, f"inlined objects become linked Records of Kind {target}")
|
|
1219
|
+
symmetric = back is forward
|
|
1220
|
+
lorebase = dict(forward.annotations)
|
|
1221
|
+
lorebase.pop("relation", None)
|
|
1222
|
+
name = self.relation_name(snake_name(forward.slot_name), owner, used, forward.path)
|
|
1223
|
+
if symmetric:
|
|
1224
|
+
inverse = name
|
|
1225
|
+
else:
|
|
1226
|
+
wanted = snake_name(back.slot_name) if back else f"{snake_name(owner)}_{name}"
|
|
1227
|
+
inverse = self.relation_name(wanted, target, used, forward.path)
|
|
1228
|
+
if back is not None:
|
|
1229
|
+
self.leftovers(back.slot, _INVERSE_SLOT_KEYS, back.path)
|
|
1230
|
+
extra = sorted(set(back.annotations) - {"relation"})
|
|
1231
|
+
if extra:
|
|
1232
|
+
self.warn(f"{back.path}/annotations", f"not kept: {', '.join(extra)}")
|
|
1233
|
+
many_to = "many" if forward.slot.get("multivalued") else "one"
|
|
1234
|
+
if symmetric:
|
|
1235
|
+
many_from = many_to
|
|
1236
|
+
elif back is not None:
|
|
1237
|
+
many_from = "many" if back.slot.get("multivalued") else "one"
|
|
1238
|
+
else:
|
|
1239
|
+
many_from = "many"
|
|
1240
|
+
|
|
1241
|
+
relation: dict[str, Any] = {}
|
|
1242
|
+
_put(relation, "id", lorebase.pop("id", None))
|
|
1243
|
+
relation["inverse_api_name"] = inverse
|
|
1244
|
+
_put(relation, "display_name", forward.slot.get("title"))
|
|
1245
|
+
_put(relation, "description", forward.slot.get("description"))
|
|
1246
|
+
relation["from_kind"] = owner
|
|
1247
|
+
relation["to_kind"] = target
|
|
1248
|
+
relation["cardinality"] = f"{many_from}_to_{many_to}"
|
|
1249
|
+
if forward.slot.get("required"):
|
|
1250
|
+
relation["required_from"] = True
|
|
1251
|
+
link_attributes = lorebase.pop("attributes", None)
|
|
1252
|
+
relation.update(lorebase)
|
|
1253
|
+
if isinstance(link_attributes, Mapping):
|
|
1254
|
+
relation["attributes"] = {
|
|
1255
|
+
key: self.link_attribute(value, f"{forward.path}/attributes/{key}")
|
|
1256
|
+
for key, value in link_attributes.items()
|
|
1257
|
+
}
|
|
1258
|
+
return name, relation
|
|
1259
|
+
|
|
1260
|
+
def link_attribute(self, attribute: Any, path: str) -> Any:
|
|
1261
|
+
if not isinstance(attribute, Mapping):
|
|
1262
|
+
return attribute
|
|
1263
|
+
kept = dict(attribute)
|
|
1264
|
+
for key, section in _SECTION_REFERENCES.items():
|
|
1265
|
+
value = kept.pop(key, None)
|
|
1266
|
+
if value is not None:
|
|
1267
|
+
self.warn(path, f"{key} {value!r} dropped: {section} are not imported")
|
|
1268
|
+
return kept
|
|
1269
|
+
|
|
1270
|
+
def relation_name(self, wanted: str, owner: str, used: set[str], path: str) -> str:
|
|
1271
|
+
"""Relation names are unique in a Domain: a taken one gets the owner Kind as prefix."""
|
|
1272
|
+
name = wanted
|
|
1273
|
+
if name in used:
|
|
1274
|
+
name = f"{snake_name(owner)}_{wanted}"
|
|
1275
|
+
number = 2
|
|
1276
|
+
while name in used:
|
|
1277
|
+
name = f"{snake_name(owner)}_{wanted}_{number}"
|
|
1278
|
+
number += 1
|
|
1279
|
+
self.warn(path, f"relation name {wanted} is taken, renamed to {name}")
|
|
1280
|
+
used.add(name)
|
|
1281
|
+
return name
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
class _Missing:
|
|
1285
|
+
pass
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
_MISSING: Any = _Missing()
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
# Merge ----------------------------------------------------------------------------------------
|
|
1292
|
+
|
|
1293
|
+
#: Fields of each element that a LinkML import sets; the others are kept from the current schema
|
|
1294
|
+
#: when the import does not carry them.
|
|
1295
|
+
_OWNED_KIND = frozenset(
|
|
1296
|
+
{"id", "display_name", "description", "primary_key", "traits", "attributes"}
|
|
1297
|
+
)
|
|
1298
|
+
_OWNED_TRAIT = frozenset({"id", "display_name", "description", "attributes"})
|
|
1299
|
+
_OWNED_ATTRIBUTE = frozenset(
|
|
1300
|
+
{
|
|
1301
|
+
"id",
|
|
1302
|
+
"display_name",
|
|
1303
|
+
"description",
|
|
1304
|
+
"type",
|
|
1305
|
+
"required",
|
|
1306
|
+
"unique",
|
|
1307
|
+
"default",
|
|
1308
|
+
"constraints",
|
|
1309
|
+
"sensitivity",
|
|
1310
|
+
}
|
|
1311
|
+
)
|
|
1312
|
+
_OWNED_RELATION = frozenset(
|
|
1313
|
+
{
|
|
1314
|
+
"id",
|
|
1315
|
+
"display_name",
|
|
1316
|
+
"description",
|
|
1317
|
+
"from_kind",
|
|
1318
|
+
"to_kind",
|
|
1319
|
+
"inverse_api_name",
|
|
1320
|
+
"cardinality",
|
|
1321
|
+
"required_from",
|
|
1322
|
+
"attributes",
|
|
1323
|
+
}
|
|
1324
|
+
)
|
|
1325
|
+
_OWNED_DERIVATION = frozenset({"id", "display_name", "description", "definition"})
|
|
1326
|
+
_OWNED_CONSTRAINTS = frozenset(key for pair in (*_CONSTRAINT_SLOTS, *_LENGTH_SLOTS) for key in pair)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def merge_into_current(current: Any, imported: Conversion, *, prune: bool = False) -> Conversion:
|
|
1330
|
+
"""The imported document laid over the current schema of a Domain.
|
|
1331
|
+
|
|
1332
|
+
The import owns Kinds, Attributes, Relations, Traits, enums and path Derivations. Everything
|
|
1333
|
+
else is kept: Operations, other Derivations and Sources, and the fields an element has that
|
|
1334
|
+
the import does not carry (`derivation`, `authority`, `indexed`, `icon`, ...). Elements match
|
|
1335
|
+
by `id`, else by name, and keep the current id. Kinds, Traits, Relations and Attributes the
|
|
1336
|
+
import leaves out are kept with a warning, or removed with `prune`.
|
|
1337
|
+
"""
|
|
1338
|
+
if not isinstance(current, Mapping):
|
|
1339
|
+
raise LinkmlError("the current schema is not a mapping")
|
|
1340
|
+
old = dict(current)
|
|
1341
|
+
document = copy.deepcopy(imported.document)
|
|
1342
|
+
warnings = list(imported.warnings)
|
|
1343
|
+
merger = _Merger(prune, warnings)
|
|
1344
|
+
|
|
1345
|
+
document["domain"] = {**_mapping(old.get("domain"), "domain"), **document["domain"]}
|
|
1346
|
+
for section in EXPORT_ONLY_SECTIONS:
|
|
1347
|
+
if old.get(section):
|
|
1348
|
+
document[section] = copy.deepcopy(old[section])
|
|
1349
|
+
renamed = {
|
|
1350
|
+
before: now
|
|
1351
|
+
for now, before in _match(
|
|
1352
|
+
_mapping(old.get("relations"), "relations"),
|
|
1353
|
+
_mapping(document.get("relations"), "relations"),
|
|
1354
|
+
).items()
|
|
1355
|
+
}
|
|
1356
|
+
imported_paths = _mapping(imported.document.get("derivations"), "derivations")
|
|
1357
|
+
pruned: set[str] = set()
|
|
1358
|
+
for section, owned in (("traits", _OWNED_TRAIT), ("kinds", _OWNED_KIND)):
|
|
1359
|
+
merged, gone = merger.section(section, old, document, owned, set())
|
|
1360
|
+
_put(document, section, merged)
|
|
1361
|
+
if section == "kinds":
|
|
1362
|
+
pruned = gone
|
|
1363
|
+
merged, gone = merger.section("relations", old, document, _OWNED_RELATION, pruned)
|
|
1364
|
+
_put(document, "relations", merged)
|
|
1365
|
+
document.pop("derivations", None)
|
|
1366
|
+
_put(document, "derivations", merger.derivations(old, imported_paths, gone, renamed))
|
|
1367
|
+
merger.drop_stale_warnings(old, document)
|
|
1368
|
+
merger.restore_status(document, old)
|
|
1369
|
+
return Conversion(document, merger.warnings)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
class _Merger:
|
|
1373
|
+
def __init__(self, prune: bool, warnings: list[ConversionWarning]) -> None:
|
|
1374
|
+
self.prune = prune
|
|
1375
|
+
self.warnings = warnings
|
|
1376
|
+
|
|
1377
|
+
def note(self, path: str, message: str) -> None:
|
|
1378
|
+
self.warnings.append(ConversionWarning(path, message))
|
|
1379
|
+
|
|
1380
|
+
def drop_stale_warnings(self, old: Mapping[str, Any], document: Mapping[str, Any]) -> None:
|
|
1381
|
+
"""The import warns that Operations, Derivations and Sources, and the references to them,
|
|
1382
|
+
are not imported; the merge keeps the current ones, so those warnings no longer hold."""
|
|
1383
|
+
kept = {section for section in EXPORT_ONLY_SECTIONS if old.get(section)}
|
|
1384
|
+
reference = re.compile(r"^(?:derivation|authority) .* dropped: (\w+) are not imported$")
|
|
1385
|
+
location = re.compile(r"^classes/(.+)/attributes/(.+)$")
|
|
1386
|
+
|
|
1387
|
+
def survives(warning: ConversionWarning, section: str) -> bool:
|
|
1388
|
+
"""Whether the merged Attribute the warning is about still has its reference."""
|
|
1389
|
+
found = location.match(warning.path)
|
|
1390
|
+
if not found or section not in kept:
|
|
1391
|
+
return False
|
|
1392
|
+
key = "derivation" if section == "derivations" else "authority"
|
|
1393
|
+
owner = pascal_name(found.group(1))
|
|
1394
|
+
for elements in ("kinds", "traits"):
|
|
1395
|
+
element = _mapping(document.get(elements), elements).get(owner)
|
|
1396
|
+
attributes = _mapping(_mapping(element, owner).get("attributes"), owner)
|
|
1397
|
+
attribute = attributes.get(snake_name(found.group(2)))
|
|
1398
|
+
if isinstance(attribute, Mapping) and key in attribute:
|
|
1399
|
+
return True
|
|
1400
|
+
return False
|
|
1401
|
+
|
|
1402
|
+
def stale(warning: ConversionWarning) -> bool:
|
|
1403
|
+
for section in kept:
|
|
1404
|
+
if warning.path == f"annotations/{ANNOTATION_PREFIX}{section}":
|
|
1405
|
+
return True
|
|
1406
|
+
match = reference.match(warning.message)
|
|
1407
|
+
return match is not None and survives(warning, match.group(1))
|
|
1408
|
+
|
|
1409
|
+
self.warnings = [w for w in self.warnings if not stale(w)]
|
|
1410
|
+
|
|
1411
|
+
def derivations(
|
|
1412
|
+
self,
|
|
1413
|
+
old: Mapping[str, Any],
|
|
1414
|
+
imported: Mapping[str, Any],
|
|
1415
|
+
gone_relations: set[str],
|
|
1416
|
+
renamed: Mapping[str, str],
|
|
1417
|
+
) -> dict[str, Any]:
|
|
1418
|
+
"""The current Derivations, with the path ones of the import laid over them.
|
|
1419
|
+
|
|
1420
|
+
Path Derivations match by id, else by name; a name taken by another kind of Derivation
|
|
1421
|
+
is not overwritten. A current path Derivation the LinkML schema leaves out stays with a
|
|
1422
|
+
warning, or goes with `prune`, when an export would have written it; either way its
|
|
1423
|
+
steps follow the Relations the import renamed, and a path through a removed Relation
|
|
1424
|
+
is removed too."""
|
|
1425
|
+
before = _mapping(old.get("derivations"), "derivations")
|
|
1426
|
+
paths = {
|
|
1427
|
+
name: item
|
|
1428
|
+
for name, item in before.items()
|
|
1429
|
+
if isinstance(item, Mapping)
|
|
1430
|
+
and _mapping(item.get("definition"), name).get("variant") == _PATH_MARK
|
|
1431
|
+
}
|
|
1432
|
+
matched = _match(paths, imported)
|
|
1433
|
+
merged = {name: copy.deepcopy(item) for name, item in before.items()}
|
|
1434
|
+
for name, item in imported.items():
|
|
1435
|
+
previous = paths.get(matched.get(name, ""))
|
|
1436
|
+
if previous is None and name in before:
|
|
1437
|
+
self.note(
|
|
1438
|
+
f"derivations/{name}",
|
|
1439
|
+
"skipped: the Domain has another kind of Derivation with this name",
|
|
1440
|
+
)
|
|
1441
|
+
continue
|
|
1442
|
+
if previous is not None:
|
|
1443
|
+
del merged[matched[name]]
|
|
1444
|
+
item = _inherit(previous, item, _OWNED_DERIVATION)
|
|
1445
|
+
merged[name] = item
|
|
1446
|
+
exporter = _Exporter(old, LinkmlOptions()) if paths else None
|
|
1447
|
+
for name in paths:
|
|
1448
|
+
if name in matched.values() or exporter is None:
|
|
1449
|
+
continue
|
|
1450
|
+
if exporter.path_ends(paths[name]) is None:
|
|
1451
|
+
continue
|
|
1452
|
+
path = f"derivations/{name}"
|
|
1453
|
+
if self.prune:
|
|
1454
|
+
del merged[name]
|
|
1455
|
+
self.note(path, "removed: not in the LinkML schema (--prune)")
|
|
1456
|
+
else:
|
|
1457
|
+
self.note(path, "kept: not in the LinkML schema (--prune removes it)")
|
|
1458
|
+
for name, item in list(merged.items()):
|
|
1459
|
+
definition = _mapping(item.get("definition"), name)
|
|
1460
|
+
if definition.get("variant") != _PATH_MARK:
|
|
1461
|
+
continue
|
|
1462
|
+
steps = [step for step in definition.get("steps") or [] if isinstance(step, dict)]
|
|
1463
|
+
if any(step.get("relation") in gone_relations for step in steps):
|
|
1464
|
+
del merged[name]
|
|
1465
|
+
self.note(f"derivations/{name}", "removed: it refers to a pruned Relation")
|
|
1466
|
+
elif name not in imported:
|
|
1467
|
+
for step in steps:
|
|
1468
|
+
step["relation"] = renamed.get(str(step.get("relation")), step.get("relation"))
|
|
1469
|
+
return merged
|
|
1470
|
+
|
|
1471
|
+
def section(
|
|
1472
|
+
self,
|
|
1473
|
+
section: str,
|
|
1474
|
+
old: Mapping[str, Any],
|
|
1475
|
+
document: Mapping[str, Any],
|
|
1476
|
+
owned: frozenset[str],
|
|
1477
|
+
pruned_kinds: set[str],
|
|
1478
|
+
) -> tuple[dict[str, Any], set[str]]:
|
|
1479
|
+
"""The merged elements of a section and the names of current ones that are removed."""
|
|
1480
|
+
before = _mapping(old.get(section), section)
|
|
1481
|
+
merged: dict[str, Any] = {}
|
|
1482
|
+
matched = _match(before, _mapping(document.get(section), section))
|
|
1483
|
+
for name, item in _mapping(document.get(section), section).items():
|
|
1484
|
+
previous = before.get(matched.get(name, ""))
|
|
1485
|
+
if previous is None:
|
|
1486
|
+
merged[name] = item
|
|
1487
|
+
continue
|
|
1488
|
+
merged[name] = self.element(f"{section}/{name}", previous, item, owned)
|
|
1489
|
+
gone: set[str] = set()
|
|
1490
|
+
for name, item in before.items():
|
|
1491
|
+
if name in matched.values():
|
|
1492
|
+
continue
|
|
1493
|
+
path = f"{section}/{name}"
|
|
1494
|
+
ends = {str(item.get("from_kind")), str(item.get("to_kind"))}
|
|
1495
|
+
if self.prune:
|
|
1496
|
+
gone.add(name)
|
|
1497
|
+
self.note(path, "removed: not in the LinkML schema (--prune)")
|
|
1498
|
+
elif ends & pruned_kinds:
|
|
1499
|
+
gone.add(name)
|
|
1500
|
+
self.note(path, "removed: it refers to a pruned Kind")
|
|
1501
|
+
elif name in merged:
|
|
1502
|
+
gone.add(name)
|
|
1503
|
+
self.note(path, "removed: its name is taken by another element")
|
|
1504
|
+
else:
|
|
1505
|
+
merged[name] = copy.deepcopy(item)
|
|
1506
|
+
self.note(path, "kept: not in the LinkML schema (--prune removes it)")
|
|
1507
|
+
return merged, gone
|
|
1508
|
+
|
|
1509
|
+
def element(
|
|
1510
|
+
self, path: str, previous: Mapping[str, Any], item: dict[str, Any], owned: frozenset[str]
|
|
1511
|
+
) -> dict[str, Any]:
|
|
1512
|
+
"""`item` with the id and the fields it does not own taken from `previous`."""
|
|
1513
|
+
result = _inherit(previous, item, owned)
|
|
1514
|
+
if "attributes" in owned and (item.get("attributes") or previous.get("attributes")):
|
|
1515
|
+
attributes = self.attributes(
|
|
1516
|
+
f"{path}/attributes",
|
|
1517
|
+
_mapping(previous.get("attributes"), path),
|
|
1518
|
+
_mapping(item.get("attributes"), path),
|
|
1519
|
+
)
|
|
1520
|
+
_put(result, "attributes", attributes)
|
|
1521
|
+
return result
|
|
1522
|
+
|
|
1523
|
+
def attributes(
|
|
1524
|
+
self, path: str, before: Mapping[str, Any], new: Mapping[str, Any]
|
|
1525
|
+
) -> dict[str, Any]:
|
|
1526
|
+
matched = _match(before, new)
|
|
1527
|
+
merged: dict[str, Any] = {}
|
|
1528
|
+
for name, item in new.items():
|
|
1529
|
+
previous = before.get(matched.get(name, ""))
|
|
1530
|
+
merged[name] = item if previous is None else self.attribute(previous, item)
|
|
1531
|
+
for name, item in before.items():
|
|
1532
|
+
if name in matched.values():
|
|
1533
|
+
continue
|
|
1534
|
+
if self.prune:
|
|
1535
|
+
self.note(f"{path}/{name}", "removed: not in the LinkML schema (--prune)")
|
|
1536
|
+
elif name in merged:
|
|
1537
|
+
self.note(f"{path}/{name}", "removed: its name is taken by another attribute")
|
|
1538
|
+
else:
|
|
1539
|
+
merged[name] = copy.deepcopy(item)
|
|
1540
|
+
self.note(f"{path}/{name}", "kept: not in the LinkML schema (--prune removes it)")
|
|
1541
|
+
return merged
|
|
1542
|
+
|
|
1543
|
+
@staticmethod
|
|
1544
|
+
def attribute(previous: Mapping[str, Any], item: dict[str, Any]) -> dict[str, Any]:
|
|
1545
|
+
result = _inherit(previous, item, _OWNED_ATTRIBUTE)
|
|
1546
|
+
constraints = {
|
|
1547
|
+
key: value
|
|
1548
|
+
for key, value in _mapping(previous.get("constraints"), "constraints").items()
|
|
1549
|
+
if key not in _OWNED_CONSTRAINTS
|
|
1550
|
+
}
|
|
1551
|
+
constraints.update(_mapping(item.get("constraints"), "constraints"))
|
|
1552
|
+
_put(result, "constraints", constraints)
|
|
1553
|
+
return result
|
|
1554
|
+
|
|
1555
|
+
def restore_status(self, document: dict[str, Any], old: Mapping[str, Any]) -> None:
|
|
1556
|
+
"""A Kind the import made a draft for lacking a title attribute or descriptions goes
|
|
1557
|
+
back to its current status when the merge supplied what was missing."""
|
|
1558
|
+
before = _mapping(old.get("kinds"), "kinds")
|
|
1559
|
+
kinds = _mapping(document.get("kinds"), "kinds")
|
|
1560
|
+
matched = _match(before, kinds)
|
|
1561
|
+
for name, kind in kinds.items():
|
|
1562
|
+
previous = before.get(matched.get(name, ""))
|
|
1563
|
+
if previous is None or kind.get("status") != "draft":
|
|
1564
|
+
continue
|
|
1565
|
+
if previous.get("status") in (None, "draft") or not kind.get("title_attribute"):
|
|
1566
|
+
continue
|
|
1567
|
+
if all(a.get("description") for a in (kind.get("attributes") or {}).values()):
|
|
1568
|
+
kind["status"] = previous["status"]
|
|
1569
|
+
self.warnings = [
|
|
1570
|
+
w
|
|
1571
|
+
for w in self.warnings
|
|
1572
|
+
if not (
|
|
1573
|
+
w.message.startswith("imported as draft")
|
|
1574
|
+
and pascal_name(w.path.rsplit("/", 1)[-1]) == name
|
|
1575
|
+
)
|
|
1576
|
+
]
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _match(before: Mapping[str, Any], new: Mapping[str, Any]) -> dict[str, str]:
|
|
1580
|
+
"""New name -> current name of the elements that are the same: same `id`, else same name."""
|
|
1581
|
+
by_id = {
|
|
1582
|
+
str(item["id"]): name
|
|
1583
|
+
for name, item in before.items()
|
|
1584
|
+
if isinstance(item, Mapping) and item.get("id")
|
|
1585
|
+
}
|
|
1586
|
+
matched: dict[str, str] = {}
|
|
1587
|
+
for name, item in new.items():
|
|
1588
|
+
wanted = item.get("id") if isinstance(item, Mapping) else None
|
|
1589
|
+
found = by_id.get(str(wanted)) if wanted else None
|
|
1590
|
+
if found is None and not wanted and name in before:
|
|
1591
|
+
found = name
|
|
1592
|
+
if found is not None and found not in matched.values():
|
|
1593
|
+
matched[name] = found
|
|
1594
|
+
return matched
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _inherit(
|
|
1598
|
+
previous: Mapping[str, Any], item: dict[str, Any], owned: frozenset[str]
|
|
1599
|
+
) -> dict[str, Any]:
|
|
1600
|
+
"""`item`, with the id of `previous` and the fields of `previous` it neither has nor owns."""
|
|
1601
|
+
result = dict(item)
|
|
1602
|
+
if previous.get("id") and not result.get("id"):
|
|
1603
|
+
result = {"id": previous["id"], **result}
|
|
1604
|
+
for key, value in previous.items():
|
|
1605
|
+
if key not in owned and key not in result:
|
|
1606
|
+
result[key] = copy.deepcopy(value)
|
|
1607
|
+
return result
|