linkml 1.9.4rc1__py3-none-any.whl → 1.9.5__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 (83) hide show
  1. linkml/cli/main.py +5 -1
  2. linkml/converter/__init__.py +0 -0
  3. linkml/generators/__init__.py +2 -0
  4. linkml/generators/common/build.py +5 -20
  5. linkml/generators/common/template.py +289 -3
  6. linkml/generators/docgen.py +55 -10
  7. linkml/generators/erdiagramgen.py +9 -5
  8. linkml/generators/graphqlgen.py +32 -6
  9. linkml/generators/jsonldcontextgen.py +78 -12
  10. linkml/generators/jsonschemagen.py +29 -12
  11. linkml/generators/mermaidclassdiagramgen.py +21 -3
  12. linkml/generators/owlgen.py +13 -2
  13. linkml/generators/panderagen/dataframe_class.py +13 -0
  14. linkml/generators/panderagen/dataframe_field.py +50 -0
  15. linkml/generators/panderagen/linkml_pandera_validator.py +186 -0
  16. linkml/generators/panderagen/panderagen.py +22 -5
  17. linkml/generators/panderagen/panderagen_class_based/class.jinja2 +70 -13
  18. linkml/generators/panderagen/panderagen_class_based/custom_checks.jinja2 +27 -0
  19. linkml/generators/panderagen/panderagen_class_based/enums.jinja2 +3 -3
  20. linkml/generators/panderagen/panderagen_class_based/pandera.jinja2 +12 -2
  21. linkml/generators/panderagen/panderagen_class_based/slots.jinja2 +19 -17
  22. linkml/generators/panderagen/slot_generator_mixin.py +143 -16
  23. linkml/generators/panderagen/transforms/__init__.py +19 -0
  24. linkml/generators/panderagen/transforms/collection_dict_model_transform.py +62 -0
  25. linkml/generators/panderagen/transforms/list_dict_model_transform.py +66 -0
  26. linkml/generators/panderagen/transforms/model_transform.py +8 -0
  27. linkml/generators/panderagen/transforms/nested_struct_model_transform.py +27 -0
  28. linkml/generators/panderagen/transforms/simple_dict_model_transform.py +86 -0
  29. linkml/generators/plantumlgen.py +17 -11
  30. linkml/generators/pydanticgen/pydanticgen.py +53 -2
  31. linkml/generators/pydanticgen/template.py +45 -233
  32. linkml/generators/pydanticgen/templates/attribute.py.jinja +1 -0
  33. linkml/generators/pydanticgen/templates/base_model.py.jinja +16 -2
  34. linkml/generators/pydanticgen/templates/imports.py.jinja +1 -1
  35. linkml/generators/rdfgen.py +11 -2
  36. linkml/generators/rustgen/__init__.py +3 -0
  37. linkml/generators/rustgen/build.py +97 -0
  38. linkml/generators/rustgen/cli.py +83 -0
  39. linkml/generators/rustgen/rustgen.py +1186 -0
  40. linkml/generators/rustgen/template.py +910 -0
  41. linkml/generators/rustgen/templates/Cargo.toml.jinja +42 -0
  42. linkml/generators/rustgen/templates/anything.rs.jinja +149 -0
  43. linkml/generators/rustgen/templates/as_key_value.rs.jinja +86 -0
  44. linkml/generators/rustgen/templates/class_module.rs.jinja +8 -0
  45. linkml/generators/rustgen/templates/enum.rs.jinja +70 -0
  46. linkml/generators/rustgen/templates/file.rs.jinja +75 -0
  47. linkml/generators/rustgen/templates/import.rs.jinja +4 -0
  48. linkml/generators/rustgen/templates/imports.rs.jinja +8 -0
  49. linkml/generators/rustgen/templates/lib_shim.rs.jinja +52 -0
  50. linkml/generators/rustgen/templates/poly.rs.jinja +9 -0
  51. linkml/generators/rustgen/templates/poly_containers.rs.jinja +439 -0
  52. linkml/generators/rustgen/templates/poly_trait.rs.jinja +15 -0
  53. linkml/generators/rustgen/templates/poly_trait_impl.rs.jinja +5 -0
  54. linkml/generators/rustgen/templates/poly_trait_impl_orsubtype.rs.jinja +5 -0
  55. linkml/generators/rustgen/templates/poly_trait_property.rs.jinja +8 -0
  56. linkml/generators/rustgen/templates/poly_trait_property_impl.rs.jinja +134 -0
  57. linkml/generators/rustgen/templates/poly_trait_property_match.rs.jinja +10 -0
  58. linkml/generators/rustgen/templates/property.rs.jinja +28 -0
  59. linkml/generators/rustgen/templates/pyproject.toml.jinja +10 -0
  60. linkml/generators/rustgen/templates/serde_utils.rs.jinja +490 -0
  61. linkml/generators/rustgen/templates/slot_range_as_union.rs.jinja +64 -0
  62. linkml/generators/rustgen/templates/struct.rs.jinja +81 -0
  63. linkml/generators/rustgen/templates/struct_or_subtype_enum.rs.jinja +111 -0
  64. linkml/generators/rustgen/templates/stub_gen.rs.jinja +71 -0
  65. linkml/generators/rustgen/templates/stub_utils.rs.jinja +76 -0
  66. linkml/generators/rustgen/templates/typealias.rs.jinja +13 -0
  67. linkml/generators/sqltablegen.py +18 -16
  68. linkml/generators/yarrrmlgen.py +173 -0
  69. linkml/linter/config/datamodel/config.py +160 -293
  70. linkml/linter/config/datamodel/config.yaml +34 -26
  71. linkml/linter/config/default.yaml +4 -0
  72. linkml/linter/config/recommended.yaml +4 -0
  73. linkml/linter/linter.py +1 -2
  74. linkml/linter/rules.py +37 -0
  75. linkml/utils/schema_builder.py +2 -0
  76. linkml/utils/schemaloader.py +76 -3
  77. {linkml-1.9.4rc1.dist-info → linkml-1.9.5.dist-info}/METADATA +2 -2
  78. {linkml-1.9.4rc1.dist-info → linkml-1.9.5.dist-info}/RECORD +82 -40
  79. {linkml-1.9.4rc1.dist-info → linkml-1.9.5.dist-info}/entry_points.txt +2 -1
  80. linkml/generators/panderagen/panderagen_class_based/mixins.jinja2 +0 -26
  81. /linkml/{utils/converter.py → converter/cli.py} +0 -0
  82. {linkml-1.9.4rc1.dist-info → linkml-1.9.5.dist-info}/WHEEL +0 -0
  83. {linkml-1.9.4rc1.dist-info → linkml-1.9.5.dist-info}/licenses/LICENSE +0 -0
linkml/cli/main.py CHANGED
@@ -7,6 +7,7 @@ Gathers all the other linkml click entrypoints and puts them under ``linkml`` :)
7
7
  import click
8
8
 
9
9
  from linkml._version import __version__
10
+ from linkml.converter.cli import cli as linkml_convert
10
11
  from linkml.generators.csvgen import cli as gen_csv
11
12
  from linkml.generators.dbmlgen import cli as gen_dbml
12
13
  from linkml.generators.docgen import cli as gen_doc
@@ -32,6 +33,7 @@ from linkml.generators.protogen import cli as gen_proto
32
33
  from linkml.generators.pydanticgen import cli as gen_pydantic
33
34
  from linkml.generators.pythongen import cli as gen_python
34
35
  from linkml.generators.rdfgen import cli as gen_rdf
36
+ from linkml.generators.rustgen.cli import cli as gen_rust
35
37
  from linkml.generators.shaclgen import cli as gen_shacl
36
38
  from linkml.generators.shexgen import cli as gen_shex
37
39
  from linkml.generators.sparqlgen import cli as gen_sparql
@@ -42,9 +44,9 @@ from linkml.generators.summarygen import cli as gen_summary
42
44
  from linkml.generators.terminusdbgen import cli as gen_terminusdb
43
45
  from linkml.generators.typescriptgen import cli as gen_typescript
44
46
  from linkml.generators.yamlgen import cli as gen_yaml
47
+ from linkml.generators.yarrrmlgen import cli as gen_yarrrml
45
48
  from linkml.generators.yumlgen import cli as gen_yuml
46
49
  from linkml.linter.cli import main as linkml_lint
47
- from linkml.utils.converter import cli as linkml_convert
48
50
  from linkml.utils.execute_tutorial import cli as run_tutorial
49
51
  from linkml.utils.schema_fixer import main as linkml_schema_fixer
50
52
  from linkml.utils.sqlutils import main as linkml_sqldb
@@ -113,6 +115,7 @@ generate.add_command(gen_python, name="python")
113
115
  generate.add_command(gen_pydantic, name="pydantic")
114
116
  generate.add_command(gen_pandera, name="pandera")
115
117
  generate.add_command(gen_rdf, name="rdf")
118
+ generate.add_command(gen_rust, name="rust")
116
119
  generate.add_command(gen_shex, name="shex")
117
120
  generate.add_command(gen_shacl, name="shacl")
118
121
  generate.add_command(gen_sparql, name="sparql")
@@ -129,6 +132,7 @@ generate.add_command(gen_excel, name="excel")
129
132
  generate.add_command(gen_sssom, name="sssom")
130
133
  generate.add_command(gen_linkml, name="linkml")
131
134
  generate.add_command(gen_dbml, name="dbml")
135
+ generate.add_command(gen_yarrrml, name="yarrrml")
132
136
 
133
137
  # Dev helpers
134
138
  dev.add_command(run_tutorial, name="tutorial")
File without changes
@@ -12,6 +12,7 @@ from linkml.generators.panderagen import PanderaGenerator
12
12
  from linkml.generators.pydanticgen import PydanticGenerator
13
13
  from linkml.generators.pythongen import PythonGenerator
14
14
  from linkml.generators.rdfgen import RDFGenerator
15
+ from linkml.generators.rustgen import RustGenerator
15
16
  from linkml.generators.shaclgen import ShaclGenerator
16
17
  from linkml.generators.shexgen import ShExGenerator
17
18
  from linkml.generators.sqlalchemygen import SQLAlchemyGenerator
@@ -49,6 +50,7 @@ __all__ = [
49
50
  "ContextGenerator",
50
51
  "JSONLDGenerator",
51
52
  "JsonSchemaGenerator",
53
+ "RustGenerator",
52
54
  "ShaclGenerator",
53
55
  "ShExGenerator",
54
56
  "SQLAlchemyGenerator",
@@ -4,38 +4,23 @@ Models for intermediate build results
4
4
  (see PydanticGenerator for example implementation and use)
5
5
  """
6
6
 
7
- import dataclasses
8
- from typing import Annotated, Any, TypeVar
7
+ from typing import Annotated, TypeVar
9
8
 
10
9
  from linkml_runtime.linkml_model import (
11
10
  ClassDefinition,
11
+ Definition,
12
12
  EnumDefinition,
13
13
  SchemaDefinition,
14
14
  SlotDefinition,
15
15
  TypeDefinition,
16
16
  )
17
- from pydantic import BaseModel, ConfigDict, GetCoreSchemaHandler
17
+ from pydantic import BaseModel, ConfigDict, GetPydanticSchema
18
18
  from pydantic_core import core_schema
19
19
 
20
20
  T = TypeVar("T", bound="BuildResult", covariant=True)
21
+ Tsv = TypeVar("Tsv", bound=Definition, covariant=True)
21
22
 
22
-
23
- @dataclasses.dataclass()
24
- class SkipValidation:
25
- """
26
- A version of :class:`pydantic.SkipValidation` that actually skips generating the
27
- schema for the field entirely - useful for including types that don't need to be validated
28
- like the metamodel dataclasses
29
- """
30
-
31
- def __class_getitem__(cls, item: Any) -> Any:
32
- return Annotated[item, SkipValidation()]
33
-
34
- @classmethod
35
- def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
36
- return core_schema.any_schema()
37
-
38
- __hash__ = object.__hash__
23
+ SkipValidation = Annotated[Tsv, GetPydanticSchema(lambda tp, handler: core_schema.any_schema())]
39
24
 
40
25
 
41
26
  class BuildResult(BaseModel):
@@ -4,11 +4,12 @@ Base classes for jinja template handling classes.
4
4
  See :mod:`.linkml.generators.pydanticgen.template` for example implementation
5
5
  """
6
6
 
7
+ from collections.abc import Generator
7
8
  from copy import copy
8
9
  from typing import Any, ClassVar, Optional, Union
9
10
 
10
11
  from jinja2 import Environment
11
- from pydantic import BaseModel
12
+ from pydantic import BaseModel, Field, field_validator
12
13
 
13
14
 
14
15
  class TemplateModel(BaseModel):
@@ -42,7 +43,7 @@ class TemplateModel(BaseModel):
42
43
  environment (:class:`jinja2.Environment`): Template environment - see :meth:`.environment`
43
44
  """
44
45
  if environment is None:
45
- environment = TemplateModel.environment()
46
+ environment = type(self).environment()
46
47
 
47
48
  fields = {**self.model_fields, **self.model_computed_fields}
48
49
 
@@ -72,6 +73,291 @@ class TemplateModel(BaseModel):
72
73
  return ret
73
74
 
74
75
 
76
+ class ObjectImport(BaseModel):
77
+ """
78
+ An object to be imported from within a module.
79
+
80
+ See :class:`.Import` for examples
81
+ """
82
+
83
+ name: str
84
+ alias: Optional[str] = None
85
+
86
+
87
+ class Import(TemplateModel):
88
+ """
89
+ A module or module and classes to be imported.
90
+ """
91
+
92
+ module: str
93
+ alias: Optional[str] = None
94
+ objects: Optional[list[ObjectImport]] = None
95
+ is_schema: bool = False
96
+ """
97
+ Whether or not this ``Import`` is importing another schema imported by the main schema --
98
+ ie. that it is not expected to be provided by the environment, but imported locally from within the package.
99
+ Used primarily in split schema generation, see :func:`.pydanticgen.generate_split` for example usage.
100
+ """
101
+
102
+ def merge(self, other: "Import") -> list["Import"]:
103
+ """
104
+ Merge one import with another, see :meth:`.Imports` for an example.
105
+
106
+ * If module don't match, return both
107
+ * If one or the other are a :class:`.ConditionalImport`, return both
108
+ * If modules match, neither contain objects, but the other has an alias, return the other
109
+ * If modules match, one contains objects but the other doesn't, return both
110
+ * If modules match, both contain objects, merge the object lists, preferring objects with aliases
111
+ """
112
+ # return both if we are orthogonal
113
+ if self.module != other.module:
114
+ return [self, other]
115
+
116
+ # handle conditionals
117
+ if isinstance(self, ConditionalImport) and isinstance(other, ConditionalImport):
118
+ # If our condition is the same, return the newer version
119
+ if self.condition == other.condition:
120
+ return [other]
121
+ if isinstance(self, ConditionalImport) or isinstance(other, ConditionalImport):
122
+ # we don't have a good way of combining conditionals, just return both
123
+ return [self, other]
124
+
125
+ # handle module vs. object imports
126
+ elif other.objects is None and self.objects is None:
127
+ # both are modules, return the other only if it updates the alias
128
+ if other.alias:
129
+ return [other]
130
+ else:
131
+ return [self]
132
+ elif other.objects is not None and self.objects is not None:
133
+ # both are object imports, merge and return
134
+ alias = self.alias if other.alias is None else other.alias
135
+ # FIXME: super awkward implementation
136
+ # keep ours if it has an alias and the other doesn't,
137
+ # otherwise take the other's version
138
+ self_objs = {obj.name: obj for obj in self.objects}
139
+ other_objs = {
140
+ obj.name: obj for obj in other.objects if obj.name not in self_objs or self_objs[obj.name].alias is None
141
+ }
142
+ self_objs.update(other_objs)
143
+
144
+ return [
145
+ type(self)(
146
+ module=self.module,
147
+ alias=alias,
148
+ objects=list(self_objs.values()),
149
+ is_schema=self.is_schema or other.is_schema,
150
+ **{
151
+ k: getattr(other, k)
152
+ for k in other.model_fields
153
+ if k not in ("module", "alias", "objects", "is_schema")
154
+ },
155
+ )
156
+ ]
157
+ else:
158
+ # one is a module, the other imports objects, keep both
159
+ return [self, other]
160
+
161
+ def sort(self) -> None:
162
+ """
163
+ Sort imported objects
164
+
165
+ * First by whether the first letter is capitalized or not,
166
+ * Then alphabetically (by object name rather than alias)
167
+ """
168
+ if self.objects:
169
+ self.objects = sorted(self.objects, key=lambda obj: (obj.name[0].islower(), obj.name))
170
+
171
+
172
+ class ConditionalImport(Import):
173
+ """
174
+ Import that depends on some condition in the environment, common when
175
+ using backported features or straddling dependency versions.
176
+
177
+ Make sure that everything that is needed to evaluate the condition is imported
178
+ before this is added to the injected imports!
179
+ """
180
+
181
+ condition: str
182
+ alternative: Import
183
+
184
+ def sort(self) -> None:
185
+ """
186
+ :meth:`.Import.sort` called for self and :attr:`.alternative`
187
+ """
188
+ super().sort()
189
+ self.alternative.sort()
190
+
191
+
192
+ class Imports(TemplateModel):
193
+ """
194
+ Container class for imports that can handle merging!
195
+
196
+ See :class:`.Import` and :class:`.ConditionalImport` for examples of declaring individual imports
197
+
198
+ Useful for generation, because each build stage will potentially generate
199
+ overlapping imports. This ensures that we can keep a collection of imports
200
+ without having many duplicates.
201
+
202
+ Defines methods for adding, iterating, and indexing from within the :attr:`Imports.imports` list.
203
+ """
204
+
205
+ imports: list[Union[Import, ConditionalImport]] = Field(default_factory=list)
206
+ render_sorted: bool = True
207
+ """When rendering, render in sorted groups"""
208
+
209
+ @classmethod
210
+ def _merge(
211
+ cls, imports: list[Union[Import, ConditionalImport]], other: Union[Import, "Imports", list[Import]]
212
+ ) -> list[Union[Import, ConditionalImport]]:
213
+ """
214
+ Add a new import to an existing imports list, handling deduplication and flattening.
215
+
216
+ Mutates and returns ``imports``
217
+
218
+ Generally will prefer the imports in ``other`` , updating those in ``imports``.
219
+ If ``other`` ...
220
+ - doesn't match any ``module`` in ``imports``, add it!
221
+ - matches a single ``module`` in imports, :meth:`.Import.merge` the object imports
222
+ - matches multiple ``module``s in imports, then there must have been another
223
+ :class:`.ConditionalImport` already present, so we :meth:`.Import.merge` the existing
224
+ :class:`.Import` if it is one, and if it's a :class:`.ConditionalImport` just YOLO
225
+ and append it since there isn't a principled way to merge them from strings.
226
+ - is :class:`.Imports` or a list of :class:`.Import` s, call this recursively for each
227
+ item.
228
+
229
+ Since imports can be merged in an undefined order depending on the generator configuration,
230
+ default behavior for imports with matching ``module`` is to remove them and append to the
231
+ end of the imports list (rather than keeping it in the position of the existing
232
+ :class:`.Import` ). :class:`.ConditionalImports` make it possible to have namespace
233
+ conflicts, so in imperative import style we assume the most recently added :class:`.Import`
234
+ is the one that should prevail.
235
+ """
236
+ #
237
+ if isinstance(other, cls) or (isinstance(other, list) and all([isinstance(i, Import) for i in other])):
238
+ for i in other:
239
+ imports = cls._merge(imports, i)
240
+ return imports
241
+
242
+ existing = [i for i in imports if i.module == other.module]
243
+
244
+ # if we have nothing importing from this module yet, add it!
245
+ if len(existing) == 0:
246
+ imports.append(other)
247
+ elif len(existing) == 1:
248
+ imports.remove(existing[0])
249
+ imports.extend(existing[0].merge(other))
250
+ else:
251
+ # we have both a conditional and at least one nonconditional already.
252
+ # If this is another conditional, we just add it, otherwise, we merge it
253
+ # with the single nonconditional
254
+ if isinstance(other, ConditionalImport):
255
+ imports.append(other)
256
+ else:
257
+ for e in existing:
258
+ if isinstance(e, Import):
259
+ imports.remove(e)
260
+ merged = e.merge(other)
261
+ imports.extend(merged)
262
+ break
263
+
264
+ # SPECIAL CASE - __future__ annotations must happen at the top of a file
265
+ # sort here outside of sort method because our imports are invalid without it,
266
+ # where calling ``sort`` should be optional.
267
+ imports = sorted(imports, key=lambda i: i.module == "__future__", reverse=True)
268
+ return imports
269
+
270
+ def __add__(self, other: Union[Import, "Imports", list[Import]]) -> "Imports":
271
+ imports = self.imports.copy()
272
+ imports = self._merge(imports, other)
273
+ return type(self).model_construct(
274
+ imports=imports, **{k: getattr(self, k, None) for k in self.model_fields if k != "imports"}
275
+ )
276
+
277
+ def __len__(self) -> int:
278
+ return len(self.imports)
279
+
280
+ def __iter__(self) -> Generator[Import, None, None]:
281
+ yield from self.imports
282
+
283
+ def __getitem__(self, item: Union[int, str]) -> Import:
284
+ if isinstance(item, int):
285
+ return self.imports[item]
286
+ elif isinstance(item, str):
287
+ # the name of the module
288
+ an_import = [i for i in self.imports if i.module == item]
289
+ if len(an_import) == 0:
290
+ raise KeyError(f"No import with module {item} was found.\nWe have: {self.imports}")
291
+ return an_import[0]
292
+ else:
293
+ raise TypeError(f"Can only index with an int or a string as the name of the module,\nGot: {type(item)}")
294
+
295
+ def __contains__(self, item: Union[Import, "Imports", list[Import]]) -> bool:
296
+ """
297
+ Check if all the objects are imported from the given module(s)
298
+
299
+ If the import is a bare module import (ie its :attr:`~.Import.objects` is ``None`` )
300
+ then we must also have a bare module import in this Imports (because even if
301
+ we import from the module, unless we import it specifically its name won't be
302
+ available in the namespace.
303
+
304
+ :attr:`.Import.alias` must always match for the same reason.
305
+ """
306
+ if isinstance(item, Imports):
307
+ return all([i in self for i in item.imports])
308
+ elif isinstance(item, list):
309
+ return all([i in self for i in item])
310
+ elif isinstance(item, Import):
311
+ try:
312
+ an_import = self[item.module]
313
+ except KeyError:
314
+ return False
315
+ if item.objects is None:
316
+ return an_import == item
317
+ else:
318
+ return all([obj in an_import.objects for obj in item.objects])
319
+ else:
320
+ raise TypeError(f"Imports only contains single Import objects or other Imports\nGot: {type(item)}")
321
+
322
+ @field_validator("imports", mode="after")
323
+ @classmethod
324
+ def imports_are_merged(
325
+ cls, imports: list[Union[Import, ConditionalImport]]
326
+ ) -> list[Union[Import, ConditionalImport]]:
327
+ """
328
+ When creating from a list of imports, construct model as if we have done so by iteratively
329
+ constructing with __add__ calls
330
+ """
331
+ merged_imports = []
332
+ for i in imports:
333
+ merged_imports = cls._merge(merged_imports, i)
334
+ return merged_imports
335
+
336
+ def sort(self) -> None:
337
+ """
338
+ Sort imports recursively, mimicking isort:
339
+
340
+ * First by :attr:`.Import.group` according to :attr:`.Imports.group_order`
341
+ * Then by whether the :class:`.Import` has any objects
342
+ (``import module`` comes before ``from module import name``)
343
+ * Then alphabetically by module name
344
+ """
345
+
346
+ def _sort_key(i: Import) -> tuple[int, str]:
347
+ return (int(i.objects is not None), i.module)
348
+
349
+ imports = sorted(self.imports, key=_sort_key)
350
+ for i in imports:
351
+ i.sort()
352
+ self.imports = imports
353
+
354
+ def render(self, environment: Optional[Environment] = None, black: bool = False) -> str:
355
+ if self.render_sorted:
356
+ self.sort()
357
+ rendered = super().render(environment=environment, black=black)
358
+ return rendered
359
+
360
+
75
361
  def _render(
76
362
  item: Union[TemplateModel, Any, list[Union[Any, TemplateModel]], dict[str, Union[Any, TemplateModel]]],
77
363
  environment: Environment,
@@ -83,7 +369,7 @@ def _render(
83
369
  elif isinstance(item, dict):
84
370
  return {k: _render(v, environment) for k, v in item.items()}
85
371
  elif isinstance(item, BaseModel):
86
- fields = item.model_fields
372
+ fields = {**item.model_fields, **getattr(item, "model_computed_fields", {})}
87
373
  return {k: _render(getattr(item, k, None), environment) for k in fields.keys()}
88
374
  else:
89
375
  return item
@@ -173,6 +173,9 @@ class DocGenerator(Generator):
173
173
  hierarchical_class_view: bool = False
174
174
  render_imports: bool = False
175
175
 
176
+ preserve_names: bool = False
177
+ """If true, preserve LinkML element names in docs instead of camelcase/underscore."""
178
+
176
179
  def __post_init__(self):
177
180
  dialect = self.dialect
178
181
  if dialect is not None:
@@ -193,6 +196,27 @@ class DocGenerator(Generator):
193
196
  self.logger = logging.getLogger(__name__)
194
197
  self.schemaview = SchemaView(self.schema, merge_imports=self.mergeimports)
195
198
 
199
+ # Override schemaview's get_mappings to use preserved URIs when needed
200
+ if self.preserve_names:
201
+ original_get_mappings = self.schemaview.get_mappings
202
+
203
+ def get_mappings_with_preserved_uris(element_name, **kwargs):
204
+ mappings = original_get_mappings(element_name, **kwargs)
205
+ if mappings and element_name:
206
+ try:
207
+ element = self.schemaview.get_element(element_name)
208
+ if element:
209
+ preserved_uri = self.uri(element, expand=True)
210
+ # Fix self and native mappings to use preserved URI
211
+ for key in ("self", "native"):
212
+ if key in mappings:
213
+ mappings[key] = [preserved_uri]
214
+ except Exception:
215
+ pass
216
+ return mappings
217
+
218
+ self.schemaview.get_mappings = get_mappings_with_preserved_uris
219
+
196
220
  def serialize(self, directory: str = None) -> None:
197
221
  """
198
222
  Serialize a schema as a collection of documents
@@ -363,19 +387,21 @@ class DocGenerator(Generator):
363
387
  :return: slot name or numeric portion of CURIE prefixed
364
388
  slot_uri
365
389
  """
366
- if type(element).class_name == "slot_definition":
390
+ if element is None:
391
+ return ""
392
+ if self.preserve_names:
393
+ return element.name
394
+ elif type(element).class_name == "slot_definition":
367
395
  if self.use_slot_uris:
368
396
  curie = self.schemaview.get_uri(element)
369
397
  if curie:
370
398
  return curie.split(":")[1]
371
-
372
399
  return underscore(element.name)
373
400
  elif type(element).class_name == "class_definition":
374
401
  if self.use_class_uris:
375
402
  curie = self.schemaview.get_uri(element)
376
403
  if curie:
377
404
  return curie.split(":")[1]
378
-
379
405
  return camelcase(element.name)
380
406
  else:
381
407
  return camelcase(element.name)
@@ -391,6 +417,13 @@ class DocGenerator(Generator):
391
417
  # Subsets have no uri attribute
392
418
  return self.name(element)
393
419
 
420
+ # Simple URI composition for preserved names
421
+ if self.preserve_names and not (self.use_class_uris or self.use_slot_uris):
422
+ base = self.schemaview.schema.id
423
+ if base and ("://" in base or base.startswith("http")):
424
+ sep = "" if base.endswith(("/", "#", "_", ":")) else "/"
425
+ return f"{base}{sep}{self.name(element)}"
426
+
394
427
  if self.subfolder_type_separation:
395
428
  return self.schemaview.get_uri(element, expand=expand, use_element_type=True)
396
429
 
@@ -442,6 +475,8 @@ class DocGenerator(Generator):
442
475
  return "NONE"
443
476
  if not isinstance(e, Definition):
444
477
  e = self.schemaview.get_element(e)
478
+ if e is None:
479
+ return "NONE"
445
480
  if self._is_external(e):
446
481
  return self.uri_link(e)
447
482
  elif isinstance(e, ClassDefinition):
@@ -454,12 +489,12 @@ class DocGenerator(Generator):
454
489
  subfolder=subfolder + CLASS_SUBFOLDER if self.subfolder_type_separation else None,
455
490
  )
456
491
  return self._markdown_link(
457
- camelcase(e.name),
492
+ self.name(e),
458
493
  subfolder=subfolder + CLASS_SUBFOLDER if self.subfolder_type_separation else None,
459
494
  )
460
495
  elif isinstance(e, EnumDefinition):
461
496
  return self._markdown_link(
462
- camelcase(e.name),
497
+ self.name(e),
463
498
  subfolder=subfolder + ENUM_SUBFOLDER if self.subfolder_type_separation else None,
464
499
  )
465
500
  elif isinstance(e, SlotDefinition):
@@ -472,17 +507,17 @@ class DocGenerator(Generator):
472
507
  subfolder=subfolder + SLOT_SUBFOLDER if self.subfolder_type_separation else None,
473
508
  )
474
509
  return self._markdown_link(
475
- underscore(e.name),
510
+ self.name(e),
476
511
  subfolder=subfolder + SLOT_SUBFOLDER if self.subfolder_type_separation else None,
477
512
  )
478
513
  elif isinstance(e, TypeDefinition):
479
514
  return self._markdown_link(
480
- camelcase(e.name),
515
+ self.name(e),
481
516
  subfolder=subfolder + TYPE_SUBFOLDER if self.subfolder_type_separation else None,
482
517
  )
483
518
  elif isinstance(e, SubsetDefinition):
484
519
  return self._markdown_link(
485
- camelcase(e.name),
520
+ self.name(e),
486
521
  subfolder=subfolder + SUBSET_SUBFOLDER if self.subfolder_type_separation else None,
487
522
  )
488
523
  else:
@@ -500,6 +535,8 @@ class DocGenerator(Generator):
500
535
  return self._is_external(t) and not self.schemaview.schema.id.startswith("https://w3id.org/linkml/")
501
536
 
502
537
  def _is_external(self, element: Element) -> bool:
538
+ if element is None:
539
+ return False
503
540
  if element.from_schema == "https://w3id.org/linkml/types" and not self.genmeta:
504
541
  return True
505
542
  else:
@@ -714,7 +751,7 @@ class DocGenerator(Generator):
714
751
  :return:
715
752
  """
716
753
  if self.diagram_type.value == DiagramType.er_diagram.value:
717
- erdgen = ERDiagramGenerator(self.schemaview.schema, format="mermaid")
754
+ erdgen = ERDiagramGenerator(self.schemaview.schema, format="mermaid", preserve_names=self.preserve_names)
718
755
  if class_names:
719
756
  return erdgen.serialize_classes(class_names, follow_references=True, max_hops=2)
720
757
  else:
@@ -722,7 +759,7 @@ class DocGenerator(Generator):
722
759
  elif self.diagram_type.value == DiagramType.mermaid_class_diagram.value:
723
760
  self.logger.info("This is currently handled in the jinja templates")
724
761
  elif self.diagram_type.value == DiagramType.plantuml_class_diagram.value:
725
- plantumlgen = PlantumlGenerator(self.schema)
762
+ plantumlgen = PlantumlGenerator(self.schema, preserve_names=self.preserve_names)
726
763
  plantuml_diagram = plantumlgen.serialize(classes=class_names)
727
764
  self.logger.debug(f"Created PlantUML diagram for class: {class_names}")
728
765
  return plantuml_diagram
@@ -1138,6 +1175,12 @@ YAML, and including it when necessary but not by default (e.g. in documentation
1138
1175
  Whether to truncate long (potentially spanning multiple lines) descriptions of classes, slots, etc., in the docs.
1139
1176
  Set to true for truncated descriptions, and false to display full descriptions.""",
1140
1177
  )
1178
+ @click.option(
1179
+ "--preserve-names/--normalize-names",
1180
+ default=False,
1181
+ show_default=True,
1182
+ help="Preserve original LinkML names in documentation output (e.g., for page titles, links, and file names).",
1183
+ )
1141
1184
  @click.version_option(__version__, "-V", "--version")
1142
1185
  @click.command(name="doc")
1143
1186
  def cli(
@@ -1152,6 +1195,7 @@ def cli(
1152
1195
  subfolder_type_separation,
1153
1196
  render_imports,
1154
1197
  truncate_descriptions,
1198
+ preserve_names,
1155
1199
  **args,
1156
1200
  ):
1157
1201
  """Generate documentation folder from a LinkML YAML schema
@@ -1185,6 +1229,7 @@ def cli(
1185
1229
  subfolder_type_separation=subfolder_type_separation,
1186
1230
  render_imports=render_imports,
1187
1231
  truncate_descriptions=truncate_descriptions,
1232
+ preserve_names=preserve_names,
1188
1233
  **args,
1189
1234
  )
1190
1235
  print(gen.serialize())
@@ -142,6 +142,9 @@ class ERDiagramGenerator(Generator):
142
142
  no_types_dir: bool = False
143
143
  use_slot_uris: bool = False
144
144
 
145
+ preserve_names: bool = False
146
+ """If true, preserve LinkML element names (classes/slots) in diagram labels."""
147
+
145
148
  def __post_init__(self):
146
149
  self.schemaview = SchemaView(self.schema)
147
150
  super().__post_init__()
@@ -232,7 +235,7 @@ class ERDiagramGenerator(Generator):
232
235
  cls = sv.get_class(class_name)
233
236
  if self.exclude_abstract_classes and cls.abstract:
234
237
  return
235
- entity = Entity(name=camelcase(cls.name))
238
+ entity = Entity(name=cls.name if self.preserve_names else camelcase(cls.name))
236
239
  diagram.entities.append(entity)
237
240
  for slot in sv.class_induced_slots(class_name):
238
241
  if slot.range in targets:
@@ -250,7 +253,7 @@ class ERDiagramGenerator(Generator):
250
253
  cls = sv.get_class(class_name)
251
254
  if self.exclude_abstract_classes and cls.abstract:
252
255
  return
253
- entity = Entity(name=camelcase(cls.name))
256
+ entity = Entity(name=cls.name if self.preserve_names else camelcase(cls.name))
254
257
  diagram.entities.append(entity)
255
258
  for slot in sv.class_induced_slots(class_name):
256
259
  # TODO: schemaview should infer this
@@ -280,7 +283,9 @@ class ERDiagramGenerator(Generator):
280
283
  rel = Relationship(
281
284
  first_entity=entity.name,
282
285
  relationship_type=rel_type,
283
- second_entity=camelcase(sv.get_class(slot.range).name),
286
+ second_entity=(
287
+ sv.get_class(slot.range).name if self.preserve_names else camelcase(sv.get_class(slot.range).name)
288
+ ),
284
289
  relationship_label=slot.name,
285
290
  )
286
291
  diagram.relationships.append(rel)
@@ -299,7 +304,7 @@ class ERDiagramGenerator(Generator):
299
304
  if slot.multivalued:
300
305
  # NOTE: mermaid does not support []s or *s in attribute types
301
306
  dt = f"{dt}List"
302
- attr = Attribute(name=underscore(slot.name), datatype=dt)
307
+ attr = Attribute(name=(slot.name if self.preserve_names else underscore(slot.name)), datatype=dt)
303
308
  entity.attributes.append(attr)
304
309
 
305
310
 
@@ -324,7 +329,6 @@ class ERDiagramGenerator(Generator):
324
329
  default=False,
325
330
  help="If True, follow references even if not inlined",
326
331
  )
327
- @click.option("--format", "-f", default="markdown", type=click.Choice(ERDiagramGenerator.valid_formats))
328
332
  @click.option("--max-hops", default=None, type=click.INT, help="Maximum number of hops")
329
333
  @click.option("--classes", "-c", multiple=True, help="List of classes to serialize")
330
334
  @click.option("--include-upstream", is_flag=True, help="Include upstream classes")