linkml 1.9.4rc1__py3-none-any.whl → 1.9.5rc1__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.
Files changed (77) hide show
  1. linkml/cli/main.py +4 -0
  2. linkml/generators/__init__.py +2 -0
  3. linkml/generators/common/build.py +5 -20
  4. linkml/generators/common/template.py +289 -3
  5. linkml/generators/docgen.py +55 -10
  6. linkml/generators/erdiagramgen.py +9 -5
  7. linkml/generators/graphqlgen.py +32 -6
  8. linkml/generators/jsonldcontextgen.py +78 -12
  9. linkml/generators/jsonschemagen.py +29 -12
  10. linkml/generators/mermaidclassdiagramgen.py +21 -3
  11. linkml/generators/owlgen.py +4 -1
  12. linkml/generators/panderagen/dataframe_class.py +13 -0
  13. linkml/generators/panderagen/dataframe_field.py +50 -0
  14. linkml/generators/panderagen/linkml_pandera_validator.py +186 -0
  15. linkml/generators/panderagen/panderagen.py +22 -5
  16. linkml/generators/panderagen/panderagen_class_based/class.jinja2 +70 -13
  17. linkml/generators/panderagen/panderagen_class_based/custom_checks.jinja2 +27 -0
  18. linkml/generators/panderagen/panderagen_class_based/enums.jinja2 +3 -3
  19. linkml/generators/panderagen/panderagen_class_based/pandera.jinja2 +12 -2
  20. linkml/generators/panderagen/panderagen_class_based/slots.jinja2 +19 -17
  21. linkml/generators/panderagen/slot_generator_mixin.py +143 -16
  22. linkml/generators/panderagen/transforms/__init__.py +19 -0
  23. linkml/generators/panderagen/transforms/collection_dict_model_transform.py +62 -0
  24. linkml/generators/panderagen/transforms/list_dict_model_transform.py +66 -0
  25. linkml/generators/panderagen/transforms/model_transform.py +8 -0
  26. linkml/generators/panderagen/transforms/nested_struct_model_transform.py +27 -0
  27. linkml/generators/panderagen/transforms/simple_dict_model_transform.py +86 -0
  28. linkml/generators/plantumlgen.py +17 -11
  29. linkml/generators/pydanticgen/pydanticgen.py +53 -2
  30. linkml/generators/pydanticgen/template.py +45 -233
  31. linkml/generators/pydanticgen/templates/attribute.py.jinja +1 -0
  32. linkml/generators/pydanticgen/templates/base_model.py.jinja +16 -2
  33. linkml/generators/pydanticgen/templates/imports.py.jinja +1 -1
  34. linkml/generators/rdfgen.py +11 -2
  35. linkml/generators/rustgen/__init__.py +3 -0
  36. linkml/generators/rustgen/build.py +94 -0
  37. linkml/generators/rustgen/cli.py +65 -0
  38. linkml/generators/rustgen/rustgen.py +1038 -0
  39. linkml/generators/rustgen/template.py +865 -0
  40. linkml/generators/rustgen/templates/Cargo.toml.jinja +42 -0
  41. linkml/generators/rustgen/templates/anything.rs.jinja +142 -0
  42. linkml/generators/rustgen/templates/as_key_value.rs.jinja +56 -0
  43. linkml/generators/rustgen/templates/class_module.rs.jinja +8 -0
  44. linkml/generators/rustgen/templates/enum.rs.jinja +54 -0
  45. linkml/generators/rustgen/templates/file.rs.jinja +62 -0
  46. linkml/generators/rustgen/templates/import.rs.jinja +4 -0
  47. linkml/generators/rustgen/templates/imports.rs.jinja +8 -0
  48. linkml/generators/rustgen/templates/poly.rs.jinja +9 -0
  49. linkml/generators/rustgen/templates/poly_containers.rs.jinja +439 -0
  50. linkml/generators/rustgen/templates/poly_trait.rs.jinja +15 -0
  51. linkml/generators/rustgen/templates/poly_trait_impl.rs.jinja +5 -0
  52. linkml/generators/rustgen/templates/poly_trait_impl_orsubtype.rs.jinja +5 -0
  53. linkml/generators/rustgen/templates/poly_trait_property.rs.jinja +8 -0
  54. linkml/generators/rustgen/templates/poly_trait_property_impl.rs.jinja +132 -0
  55. linkml/generators/rustgen/templates/poly_trait_property_match.rs.jinja +10 -0
  56. linkml/generators/rustgen/templates/property.rs.jinja +19 -0
  57. linkml/generators/rustgen/templates/pyproject.toml.jinja +10 -0
  58. linkml/generators/rustgen/templates/serde_utils.rs.jinja +310 -0
  59. linkml/generators/rustgen/templates/slot_range_as_union.rs.jinja +61 -0
  60. linkml/generators/rustgen/templates/struct.rs.jinja +75 -0
  61. linkml/generators/rustgen/templates/struct_or_subtype_enum.rs.jinja +108 -0
  62. linkml/generators/rustgen/templates/typealias.rs.jinja +13 -0
  63. linkml/generators/sqltablegen.py +18 -16
  64. linkml/generators/yarrrmlgen.py +157 -0
  65. linkml/linter/config/datamodel/config.py +160 -293
  66. linkml/linter/config/datamodel/config.yaml +34 -26
  67. linkml/linter/config/default.yaml +4 -0
  68. linkml/linter/config/recommended.yaml +4 -0
  69. linkml/linter/linter.py +1 -2
  70. linkml/linter/rules.py +37 -0
  71. linkml/utils/schemaloader.py +55 -3
  72. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/METADATA +2 -2
  73. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/RECORD +76 -38
  74. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/entry_points.txt +1 -0
  75. linkml/generators/panderagen/panderagen_class_based/mixins.jinja2 +0 -26
  76. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/WHEEL +0 -0
  77. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1038 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from pathlib import Path
4
+ from typing import Literal, Optional, Union, overload
5
+
6
+ from jinja2 import Environment
7
+ from linkml_runtime.linkml_model.meta import (
8
+ ClassDefinition,
9
+ EnumDefinition,
10
+ PermissibleValue,
11
+ SlotDefinition,
12
+ TypeDefinition,
13
+ )
14
+ from linkml_runtime.utils.formatutils import camelcase, uncamelcase, underscore
15
+ from linkml_runtime.utils.schemaview import OrderedBy, SchemaView
16
+
17
+ from linkml.generators.common.lifecycle import LifecycleMixin
18
+ from linkml.generators.common.template import ObjectImport
19
+ from linkml.generators.common.type_designators import get_accepted_type_designator_values
20
+ from linkml.generators.rustgen.build import (
21
+ AttributeResult,
22
+ ClassResult,
23
+ CrateResult,
24
+ EnumResult,
25
+ FileResult,
26
+ SlotResult,
27
+ TypeResult,
28
+ )
29
+ from linkml.generators.rustgen.template import (
30
+ AsKeyValue,
31
+ ContainerType,
32
+ Import,
33
+ Imports,
34
+ PolyContainersFile,
35
+ PolyFile,
36
+ PolyTrait,
37
+ PolyTraitImpl,
38
+ PolyTraitImplForSubtypeEnum,
39
+ PolyTraitProperty,
40
+ PolyTraitPropertyImpl,
41
+ PolyTraitPropertyMatch,
42
+ RustCargo,
43
+ RustClassModule,
44
+ RustEnum,
45
+ RustEnumItem,
46
+ RustFile,
47
+ RustProperty,
48
+ RustPyProject,
49
+ RustRange,
50
+ RustStruct,
51
+ RustStructOrSubtypeEnum,
52
+ RustTemplateModel,
53
+ RustTypeAlias,
54
+ SerdeUtilsFile,
55
+ SlotRangeAsUnion,
56
+ )
57
+ from linkml.utils.generator import Generator
58
+
59
+ RUST_MODES = Literal["crate", "file"]
60
+
61
+ PYTHON_TO_RUST = {
62
+ int: "isize",
63
+ float: "f64",
64
+ str: "String",
65
+ bool: "bool",
66
+ "int": "isize",
67
+ "float": "f64",
68
+ "str": "String",
69
+ "String": "String",
70
+ "bool": "bool",
71
+ "Bool": "bool",
72
+ "XSDDate": "NaiveDate",
73
+ "date": "NaiveDate",
74
+ "XSDDateTime": "NaiveDateTime",
75
+ "datetime": "NaiveDateTime",
76
+ # "Decimal": "dec",
77
+ "Decimal": "f64",
78
+ }
79
+ """
80
+ Mapping from python types to rust types.
81
+
82
+ .. todo::
83
+
84
+ - Add numpy types
85
+ - make an enum wrapper for naivedatetime and datetime<fixedoffset> that can represent both of them
86
+
87
+ """
88
+
89
+ PROTECTED_NAMES = ("type", "typeof", "abstract")
90
+
91
+ RUST_IMPORTS = {
92
+ "dec": Import(module="rust_decimal", version="1.36", objects=[ObjectImport(name="dec")]),
93
+ "NaiveDate": Import(
94
+ module="chrono", features=["serde"], version="0.4.41", objects=[ObjectImport(name="NaiveDate")]
95
+ ),
96
+ "NaiveDateTime": Import(
97
+ module="chrono", features=["serde"], version="0.4.41", objects=[ObjectImport(name="NaiveDateTime")]
98
+ ),
99
+ }
100
+
101
+ MERGE_ANNOTATION = "rust.linkml.io/generate/merge"
102
+
103
+ MERGE_IMPORTS = Imports(
104
+ imports=[Import(module="merge", version="0.2.0", objects=[ObjectImport(name="Merge")])],
105
+ )
106
+
107
+ DEFAULT_IMPORTS = Imports(
108
+ imports=[
109
+ Import(module="std::collections", objects=[ObjectImport(name="HashMap")]),
110
+ # Import(module="std::fmt", objects=[ObjectImport(name="Display")]),
111
+ ]
112
+ )
113
+
114
+ SERDE_IMPORTS = Imports(
115
+ imports=[
116
+ Import(
117
+ module="serde",
118
+ version="1.0",
119
+ features=["derive"],
120
+ objects=[
121
+ ObjectImport(name="Serialize"),
122
+ ObjectImport(name="Deserialize"),
123
+ ObjectImport(name="de::IntoDeserializer"),
124
+ ],
125
+ feature_flag="serde",
126
+ ),
127
+ Import(module="serde-value", version="0.7.0", objects=[ObjectImport(name="Value")]),
128
+ Import(module="serde_yml", version="0.0.12", feature_flag="serde", alias="_"),
129
+ Import(module="serde_path_to_error", version="0.1.17", objects=[], feature_flag="serde"),
130
+ ]
131
+ )
132
+
133
+ PYTHON_IMPORTS = Imports(
134
+ imports=[
135
+ Import(
136
+ module="pyo3",
137
+ version="0.25.0",
138
+ objects=[ObjectImport(name="prelude::*"), ObjectImport(name="FromPyObject")],
139
+ feature_flag="pyo3",
140
+ features=["chrono"],
141
+ ),
142
+ # Import(module="serde_pyobject", version="0.6.1", objects=[], feature_flag="pyo3", features=[]),
143
+ ]
144
+ )
145
+
146
+
147
+ class SlotContainerMode(Enum):
148
+ SINGLE_VALUE = "single_value"
149
+ MAPPING = "mapping"
150
+ LIST = "list"
151
+
152
+
153
+ class SlotInlineMode(Enum):
154
+ INLINE = "inline"
155
+ PRIMITIVE = "primitive"
156
+ REFERENCE = "reference"
157
+
158
+
159
+ def get_key_or_identifier_slot(cls: ClassDefinition, sv: SchemaView) -> Optional[SlotDefinition]:
160
+ induced_slots = sv.class_induced_slots(cls.name)
161
+ for slot in induced_slots:
162
+ if slot.identifier or slot.key:
163
+ return slot
164
+ return None
165
+
166
+
167
+ def get_identifier_slot(cls: ClassDefinition, sv: SchemaView) -> Optional[SlotDefinition]:
168
+ induced_slots = sv.class_induced_slots(cls.name)
169
+ for slot in induced_slots:
170
+ if slot.identifier:
171
+ return slot
172
+ return None
173
+
174
+
175
+ def class_real_descendants(sv: SchemaView, class_name: str) -> list[str]:
176
+ """Return true descendants of a class, excluding the class itself.
177
+
178
+ Some SchemaView implementations include the class in `class_descendants`.
179
+ We normalize here to avoid off-by-one errors when deciding if a class has
180
+ subtypes (for OrSubtype generation and trait typing decisions).
181
+ """
182
+ try:
183
+ descs = list(sv.class_descendants(class_name))
184
+ except Exception:
185
+ descs = []
186
+ return [d for d in descs if d != class_name]
187
+
188
+
189
+ def has_real_subtypes(sv: SchemaView, class_name: str) -> bool:
190
+ """True when the class has at least one real subtype (excluding itself)."""
191
+ return len(class_real_descendants(sv, class_name)) > 0
192
+
193
+
194
+ def determine_slot_mode(s: SlotDefinition, sv: SchemaView) -> tuple[SlotContainerMode, SlotInlineMode]:
195
+ """Return container and inline modes for a slot."""
196
+
197
+ class_range = s.range in sv.all_classes()
198
+ if not class_range:
199
+ return (
200
+ SlotContainerMode.LIST if s.multivalued else SlotContainerMode.SINGLE_VALUE,
201
+ SlotInlineMode.PRIMITIVE,
202
+ )
203
+
204
+ if s.multivalued and s.inlined_as_list:
205
+ return (SlotContainerMode.LIST, SlotInlineMode.INLINE)
206
+
207
+ key_slot = get_key_or_identifier_slot(sv.get_class(s.range), sv)
208
+ identifier_slot = get_identifier_slot(sv.get_class(s.range), sv)
209
+ inlined = s.inlined
210
+ if identifier_slot is None:
211
+ # can only inline if identifier slot is none
212
+ inlined = True
213
+
214
+ if not s.multivalued:
215
+ return (
216
+ SlotContainerMode.SINGLE_VALUE,
217
+ SlotInlineMode.INLINE if inlined else SlotInlineMode.REFERENCE,
218
+ )
219
+
220
+ if not inlined:
221
+ return (SlotContainerMode.LIST, SlotInlineMode.REFERENCE)
222
+
223
+ if key_slot is not None:
224
+ return (SlotContainerMode.MAPPING, SlotInlineMode.INLINE)
225
+ else:
226
+ return (SlotContainerMode.LIST, SlotInlineMode.INLINE)
227
+
228
+
229
+ def can_contain_reference_to_class(s: SlotDefinition, cls: ClassDefinition, sv: SchemaView) -> bool:
230
+ ref_name = cls.name
231
+ seen_classes = set()
232
+ classes_to_check = [s.range]
233
+ while len(classes_to_check) > 0:
234
+ a_class = classes_to_check.pop()
235
+ seen_classes.add(a_class)
236
+ if a_class not in sv.all_classes():
237
+ continue
238
+ if a_class == ref_name:
239
+ return True
240
+ induced_class = sv.induced_class(a_class)
241
+ for attr in induced_class.attributes.values():
242
+ if attr.range not in seen_classes:
243
+ classes_to_check.append(attr.range)
244
+ return False
245
+
246
+
247
+ def get_rust_type(
248
+ t: Union[TypeDefinition, type, str], sv: SchemaView, pyo3: bool = False, crate_ref: Optional[str] = None
249
+ ) -> str:
250
+ """
251
+ Get the rust type from a given linkml type
252
+ """
253
+ rsrange = None
254
+ no_add_crate = False
255
+
256
+ if isinstance(t, TypeDefinition):
257
+ rsrange = t.base
258
+ if rsrange is not None and rsrange not in PYTHON_TO_RUST:
259
+ # A type like URIorCURIE which is an alias for a rust type
260
+ rsrange = get_name(t)
261
+
262
+ elif rsrange is None and t.typeof is not None:
263
+ # A type with no base type,
264
+ no_add_crate = True
265
+ rsrange = get_rust_type(sv.get_type(t.typeof), sv, pyo3)
266
+
267
+ elif isinstance(t, str):
268
+ if tdef := sv.all_types().get(t, None):
269
+ rsrange = get_rust_type(tdef, sv, pyo3)
270
+ no_add_crate = True
271
+ elif t in sv.all_enums():
272
+ # Map LinkML enums to generated Rust enums rather than collapsing to String
273
+ e = sv.get_enum(t)
274
+ rsrange = get_name(e)
275
+ no_add_crate = True
276
+ elif t in sv.all_classes():
277
+ c = sv.get_class(t)
278
+ rsrange = get_name(c)
279
+
280
+ # FIXME: Raise here once we have implemented all base types
281
+ if rsrange is None:
282
+ rsrange = PYTHON_TO_RUST[str]
283
+ elif rsrange in PYTHON_TO_RUST:
284
+ rsrange = PYTHON_TO_RUST[rsrange]
285
+ elif crate_ref is not None and not no_add_crate:
286
+ rsrange = f"{crate_ref}::{rsrange}"
287
+ return rsrange
288
+
289
+
290
+ def get_rust_range_info(
291
+ cls: ClassDefinition, s: SlotDefinition, sv: SchemaView, crate_ref: Optional[str] = None
292
+ ) -> RustRange:
293
+ (container_mode, inline_mode) = determine_slot_mode(s, sv)
294
+ all_ranges = sv.slot_range_as_union(s)
295
+ sub_ranges = [
296
+ RustRange(
297
+ type_="String" if inline_mode == SlotInlineMode.REFERENCE else get_rust_type(r, sv, True, crate_ref),
298
+ is_class_range=r in sv.all_classes(),
299
+ has_class_subtypes=has_real_subtypes(sv, r) if r in sv.all_classes() else False,
300
+ )
301
+ for r in all_ranges
302
+ ]
303
+
304
+ res = RustRange(
305
+ optional=not s.required,
306
+ has_default=not (s.required or False) or (s.multivalued or False),
307
+ containerType=(
308
+ ContainerType.LIST
309
+ if container_mode == SlotContainerMode.LIST
310
+ else ContainerType.MAPPING
311
+ if container_mode == SlotContainerMode.MAPPING
312
+ else None
313
+ ),
314
+ child_ranges=sub_ranges if len(sub_ranges) > 1 else None,
315
+ box_needed=inline_mode == SlotInlineMode.INLINE and can_contain_reference_to_class(s, cls, sv),
316
+ is_class_range=all_ranges[0] in sv.all_classes() if len(all_ranges) == 1 else False,
317
+ is_reference=inline_mode == SlotInlineMode.REFERENCE,
318
+ has_class_subtypes=(
319
+ has_real_subtypes(sv, all_ranges[0])
320
+ if (len(all_ranges) == 1 and all_ranges[0] in sv.all_classes())
321
+ else False
322
+ ),
323
+ type_=(
324
+ underscore(uncamelcase(cls.name)) + "_utl::" + get_name(s) + "_range"
325
+ if len(sub_ranges) > 1
326
+ else ("String" if inline_mode == SlotInlineMode.REFERENCE else get_rust_type(s.range, sv, True, crate_ref))
327
+ ),
328
+ )
329
+ return res
330
+
331
+
332
+ def protect_name(v: str) -> str:
333
+ """
334
+ append an underscore to a protected name
335
+ """
336
+ if v in PROTECTED_NAMES:
337
+ v = f"{v}_"
338
+ return v
339
+
340
+
341
+ def get_name(e: Union[ClassDefinition, SlotDefinition, EnumDefinition, PermissibleValue, TypeDefinition]) -> str:
342
+ if isinstance(e, (ClassDefinition, EnumDefinition)):
343
+ name = camelcase(e.name)
344
+ elif isinstance(e, PermissibleValue):
345
+ name = camelcase(e.text)
346
+ elif isinstance(e, (SlotDefinition, TypeDefinition)):
347
+ name = underscore(e.name)
348
+ else:
349
+ raise ValueError("Can only get the name from a slot or class!")
350
+
351
+ name = protect_name(name)
352
+ return name
353
+
354
+
355
+ @dataclass
356
+ class RustGenerator(Generator, LifecycleMixin):
357
+ """
358
+ Generate rust types from a linkml schema
359
+ """
360
+
361
+ generatorname = "rustgenerator"
362
+ generatorversion = "0.0.2"
363
+ valid_formats = ["rust"]
364
+ file_extension = "rs"
365
+ crate_name: Optional[str] = None
366
+
367
+ pyo3: bool = True
368
+ """Generate pyO3 bindings for the rust defs"""
369
+ pyo3_version: str = ">=0.21.1"
370
+ serde: bool = True
371
+ """Generate serde derive serialization/deserialization attributes"""
372
+ mode: RUST_MODES = "crate"
373
+ """Generate a cargo.toml file"""
374
+ output: Optional[Path] = None
375
+ """
376
+ * If ``mode == "crate"`` , a directory to contain the generated crate
377
+ * If ``mode == "file"`` , a file with a ``.rs`` extension
378
+
379
+ If output is not provided at object instantiation,
380
+ it must be provided on a call to :meth:`.serialize`
381
+ """
382
+
383
+ _environment: Optional[Environment] = None
384
+
385
+ def __post_init__(self):
386
+ self.schemaview: SchemaView = SchemaView(self.schema)
387
+ super().__post_init__()
388
+
389
+ def generate_type(self, type_: TypeDefinition) -> TypeResult:
390
+ type_ = self.before_generate_type(type_, self.schemaview)
391
+ res = TypeResult(
392
+ source=type_,
393
+ type_=RustTypeAlias(
394
+ name=get_name(type_), type_=get_rust_type(type_.base, self.schemaview, self.pyo3), pyo3=self.pyo3
395
+ ),
396
+ imports=self.get_imports(type_),
397
+ )
398
+ slot = self.after_generate_type(res, self.schemaview)
399
+ return slot
400
+
401
+ def generate_enum(self, enum: EnumDefinition) -> EnumResult:
402
+ enum = self.before_generate_enum(enum, self.schemaview)
403
+ items = [
404
+ RustEnumItem(
405
+ variant=get_name(pv),
406
+ text=pv.text or name,
407
+ )
408
+ for name, pv in enum.permissible_values.items()
409
+ ]
410
+ res = EnumResult(
411
+ source=enum,
412
+ enum=RustEnum(
413
+ name=get_name(enum),
414
+ items=items,
415
+ pyo3=self.pyo3,
416
+ serde=self.serde,
417
+ ),
418
+ )
419
+ res = self.after_generate_enum(res, self.schemaview)
420
+ return res
421
+
422
+ def generate_slot(self, slot: SlotDefinition) -> SlotResult:
423
+ """
424
+ Generate a slot as a struct field
425
+ """
426
+ slot = self.before_generate_slot(slot, self.schemaview)
427
+ class_range = slot.range in self.schemaview.all_classes()
428
+ type_ = get_rust_type(slot.range, self.schemaview, self.pyo3)
429
+
430
+ slot = SlotResult(
431
+ source=slot,
432
+ slot=RustTypeAlias(
433
+ name=get_name(slot),
434
+ type_=type_,
435
+ multivalued=slot.multivalued,
436
+ pyo3=self.pyo3,
437
+ class_range=class_range,
438
+ ),
439
+ imports=self.get_imports(slot),
440
+ )
441
+ slot = self.after_generate_slot(slot, self.schemaview)
442
+ return slot
443
+
444
+ def generate_class(self, cls: ClassDefinition) -> ClassResult:
445
+ """
446
+ Generate a class as a struct!
447
+ """
448
+ cls = self.before_generate_class(cls, self.schemaview)
449
+ induced_attrs = [self.schemaview.induced_slot(sn, cls.name) for sn in self.schemaview.class_slots(cls.name)]
450
+ induced_attrs = self.before_generate_slots(induced_attrs, self.schemaview)
451
+ slot_range_unions = []
452
+ for a in induced_attrs:
453
+ # Promote union across descendants for canonical union enum in base module
454
+ ranges = []
455
+ for r in self.schemaview.slot_range_as_union(a):
456
+ ranges.append(r)
457
+ for d in self.schemaview.class_descendants(cls.name):
458
+ sdesc = self.schemaview.induced_slot(a.name, d)
459
+ if sdesc is None:
460
+ continue
461
+ for r in self.schemaview.slot_range_as_union(sdesc):
462
+ if r not in ranges:
463
+ ranges.append(r)
464
+ if len(ranges) > 1:
465
+ slot_range_unions.append(
466
+ SlotRangeAsUnion(
467
+ slot_name=get_name(a),
468
+ ranges=[get_rust_type(r, self.schemaview, True) for r in ranges],
469
+ )
470
+ )
471
+
472
+ cls_mod = RustClassModule(
473
+ class_name=get_name(cls),
474
+ class_name_snakecase=underscore(uncamelcase(cls.name)),
475
+ slot_ranges=slot_range_unions,
476
+ )
477
+
478
+ attributes = [self.generate_attribute(attr, cls) for attr in induced_attrs]
479
+ attributes = self.after_generate_slots(attributes, self.schemaview)
480
+
481
+ unsendable = any([a.range in self.schemaview.all_classes() for a in induced_attrs])
482
+ res = ClassResult(
483
+ source=cls,
484
+ cls=RustStruct(
485
+ name=get_name(cls),
486
+ properties=[a.attribute for a in attributes],
487
+ special_case_enabled=self.schemaview.get_uri(cls, expand=True).startswith("https://w3id.org/linkml"),
488
+ generate_merge=MERGE_ANNOTATION in cls.annotations,
489
+ unsendable=unsendable,
490
+ pyo3=self.pyo3,
491
+ serde=self.serde,
492
+ as_key_value=self.generate_class_as_key_value(cls),
493
+ struct_or_subtype_enum=self.gen_struct_or_subtype_enum(cls),
494
+ class_module=cls_mod,
495
+ ),
496
+ )
497
+ # merge imports
498
+ for attr in attributes:
499
+ res = res.merge(attr)
500
+
501
+ res = self.after_generate_class(res, self.schemaview)
502
+ return res
503
+
504
+ def gen_struct_or_subtype_enum(self, cls: ClassDefinition) -> Optional[RustStructOrSubtypeEnum]:
505
+ descendants = class_real_descendants(self.schemaview, cls.name)
506
+ td = self.schemaview.get_type_designator_slot(cls.name)
507
+ td_mapping = {}
508
+ if td is not None:
509
+ for d in descendants:
510
+ d_class = self.schemaview.get_class(d)
511
+ values = get_accepted_type_designator_values(self.schemaview, td, d_class)
512
+ td_mapping[d] = values
513
+ if len(descendants) > 0:
514
+ return RustStructOrSubtypeEnum(
515
+ enum_name=get_name(cls) + "OrSubtype",
516
+ struct_names=[get_name(self.schemaview.get_class(d)) for d in descendants],
517
+ type_designator_name=get_name(td) if td else None,
518
+ as_key_value=get_key_or_identifier_slot(cls, self.schemaview) is not None,
519
+ type_designators=td_mapping,
520
+ )
521
+ return None
522
+
523
+ def generate_class_as_key_value(self, cls: ClassDefinition) -> Optional[AsKeyValue]:
524
+ induced_attrs = [self.schemaview.induced_slot(sn, cls.name) for sn in self.schemaview.class_slots(cls.name)]
525
+ key_attr = None
526
+ value_attrs = []
527
+ value_args_no_default = []
528
+
529
+ for attr in induced_attrs:
530
+ if attr.identifier:
531
+ if key_attr is not None:
532
+ ## multiple identifiers --> don't know what to do!
533
+ return None
534
+ key_attr = attr
535
+ elif attr.key:
536
+ if key_attr is not None:
537
+ ## multiple keys --> don't know what to do!
538
+ return None
539
+ key_attr = attr
540
+ else:
541
+ if not attr.multivalued:
542
+ value_attrs.append(attr)
543
+ if attr.required:
544
+ value_args_no_default.append(attr)
545
+ if key_attr is not None:
546
+ # If there is a key/identifier but no single-valued non-multivalued
547
+ # attribute to serve as the value, do not treat this as a key/value class.
548
+ if len(value_attrs) == 0:
549
+ return None
550
+ return AsKeyValue(
551
+ name=get_name(cls),
552
+ key_property_name=get_name(key_attr),
553
+ key_property_type=get_rust_type(key_attr.range, self.schemaview, self.pyo3),
554
+ value_property_name=get_name(value_attrs[0]),
555
+ value_property_type=get_rust_type(value_attrs[0].range, self.schemaview, self.pyo3),
556
+ can_convert_from_primitive=len(value_args_no_default) <= 1,
557
+ can_convert_from_empty=len(value_args_no_default) == 0,
558
+ serde=self.serde,
559
+ pyo3=self.pyo3,
560
+ )
561
+ return None
562
+
563
+ def generate_attribute(self, attr: SlotDefinition, cls: ClassDefinition) -> AttributeResult:
564
+ """
565
+ Generate an attribute as a struct property
566
+ """
567
+ attr = self.before_generate_slot(attr, self.schemaview)
568
+ is_class_range = attr.range in self.schemaview.all_classes()
569
+ (container_mode, inline_mode) = determine_slot_mode(attr, self.schemaview)
570
+ range = get_rust_range_info(cls, attr, self.schemaview)
571
+ res = AttributeResult(
572
+ source=attr,
573
+ attribute=RustProperty(
574
+ name=get_name(attr),
575
+ inline_mode=inline_mode.value,
576
+ alias=attr.alias if attr.alias is not None and attr.alias != get_name(attr) else None,
577
+ generate_merge=MERGE_ANNOTATION in cls.annotations,
578
+ container_mode=container_mode.value,
579
+ type_=range,
580
+ required=bool(attr.required),
581
+ multivalued=True if attr.multivalued else False,
582
+ is_key_value=is_class_range
583
+ and self.generate_class_as_key_value(self.schemaview.get_class(attr.range)) is not None,
584
+ pyo3=self.pyo3,
585
+ serde=self.serde,
586
+ ),
587
+ imports=self.get_imports(attr),
588
+ )
589
+
590
+ res = self.after_generate_slot(res, self.schemaview)
591
+ return res
592
+
593
+ def generate_cargo(self, imports: Imports) -> RustCargo:
594
+ """
595
+ Generate a Cargo.toml file
596
+ """
597
+ version = self.schemaview.schema.version if self.schemaview.schema.version is not None else "0.0.0"
598
+ return RustCargo(
599
+ name=self.crate_name if self.crate_name is not None else self.schemaview.schema.name,
600
+ version=version,
601
+ imports=imports,
602
+ pyo3_version=self.pyo3_version,
603
+ pyo3=self.pyo3,
604
+ serde=self.serde,
605
+ )
606
+
607
+ def generate_pyproject(self) -> RustPyProject:
608
+ """
609
+ Generate a pyproject.toml file for a pyo3 rust crate
610
+ """
611
+ version = self.schemaview.schema.version if self.schemaview.schema.version is not None else "0.0.0"
612
+ return RustPyProject(name=self.schemaview.schema.name, version=version)
613
+
614
+ def get_imports(self, element: Union[SlotDefinition, TypeDefinition]) -> Imports:
615
+ if isinstance(element, SlotDefinition):
616
+ type_ = get_rust_type(element.range, self.schemaview, self.pyo3)
617
+ elif isinstance(element, TypeDefinition):
618
+ type_ = get_rust_type(element.base, self.schemaview, self.pyo3)
619
+ else:
620
+ raise TypeError("Must be a slot or type definition")
621
+
622
+ if type_ in RUST_IMPORTS:
623
+ return Imports(imports=[RUST_IMPORTS[type_]])
624
+ else:
625
+ return Imports()
626
+
627
+ @overload
628
+ def render(self, mode: Literal["file"] = "file") -> FileResult: ...
629
+
630
+ @overload
631
+ def render(self, mode: Literal["crate"] = "crate") -> CrateResult: ...
632
+
633
+ def render(self, mode: Optional[RUST_MODES] = None) -> Union[FileResult, CrateResult]:
634
+ """
635
+ Render the template model of a rust file before serializing
636
+
637
+ Args:
638
+ mode (:class:`.RUST_MODES`, optional): Override the instance-level generation mode
639
+ """
640
+ if mode is None:
641
+ mode = self.mode
642
+
643
+ sv = self.schemaview
644
+
645
+ types = list(sv.all_types(imports=True).values())
646
+ types = self.before_generate_types(types, sv)
647
+ types = [self.generate_type(t) for t in types]
648
+ types = self.after_generate_types(types, sv)
649
+
650
+ enums = list(sv.all_enums(imports=True).values())
651
+ enums = self.before_generate_enums(enums, sv)
652
+ enums = [self.generate_enum(e) for e in enums]
653
+ enums = self.after_generate_enums(enums, sv)
654
+
655
+ slots = list(sv.induced_slot(s) for s in sv.all_slots())
656
+ slots = self.before_generate_slots(slots, sv)
657
+ slots = [self.generate_slot(s) for s in slots]
658
+ slots = self.after_generate_slots(slots, sv)
659
+
660
+ need_merge_crate = False
661
+ classes = list(sv.induced_class(c) for c in sv.all_classes(ordered_by=OrderedBy.INHERITANCE))
662
+ for c in classes:
663
+ if MERGE_ANNOTATION in c.annotations:
664
+ need_merge_crate = True
665
+ break
666
+
667
+ classes = self.before_generate_classes(classes, sv)
668
+ classes = [self.generate_class(c) for c in classes]
669
+ classes = self.after_generate_classes(classes, sv)
670
+
671
+ poly_traits = [self.gen_poly_trait(sv.get_class(c)) for c in sv.all_classes(ordered_by=OrderedBy.INHERITANCE)]
672
+
673
+ imports = DEFAULT_IMPORTS.model_copy()
674
+ imports += PYTHON_IMPORTS
675
+ imports += SERDE_IMPORTS
676
+ if need_merge_crate:
677
+ imports += MERGE_IMPORTS
678
+ for result in [*enums, *slots, *classes]:
679
+ imports += result.imports
680
+
681
+ file = RustFile(
682
+ name=sv.schema.name,
683
+ imports=imports,
684
+ slots=[t.slot for t in slots],
685
+ types=[t.type_ for t in types],
686
+ enums=[e.enum for e in enums],
687
+ structs=[c.cls for c in classes],
688
+ pyo3=self.pyo3,
689
+ serde=self.serde,
690
+ )
691
+
692
+ if mode == "crate":
693
+ extra_files = {}
694
+ extra_files["serde_utils"] = SerdeUtilsFile()
695
+ extra_files["poly"] = PolyFile(imports=imports, traits=poly_traits)
696
+ extra_files["poly_containers"] = PolyContainersFile()
697
+ cargo = self.generate_cargo(imports)
698
+ pyproject = self.generate_pyproject()
699
+ res = CrateResult(cargo=cargo, file=file, pyproject=pyproject, source=sv.schema, extra_files=extra_files)
700
+ return res
701
+ else:
702
+ # Single file: inline serde utils, and skip poly modules
703
+ file.inline_serde_utils = True
704
+ file.emit_poly = False
705
+ file.serde_utils = SerdeUtilsFile()
706
+ res = FileResult(file=file, source=sv.schema)
707
+ return res
708
+
709
+ def gen_poly_trait(self, cls: ClassDefinition) -> PolyTrait:
710
+ impls = []
711
+ class_name = get_name(cls)
712
+ attribs = self.schemaview.class_induced_slots(cls.name)
713
+ superclass_names = []
714
+ if cls.is_a is not None:
715
+ superclass_names.append(cls.is_a)
716
+ for m in cls.mixins:
717
+ superclass_names.append(m)
718
+
719
+ superclasses = [self.schemaview.get_class(sn) for sn in superclass_names if sn is not None]
720
+ for superclass in superclasses:
721
+ attribs_sc = self.schemaview.class_induced_slots(superclass.name)
722
+ attribs = [a for a in attribs if a.name not in [sc.name for sc in attribs_sc]]
723
+
724
+ rust_attribs = []
725
+ for a in attribs:
726
+ n = get_name(a)
727
+ base_ri = get_rust_range_info(cls, a, self.schemaview)
728
+ promoted_ri = self.get_rust_range_info_across_descendants(cls, a)
729
+ rust_attribs.append(PolyTraitProperty(name=n, range=base_ri, promoted_range=promoted_ri))
730
+
731
+ subtype_impls = []
732
+ for sc in self.schemaview.class_descendants(cls.name):
733
+ sco = self.schemaview.get_class(sc)
734
+ induced_slots = self.schemaview.class_induced_slots(sco.name)
735
+
736
+ def find_slot(n: str):
737
+ for s in induced_slots:
738
+ if s.name == n:
739
+ return s
740
+ return None
741
+
742
+ ptis = [
743
+ PolyTraitPropertyImpl(
744
+ name=get_name(a),
745
+ range=get_rust_range_info(sco, find_slot(a.name), self.schemaview),
746
+ definition_range=self.get_rust_range_info_across_descendants(cls, a),
747
+ trait_range=self.get_rust_range_info_across_descendants(cls, a),
748
+ struct_name=get_name(sco),
749
+ )
750
+ for a in attribs
751
+ ]
752
+ impls.append(PolyTraitImpl(name=class_name, struct_name=get_name(sco), attrs=ptis))
753
+ has_subtypes = has_real_subtypes(self.schemaview, sc)
754
+ if has_subtypes:
755
+ cases = [get_name(self.schemaview.get_class(x)) for x in class_real_descendants(self.schemaview, sc)]
756
+ matches = [
757
+ PolyTraitPropertyMatch(
758
+ name=get_name(a),
759
+ range=self.get_rust_range_info_across_descendants(cls, a),
760
+ cases=cases,
761
+ struct_name=f"{get_name(sco)}OrSubtype",
762
+ )
763
+ for a in attribs
764
+ ]
765
+ subtype_impls.append(
766
+ PolyTraitImplForSubtypeEnum(name=class_name, enum_name=f"{get_name(sco)}OrSubtype", attrs=matches)
767
+ )
768
+ return PolyTrait(
769
+ name=class_name,
770
+ impls=impls,
771
+ attrs=rust_attribs,
772
+ superclass_names=[get_name(scla) for scla in superclasses],
773
+ subtypes=subtype_impls,
774
+ )
775
+
776
+ def serialize(self, output: Optional[Path] = None, mode: Optional[RUST_MODES] = None, force: bool = False) -> str:
777
+ """
778
+ Serialize a schema to a rust crate or file.
779
+
780
+ Args:
781
+ output (Path, optional): A ``.rs`` file if in ``file`` mode,
782
+ directory otherwise.
783
+ force (bool): If the output already exists, overwrite it.
784
+ Otherwise raise a :class:`FileExistsError`
785
+ """
786
+ if mode is None:
787
+ mode = self.mode
788
+
789
+ output = self._validate_output(output, mode, force)
790
+ rendered = self.render(mode=mode)
791
+ if mode == "crate":
792
+ serialized = self.write_crate(output, rendered, force)
793
+ else:
794
+ serialized = rendered.file.render(self.template_environment)
795
+ serialized = serialized.rstrip("\n") + "\n"
796
+ with open(output, "w") as f:
797
+ f.write(serialized)
798
+
799
+ return serialized
800
+
801
+ def get_rust_range_info_across_descendants(self, cls: ClassDefinition, s: SlotDefinition) -> RustRange:
802
+ """Compute a RustRange representing the union of a slot's ranges across a class and all its descendants.
803
+
804
+ Container and optionality are taken from the base class slot.
805
+ """
806
+ sv = self.schemaview
807
+ # Collect rust type names for all ranges across base + descendants, and remember
808
+ # the source class name (if any) responsible for each rust type so we can
809
+ # correctly determine subtype presence against the metamodel (using class names,
810
+ # not rust type identifiers).
811
+ type_names: list[str] = []
812
+ rust_to_class: dict[str, Optional[str]] = {}
813
+
814
+ def add_for_slot(slot_def: SlotDefinition):
815
+ for r in sv.slot_range_as_union(slot_def):
816
+ if r in sv.all_classes():
817
+ # Special-case: treat Anything/AnyValue as inline to ensure
818
+ # promoted unions include the corresponding variant.
819
+ if r in {"Anything", "AnyValue"}:
820
+ tname = get_rust_type(r, sv, True)
821
+ rust_to_class[tname] = r
822
+ if tname not in type_names:
823
+ type_names.append(tname)
824
+ continue
825
+ # Prefer concrete observations: only add String if explicitly non-inlined
826
+ inl = slot_def.inlined
827
+ inl_list = slot_def.inlined_as_list
828
+ if inl is True or inl_list is True:
829
+ tname = get_rust_type(r, sv, True)
830
+ rust_to_class[tname] = r
831
+ elif inl is False and (inl_list is False or inl_list is None):
832
+ tname = "String"
833
+ rust_to_class[tname] = None
834
+ else:
835
+ # Unknown inlining at this definition; skip adding a guess
836
+ continue
837
+ else:
838
+ tname = get_rust_type(r, sv, True)
839
+ rust_to_class[tname] = None
840
+ if tname not in type_names:
841
+ type_names.append(tname)
842
+
843
+ base_slot = sv.induced_slot(s.name, cls.name)
844
+ if base_slot is not None:
845
+ add_for_slot(base_slot)
846
+
847
+ # Include descendants in the class inheritance tree
848
+ for d in sv.class_descendants(cls.name):
849
+ ds = sv.induced_slot(s.name, d)
850
+ if ds is not None:
851
+ add_for_slot(ds)
852
+
853
+ # If this is a mixin, include classes that use the mixin and their descendants
854
+ try:
855
+ all_classes = list(sv.all_classes())
856
+ except Exception:
857
+ all_classes = []
858
+ for cname in all_classes:
859
+ cdef = sv.get_class(cname)
860
+ if cdef is None:
861
+ continue
862
+ if cls.name in (cdef.mixins or []):
863
+ ds = sv.induced_slot(s.name, cname)
864
+ if ds is not None:
865
+ add_for_slot(ds)
866
+ for dd in sv.class_descendants(cname):
867
+ dslot = sv.induced_slot(s.name, dd)
868
+ if dslot is not None:
869
+ add_for_slot(dslot)
870
+
871
+ container_mode, _ = determine_slot_mode(s, sv)
872
+ # Optionality across descendants/mixin users: optional if not all are required
873
+ all_required = True
874
+
875
+ def consider_required(slot_def: SlotDefinition):
876
+ nonlocal all_required
877
+ if not bool(slot_def.required):
878
+ all_required = False
879
+
880
+ if base_slot is not None:
881
+ consider_required(base_slot)
882
+ for d in sv.class_descendants(cls.name):
883
+ ds = sv.induced_slot(s.name, d)
884
+ if ds is not None:
885
+ consider_required(ds)
886
+ try:
887
+ all_classes = list(sv.all_classes())
888
+ except Exception:
889
+ all_classes = []
890
+ for cname in all_classes:
891
+ cdef = sv.get_class(cname)
892
+ if cdef is None:
893
+ continue
894
+ if cls.name in (cdef.mixins or []):
895
+ ds = sv.induced_slot(s.name, cname)
896
+ if ds is not None:
897
+ consider_required(ds)
898
+ for dd in sv.class_descendants(cname):
899
+ dslot = sv.induced_slot(s.name, dd)
900
+ if dslot is not None:
901
+ consider_required(dslot)
902
+ base_optional = not all_required
903
+
904
+ if len(type_names) > 1:
905
+ child_ranges = [
906
+ RustRange(
907
+ type_=t,
908
+ is_class_range=t not in ("String", "bool", "f64", "isize"),
909
+ )
910
+ for t in type_names
911
+ ]
912
+ return RustRange(
913
+ optional=base_optional,
914
+ has_default=base_optional or (s.multivalued or False),
915
+ containerType=(
916
+ ContainerType.LIST
917
+ if container_mode == SlotContainerMode.LIST
918
+ else ContainerType.MAPPING
919
+ if container_mode == SlotContainerMode.MAPPING
920
+ else None
921
+ ),
922
+ child_ranges=child_ranges,
923
+ is_class_range=False,
924
+ is_reference=False,
925
+ type_=underscore(uncamelcase(cls.name)) + "_utl::" + get_name(s) + "_range",
926
+ )
927
+ else:
928
+ # Fall back to base definition only if nothing was observed concretely
929
+ if len(type_names) == 0 and base_slot is not None:
930
+ for r in sv.slot_range_as_union(base_slot):
931
+ if r in sv.all_classes():
932
+ inl = base_slot.inlined
933
+ inl_list = base_slot.inlined_as_list
934
+ if inl is True or inl_list is True:
935
+ tname = get_rust_type(r, sv, True)
936
+ if tname not in type_names:
937
+ type_names.append(tname)
938
+ rust_to_class[tname] = r
939
+ else:
940
+ tname = get_rust_type(r, sv, True)
941
+ if tname not in type_names:
942
+ type_names.append(tname)
943
+ rust_to_class[tname] = None
944
+ # If still empty, fall back to original per-class range info
945
+ if len(type_names) == 0:
946
+ return get_rust_range_info(cls, s, sv)
947
+ single = type_names[0]
948
+ single_src_class = rust_to_class.get(single, None)
949
+ return RustRange(
950
+ optional=base_optional,
951
+ has_default=base_optional or (s.multivalued or False),
952
+ containerType=(
953
+ ContainerType.LIST
954
+ if container_mode == SlotContainerMode.LIST
955
+ else ContainerType.MAPPING
956
+ if container_mode == SlotContainerMode.MAPPING
957
+ else None
958
+ ),
959
+ child_ranges=None,
960
+ is_class_range=single not in ("String", "bool", "f64", "isize"),
961
+ is_reference=False,
962
+ has_class_subtypes=(
963
+ has_real_subtypes(self.schemaview, single_src_class) if single_src_class is not None else False
964
+ ),
965
+ type_=single,
966
+ )
967
+
968
+ def write_crate(
969
+ self, output: Optional[Path] = None, rendered: Union[FileResult, CrateResult] = None, force: bool = False
970
+ ) -> str:
971
+ output = self._validate_output(output, mode="crate", force=force)
972
+ if rendered is None:
973
+ rendered = self.render(mode="crate")
974
+
975
+ cargo = rendered.cargo.render(self.template_environment)
976
+ # Normalize EOF: exactly one trailing newline
977
+ cargo = cargo.rstrip("\n") + "\n"
978
+ cargo_file = output / "Cargo.toml"
979
+ with open(cargo_file, "w") as cfile:
980
+ cfile.write(cargo)
981
+
982
+ pyproject = rendered.pyproject.render(self.template_environment)
983
+ pyproject = pyproject.rstrip("\n") + "\n"
984
+ pyproject_file = output / "pyproject.toml"
985
+ with open(pyproject_file, "w") as pyfile:
986
+ pyfile.write(pyproject)
987
+
988
+ rust_file = rendered.file.render(self.template_environment)
989
+ rust_file = rust_file.rstrip("\n") + "\n"
990
+ src_dir = output / "src"
991
+ src_dir.mkdir(exist_ok=True)
992
+ lib_file = src_dir / "lib.rs"
993
+ with open(lib_file, "w") as lfile:
994
+ lfile.write(rust_file)
995
+
996
+ for k, f in rendered.extra_files.items():
997
+ extra_file = f.render(self.template_environment)
998
+ extra_file = extra_file.rstrip("\n") + "\n"
999
+ extra_file_name = f"{k}.rs"
1000
+ extra_file_path = src_dir / extra_file_name
1001
+ with open(extra_file_path, "w") as ef:
1002
+ ef.write(extra_file)
1003
+
1004
+ return rust_file
1005
+
1006
+ def _validate_output(
1007
+ self, output: Optional[Path] = None, mode: Optional[RUST_MODES] = None, force: bool = False
1008
+ ) -> Path:
1009
+ """Raise a ValueError if given a dir when in file mode or vice versa"""
1010
+ if output is None:
1011
+ if self.output is None:
1012
+ raise ValueError("Must provide an output if generator doesn't already have one")
1013
+ else:
1014
+ output = Path(self.output)
1015
+ else:
1016
+ output = Path(output)
1017
+
1018
+ if mode == "file":
1019
+ assert output.suffix == ".rs", "Output must be a rust file in file mode"
1020
+ if not force and output.exists():
1021
+ raise FileExistsError(f"{output} already exists and force is False! pass force=True to overwrite")
1022
+ output.parent.mkdir(exist_ok=True, parents=True)
1023
+ elif mode == "crate":
1024
+ if not force and len([d for d in output.iterdir()]) != 0:
1025
+ raise FileExistsError(
1026
+ f"{output} already exists, is not empty, and force is False! pass force=True to overwrite"
1027
+ )
1028
+ output.mkdir(exist_ok=True, parents=True)
1029
+ else:
1030
+ raise ValueError(f"Invalid generation mode: {mode}")
1031
+
1032
+ return output
1033
+
1034
+ @property
1035
+ def template_environment(self) -> Environment:
1036
+ if self._environment is None:
1037
+ self._environment = RustTemplateModel.environment()
1038
+ return self._environment