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/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