linkml 1.8.1__py3-none-any.whl → 1.8.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.
Files changed (78) hide show
  1. linkml/cli/__init__.py +0 -0
  2. linkml/cli/__main__.py +4 -0
  3. linkml/cli/main.py +130 -0
  4. linkml/generators/common/build.py +105 -0
  5. linkml/generators/common/ifabsent_processor.py +286 -0
  6. linkml/generators/common/lifecycle.py +124 -0
  7. linkml/generators/common/template.py +89 -0
  8. linkml/generators/csvgen.py +1 -1
  9. linkml/generators/docgen/slot.md.jinja2 +4 -0
  10. linkml/generators/docgen.py +1 -1
  11. linkml/generators/dotgen.py +1 -1
  12. linkml/generators/erdiagramgen.py +1 -1
  13. linkml/generators/excelgen.py +1 -1
  14. linkml/generators/golanggen.py +1 -1
  15. linkml/generators/golrgen.py +1 -1
  16. linkml/generators/graphqlgen.py +1 -1
  17. linkml/generators/javagen.py +1 -1
  18. linkml/generators/jsonldcontextgen.py +4 -5
  19. linkml/generators/jsonldgen.py +1 -1
  20. linkml/generators/jsonschemagen.py +69 -22
  21. linkml/generators/linkmlgen.py +1 -1
  22. linkml/generators/markdowngen.py +1 -1
  23. linkml/generators/namespacegen.py +1 -1
  24. linkml/generators/oocodegen.py +2 -1
  25. linkml/generators/owlgen.py +1 -1
  26. linkml/generators/plantumlgen.py +1 -1
  27. linkml/generators/prefixmapgen.py +1 -1
  28. linkml/generators/projectgen.py +1 -1
  29. linkml/generators/protogen.py +1 -1
  30. linkml/generators/pydanticgen/__init__.py +8 -3
  31. linkml/generators/pydanticgen/array.py +114 -194
  32. linkml/generators/pydanticgen/build.py +64 -25
  33. linkml/generators/pydanticgen/includes.py +1 -31
  34. linkml/generators/pydanticgen/pydanticgen.py +621 -276
  35. linkml/generators/pydanticgen/template.py +152 -184
  36. linkml/generators/pydanticgen/templates/attribute.py.jinja +9 -7
  37. linkml/generators/pydanticgen/templates/base_model.py.jinja +0 -13
  38. linkml/generators/pydanticgen/templates/class.py.jinja +2 -2
  39. linkml/generators/pydanticgen/templates/footer.py.jinja +2 -10
  40. linkml/generators/pydanticgen/templates/module.py.jinja +2 -2
  41. linkml/generators/pydanticgen/templates/validator.py.jinja +0 -4
  42. linkml/generators/python/__init__.py +1 -0
  43. linkml/generators/python/python_ifabsent_processor.py +92 -0
  44. linkml/generators/pythongen.py +19 -23
  45. linkml/generators/rdfgen.py +1 -1
  46. linkml/generators/shacl/__init__.py +1 -3
  47. linkml/generators/shacl/shacl_data_type.py +1 -1
  48. linkml/generators/shacl/shacl_ifabsent_processor.py +89 -0
  49. linkml/generators/shaclgen.py +11 -5
  50. linkml/generators/shexgen.py +1 -1
  51. linkml/generators/sparqlgen.py +1 -1
  52. linkml/generators/sqlalchemygen.py +1 -1
  53. linkml/generators/sqltablegen.py +1 -1
  54. linkml/generators/sssomgen.py +1 -1
  55. linkml/generators/summarygen.py +1 -1
  56. linkml/generators/terminusdbgen.py +7 -4
  57. linkml/generators/typescriptgen.py +1 -1
  58. linkml/generators/yamlgen.py +1 -1
  59. linkml/generators/yumlgen.py +1 -1
  60. linkml/linter/cli.py +1 -1
  61. linkml/transformers/logical_model_transformer.py +117 -18
  62. linkml/utils/converter.py +1 -1
  63. linkml/utils/execute_tutorial.py +2 -0
  64. linkml/utils/logictools.py +142 -29
  65. linkml/utils/schema_builder.py +7 -6
  66. linkml/utils/schema_fixer.py +1 -1
  67. linkml/utils/sqlutils.py +1 -1
  68. linkml/validator/cli.py +4 -1
  69. linkml/validators/jsonschemavalidator.py +1 -1
  70. linkml/validators/sparqlvalidator.py +1 -1
  71. linkml/workspaces/example_runner.py +1 -1
  72. {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/METADATA +2 -2
  73. {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/RECORD +76 -68
  74. {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/entry_points.txt +1 -1
  75. linkml/generators/shacl/ifabsent_processor.py +0 -59
  76. linkml/utils/ifabsent_functions.py +0 -138
  77. {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/LICENSE +0 -0
  78. {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/WHEEL +0 -0
@@ -1,20 +1,20 @@
1
1
  import inspect
2
2
  import logging
3
3
  import os
4
+ import re
4
5
  import textwrap
5
6
  from collections import defaultdict
6
- from copy import copy, deepcopy
7
7
  from dataclasses import dataclass, field
8
8
  from enum import Enum
9
9
  from pathlib import Path
10
10
  from types import ModuleType
11
- from typing import Dict, List, Literal, Optional, Set, Type, TypeVar, Union, overload
11
+ from typing import ClassVar, Dict, List, Literal, Optional, Set, Tuple, Type, TypeVar, Union, overload
12
12
 
13
13
  import click
14
- from jinja2 import ChoiceLoader, Environment, FileSystemLoader
14
+ from jinja2 import ChoiceLoader, Environment, FileSystemLoader, Template
15
15
  from linkml_runtime.linkml_model.meta import (
16
- Annotation,
17
16
  ClassDefinition,
17
+ ElementName,
18
18
  SchemaDefinition,
19
19
  SlotDefinition,
20
20
  TypeDefinition,
@@ -25,13 +25,13 @@ from linkml_runtime.utils.schemaview import SchemaView
25
25
  from pydantic.version import VERSION as PYDANTIC_VERSION
26
26
 
27
27
  from linkml._version import __version__
28
+ from linkml.generators.common.lifecycle import LifecycleMixin
28
29
  from linkml.generators.common.type_designators import get_accepted_type_designator_values, get_type_designator_value
29
30
  from linkml.generators.oocodegen import OOCodeGenerator
30
31
  from linkml.generators.pydanticgen import includes
31
32
  from linkml.generators.pydanticgen.array import ArrayRangeGenerator, ArrayRepresentation
32
- from linkml.generators.pydanticgen.build import SlotResult
33
+ from linkml.generators.pydanticgen.build import ClassResult, SlotResult, SplitResult
33
34
  from linkml.generators.pydanticgen.template import (
34
- ConditionalImport,
35
35
  Import,
36
36
  Imports,
37
37
  ObjectImport,
@@ -39,11 +39,11 @@ from linkml.generators.pydanticgen.template import (
39
39
  PydanticBaseModel,
40
40
  PydanticClass,
41
41
  PydanticModule,
42
- TemplateModel,
42
+ PydanticTemplateModel,
43
43
  )
44
+ from linkml.generators.python.python_ifabsent_processor import PythonIfAbsentProcessor
44
45
  from linkml.utils import deprecation_warning
45
46
  from linkml.utils.generator import shared_arguments
46
- from linkml.utils.ifabsent_functions import ifabsent_value_declaration
47
47
 
48
48
  if int(PYDANTIC_VERSION[0]) == 1:
49
49
  deprecation_warning("pydantic-v1")
@@ -67,7 +67,9 @@ def _get_pyrange(t: TypeDefinition, sv: SchemaView) -> str:
67
67
  DEFAULT_IMPORTS = (
68
68
  Imports()
69
69
  + Import(module="__future__", objects=[ObjectImport(name="annotations")])
70
- + Import(module="datetime", objects=[ObjectImport(name="datetime"), ObjectImport(name="date")])
70
+ + Import(
71
+ module="datetime", objects=[ObjectImport(name="datetime"), ObjectImport(name="date"), ObjectImport(name="time")]
72
+ )
71
73
  + Import(module="decimal", objects=[ObjectImport(name="Decimal")])
72
74
  + Import(module="enum", objects=[ObjectImport(name="Enum")])
73
75
  + Import(module="re")
@@ -84,9 +86,7 @@ DEFAULT_IMPORTS = (
84
86
  ObjectImport(name="Union"),
85
87
  ],
86
88
  )
87
- + Import(module="pydantic.version", objects=[ObjectImport(name="VERSION", alias="PYDANTIC_VERSION")])
88
- + ConditionalImport(
89
- condition="int(PYDANTIC_VERSION[0])>=2",
89
+ + Import(
90
90
  module="pydantic",
91
91
  objects=[
92
92
  ObjectImport(name="BaseModel"),
@@ -95,20 +95,16 @@ DEFAULT_IMPORTS = (
95
95
  ObjectImport(name="RootModel"),
96
96
  ObjectImport(name="field_validator"),
97
97
  ],
98
- alternative=Import(
99
- module="pydantic",
100
- objects=[ObjectImport(name="BaseModel"), ObjectImport(name="Field"), ObjectImport(name="validator")],
101
- ),
102
98
  )
103
99
  )
104
100
 
105
- DEFAULT_INJECTS = {1: [includes.LinkMLMeta_v1], 2: [includes.LinkMLMeta_v2]}
101
+ DEFAULT_INJECTS = [includes.LinkMLMeta]
106
102
 
107
103
 
108
104
  class MetadataMode(str, Enum):
109
105
  FULL = "full"
110
106
  """
111
- all metadata from the source schema will be included, even if it is represented by the template classes,
107
+ all metadata from the source schema will be included, even if it is represented by the template classes,
112
108
  and even if it is represented by some child class (eg. "classes" will be included with schema metadata
113
109
  """
114
110
  EXCEPT_CHILDREN = "except_children"
@@ -118,7 +114,7 @@ class MetadataMode(str, Enum):
118
114
  """
119
115
  AUTO = "auto"
120
116
  """
121
- Only the metadata that isn't represented by the template classes or excluded with ``meta_exclude`` will be included
117
+ Only the metadata that isn't represented by the template classes or excluded with ``meta_exclude`` will be included
122
118
  """
123
119
  NONE = None
124
120
  """
@@ -126,16 +122,55 @@ class MetadataMode(str, Enum):
126
122
  """
127
123
 
128
124
 
125
+ class SplitMode(str, Enum):
126
+ FULL = "full"
127
+ """
128
+ Import all classes defined in imported schemas
129
+ """
130
+
131
+ AUTO = "auto"
132
+ """
133
+ Only import those classes that are actually used in the generated schema as
134
+
135
+ * parents (``is_a``)
136
+ * mixins
137
+ * slot ranges
138
+ """
139
+
140
+
129
141
  DefinitionType = TypeVar("DefinitionType", bound=Union[SchemaDefinition, ClassDefinition, SlotDefinition])
130
142
  TemplateType = TypeVar("TemplateType", bound=Union[PydanticModule, PydanticClass, PydanticAttribute])
131
143
 
132
144
 
133
145
  @dataclass
134
- class PydanticGenerator(OOCodeGenerator):
146
+ class PydanticGenerator(OOCodeGenerator, LifecycleMixin):
135
147
  """
136
148
  Generates Pydantic-compliant classes from a schema
137
149
 
138
150
  This is an alternative to the dataclasses-based Pythongen
151
+
152
+ Lifecycle methods (see :class:`.LifecycleMixin` ) supported:
153
+
154
+ * :meth:`~.LifecycleMixin.before_generate_enums`
155
+
156
+ Slot generation is nested within class generation, since the pydantic generator currently doesn't
157
+ create an independent representation of slots aside from their materialization as class fields.
158
+ Accordingly, the ``before_`` and ``after_generate_slots`` are called before and after each class's
159
+ slot generation, rather than all slot generation.
160
+
161
+ * :meth:`~.LifecycleMixin.before_generate_classes`
162
+ * :meth:`~.LifecycleMixin.before_generate_class`
163
+ * :meth:`~.LifecycleMixin.after_generate_class`
164
+ * :meth:`~.LifecycleMixin.after_generate_classes`
165
+
166
+ * :meth:`~.LifecycleMixin.before_generate_slots`
167
+ * :meth:`~.LifecycleMixin.before_generate_slot`
168
+ * :meth:`~.LifecycleMixin.after_generate_slot`
169
+ * :meth:`~.LifecycleMixin.after_generate_slots`
170
+
171
+ * :meth:`~.LifecycleMixin.before_render_template`
172
+ * :meth:`~.LifecycleMixin.after_render_template`
173
+
139
174
  """
140
175
 
141
176
  # ClassVar overrides
@@ -150,12 +185,12 @@ class PydanticGenerator(OOCodeGenerator):
150
185
  """
151
186
  If black is present in the environment, format the serialized code with it
152
187
  """
153
- pydantic_version: int = int(PYDANTIC_VERSION[0])
188
+
154
189
  template_dir: Optional[Union[str, Path]] = None
155
190
  """
156
- Override templates for each TemplateModel.
191
+ Override templates for each PydanticTemplateModel.
157
192
 
158
- Directory with templates that override the default :attr:`.TemplateModel.template`
193
+ Directory with templates that override the default :attr:`.PydanticTemplateModel.template`
159
194
  for each class. If a matching template is not found in the override directory,
160
195
  the default templates will be used.
161
196
  """
@@ -164,62 +199,62 @@ class PydanticGenerator(OOCodeGenerator):
164
199
  injected_classes: Optional[List[Union[Type, str]]] = None
165
200
  """
166
201
  A list/tuple of classes to inject into the generated module.
167
-
202
+
168
203
  Accepts either live classes or strings. Live classes will have their source code
169
204
  extracted with inspect.get - so they need to be standard python classes declared in a
170
- source file (ie. the module they are contained in needs a ``__file__`` attr,
205
+ source file (ie. the module they are contained in needs a ``__file__`` attr,
171
206
  see: :func:`inspect.getsource` )
172
207
  """
173
208
  injected_fields: Optional[List[str]] = None
174
209
  """
175
210
  A list/tuple of field strings to inject into the base class.
176
-
211
+
177
212
  Examples:
178
-
213
+
179
214
  .. code-block:: python
180
215
 
181
216
  injected_fields = (
182
217
  'object_id: Optional[str] = Field(None, description="Unique UUID for each object")',
183
218
  )
184
-
219
+
185
220
  """
186
221
  imports: Optional[List[Import]] = None
187
222
  """
188
- Additional imports to inject into generated module.
189
-
223
+ Additional imports to inject into generated module.
224
+
190
225
  Examples:
191
-
226
+
192
227
  .. code-block:: python
193
-
228
+
194
229
  from linkml.generators.pydanticgen.template import (
195
230
  ConditionalImport,
196
231
  ObjectImport,
197
232
  Import,
198
233
  Imports
199
234
  )
200
-
201
- imports = (Imports() +
202
- Import(module='sys') +
203
- Import(module='numpy', alias='np') +
235
+
236
+ imports = (Imports() +
237
+ Import(module='sys') +
238
+ Import(module='numpy', alias='np') +
204
239
  Import(module='pathlib', objects=[
205
240
  ObjectImport(name="Path"),
206
241
  ObjectImport(name="PurePath", alias="RenamedPurePath")
207
- ]) +
242
+ ]) +
208
243
  ConditionalImport(
209
244
  module="typing",
210
245
  objects=[ObjectImport(name="Literal")],
211
246
  condition="sys.version_info >= (3, 8)",
212
247
  alternative=Import(
213
- module="typing_extensions",
248
+ module="typing_extensions",
214
249
  objects=[ObjectImport(name="Literal")]
215
250
  ),
216
251
  ).imports
217
252
  )
218
-
253
+
219
254
  becomes:
220
-
255
+
221
256
  .. code-block:: python
222
-
257
+
223
258
  import sys
224
259
  import numpy as np
225
260
  from pathlib import (
@@ -230,14 +265,72 @@ class PydanticGenerator(OOCodeGenerator):
230
265
  from typing import Literal
231
266
  else:
232
267
  from typing_extensions import Literal
233
-
268
+
234
269
  """
235
270
  metadata_mode: Union[MetadataMode, str, None] = MetadataMode.AUTO
236
271
  """
237
272
  How to include schema metadata in generated pydantic models.
238
-
273
+
239
274
  See :class:`.MetadataMode` for mode documentation
240
275
  """
276
+ split: bool = False
277
+ """
278
+ Generate schema that import other schema as separate python modules
279
+ that import from one another, rather than rolling all into a single
280
+ module (default, ``False``).
281
+ """
282
+ split_pattern: str = ".{{ schema.name }}"
283
+ """
284
+ When splitting generation, imported modules need to be generated separately
285
+ and placed in a python package and import from each other. Since the
286
+ location of those imported modules is variable -- e.g. one might want to
287
+ generate schema in multiple packages depending on their version -- this
288
+ pattern is used to generate the module portion of the import statement.
289
+
290
+ These patterns should generally yield a relative module import,
291
+ since functions like :func:`.generate_split` will generate and write files
292
+ relative to some base file, though this is not a requirement since custom
293
+ split generation logic is also allowed.
294
+
295
+ The pattern is a jinja template string that is given the ``SchemaDefinition``
296
+ of the imported schema in the environment. Additional variables can be passed
297
+ into the jinja environment with the :attr:`.split_context` argument.
298
+
299
+ Further modification is possible by using jinja filters.
300
+
301
+ After templating, the string is passed through a :attr:`SNAKE_CASE` pattern
302
+ to replace whitespace and other characters that can't be used in module names.
303
+
304
+ See also :meth:`.generate_module_import`, which is used to generate the
305
+ module portion of the import statement (and can be overridden in subclasses).
306
+
307
+ Examples:
308
+
309
+ for a schema named ``ExampleSchema`` and version ``1.2.3`` ...
310
+
311
+ ``".{{ schema.name }}"`` (the default) becomes
312
+
313
+ ``from .example_schema import ClassA, ...``
314
+
315
+ ``"...{{ schema.name }}.v{{ schema.version | replace('.', '_') }}"`` becomes
316
+
317
+ ``from ...example_schema.v1_2_3 import ClassA, ...``
318
+
319
+ """
320
+ split_context: Optional[dict] = None
321
+ """
322
+ Additional variables to pass into ``split_pattern`` when
323
+ generating imported module names.
324
+
325
+ Passed in as ``**kwargs`` , so e.g. if ``split_context = {'myval': 1}``
326
+ then one would use it in a template string like ``{{ myval }}``
327
+ """
328
+ split_mode: SplitMode = SplitMode.AUTO
329
+ """
330
+ How to filter imports from imported schema.
331
+
332
+ See :class:`.SplitMode` for description of options
333
+ """
241
334
 
242
335
  # ObjectVars (identical to pythongen)
243
336
  gen_classvars: bool = True
@@ -245,10 +338,16 @@ class PydanticGenerator(OOCodeGenerator):
245
338
  genmeta: bool = False
246
339
  emit_metadata: bool = True
247
340
 
341
+ # ClassVars
342
+ SNAKE_CASE: ClassVar[str] = r"(((?<!^)(?<!\.))(?=[A-Z][a-z]))|([^\w\.]+)"
343
+ """Substitute CamelCase and non-word characters with _"""
344
+
345
+ # Private attributes
346
+ _predefined_slot_values: Optional[Dict[str, Dict[str, str]]] = None
347
+ _class_bases: Optional[Dict[str, List[str]]] = None
348
+
248
349
  def __post_init__(self):
249
350
  super().__post_init__()
250
- if int(self.pydantic_version) == 1:
251
- deprecation_warning("pydanticgen-v1")
252
351
 
253
352
  def compile_module(self, **kwargs) -> ModuleType:
254
353
  """
@@ -263,8 +362,20 @@ class PydanticGenerator(OOCodeGenerator):
263
362
  logging.error(f"Error compiling generated python code: {e}")
264
363
  raise e
265
364
 
365
+ def _get_classes(self, sv: SchemaView) -> Tuple[List[ClassDefinition], Optional[List[ClassDefinition]]]:
366
+ all_classes = sv.all_classes(imports=True).values()
367
+
368
+ if self.split:
369
+ local_classes = sv.all_classes(imports=False).values()
370
+ imported_classes = [c for c in all_classes if c not in local_classes]
371
+ return list(local_classes), imported_classes
372
+ else:
373
+ return list(all_classes), None
374
+
266
375
  @staticmethod
267
- def sort_classes(clist: List[ClassDefinition]) -> List[ClassDefinition]:
376
+ def sort_classes(
377
+ clist: List[ClassDefinition], imported: Optional[List[ClassDefinition]] = None
378
+ ) -> List[ClassDefinition]:
268
379
  """
269
380
  sort classes such that if C is a child of P then C appears after P in the list
270
381
 
@@ -272,6 +383,9 @@ class PydanticGenerator(OOCodeGenerator):
272
383
 
273
384
  TODO: This should move to SchemaView
274
385
  """
386
+ if imported is not None:
387
+ imported = [i.name for i in imported]
388
+
275
389
  clist = list(clist)
276
390
  slist = [] # sorted
277
391
  while len(clist) > 0:
@@ -283,6 +397,11 @@ class PydanticGenerator(OOCodeGenerator):
283
397
  candidates = [candidate.is_a] + candidate.mixins
284
398
  else:
285
399
  candidates = candidate.mixins
400
+
401
+ # remove blocking classes imported from other schemas if in split mode
402
+ if imported:
403
+ candidates = [c for c in candidates if c not in imported]
404
+
286
405
  if not candidates:
287
406
  can_add = True
288
407
  else:
@@ -296,82 +415,166 @@ class PydanticGenerator(OOCodeGenerator):
296
415
  raise ValueError(f"could not find suitable element in {clist} that does not ref {slist}")
297
416
  return slist
298
417
 
299
- def get_predefined_slot_values(self) -> Dict[str, Dict[str, str]]:
418
+ def generate_class(self, cls: ClassDefinition) -> ClassResult:
419
+ pyclass = PydanticClass(
420
+ name=camelcase(cls.name),
421
+ bases=self.class_bases.get(camelcase(cls.name), PydanticBaseModel.default_name),
422
+ description=cls.description.replace('"', '\\"') if cls.description is not None else None,
423
+ )
424
+
425
+ imports = self._get_imports(cls) if self.split else None
426
+
427
+ result = ClassResult(cls=pyclass, source=cls, imports=imports)
428
+
429
+ # Gather slots
430
+ slots = [self.schemaview.induced_slot(sn, cls.name) for sn in self.schemaview.class_slots(cls.name)]
431
+ slots = self.before_generate_slots(slots, self.schemaview)
432
+
433
+ slot_results = []
434
+ for slot in slots:
435
+ slot = self.before_generate_slot(slot, self.schemaview)
436
+ slot = self.generate_slot(slot, cls)
437
+ slot = self.after_generate_slot(slot, self.schemaview)
438
+ slot_results.append(slot)
439
+ result = result.merge(slot)
440
+
441
+ slot_results = self.after_generate_slots(slot_results, self.schemaview)
442
+ attributes = {slot.attribute.name: slot.attribute for slot in slot_results}
443
+
444
+ result.cls.attributes = attributes
445
+ result.cls = self.include_metadata(result.cls, cls)
446
+
447
+ return result
448
+
449
+ def generate_slot(self, slot: SlotDefinition, cls: ClassDefinition) -> SlotResult:
450
+ slot_args = {
451
+ k: slot._as_dict.get(k, None)
452
+ for k in PydanticAttribute.model_fields.keys()
453
+ if slot._as_dict.get(k, None) is not None
454
+ }
455
+ slot_args["name"] = underscore(slot.name)
456
+ slot_args["description"] = slot.description.replace('"', '\\"') if slot.description is not None else None
457
+ predef = self.predefined_slot_values.get(camelcase(cls.name), {}).get(slot.name, None)
458
+ if predef is not None:
459
+ slot_args["predefined"] = str(predef)
460
+
461
+ pyslot = PydanticAttribute(**slot_args)
462
+ pyslot = self.include_metadata(pyslot, slot)
463
+
464
+ slot_ranges = []
465
+ # Confirm that the original slot range (ignoring the default that comes in from
466
+ # induced_slot) isn't in addition to setting any_of
467
+ any_of_ranges = [a.range if a.range else slot.range for a in slot.any_of]
468
+ if any_of_ranges:
469
+ # list comprehension here is pulling ranges from within AnonymousSlotExpression
470
+ slot_ranges.extend(any_of_ranges)
471
+ else:
472
+ slot_ranges.append(slot.range)
473
+
474
+ pyranges = [self.generate_python_range(slot_range, slot, cls) for slot_range in slot_ranges]
475
+
476
+ pyranges = list(set(pyranges)) # remove duplicates
477
+ pyranges.sort()
478
+
479
+ if len(pyranges) == 1:
480
+ pyrange = pyranges[0]
481
+ elif len(pyranges) > 1:
482
+ pyrange = f"Union[{', '.join(pyranges)}]"
483
+ else:
484
+ raise Exception(f"Could not generate python range for {cls.name}.{slot.name}")
485
+
486
+ pyslot.range = pyrange
487
+
488
+ imports = self._get_imports(slot) if self.split else None
489
+
490
+ result = SlotResult(attribute=pyslot, source=slot, imports=imports)
491
+
492
+ if slot.array is not None:
493
+ results = self.get_array_representations_range(slot, result.attribute.range)
494
+ if len(results) == 1:
495
+ result.attribute.range = results[0].range
496
+ else:
497
+ result.attribute.range = f"Union[{', '.join([res.range for res in results])}]"
498
+ for res in results:
499
+ result = result.merge(res)
500
+
501
+ elif slot.multivalued:
502
+ if slot.inlined or slot.inlined_as_list:
503
+ collection_key = self.generate_collection_key(slot_ranges, slot, cls)
504
+ else:
505
+ collection_key = None
506
+ if slot.inlined is False or collection_key is None or slot.inlined_as_list is True:
507
+ result.attribute.range = f"List[{result.attribute.range}]"
508
+ else:
509
+ simple_dict_value = None
510
+ if len(slot_ranges) == 1:
511
+ simple_dict_value = self._inline_as_simple_dict_with_value(slot)
512
+ if simple_dict_value:
513
+ # simple_dict_value might be the range of the identifier of a class when range is a class,
514
+ # so we specify either that identifier or the range itself
515
+ if simple_dict_value != result.attribute.range:
516
+ simple_dict_value = f"Union[{simple_dict_value}, {result.attribute.range}]"
517
+ result.attribute.range = f"Dict[str, {simple_dict_value}]"
518
+ else:
519
+ result.attribute.range = f"Dict[{collection_key}, {result.attribute.range}]"
520
+ if not (slot.required or slot.identifier or slot.key) and not slot.designates_type:
521
+ result.attribute.range = f"Optional[{result.attribute.range}]"
522
+ return result
523
+
524
+ @property
525
+ def predefined_slot_values(self) -> Dict[str, Dict[str, str]]:
300
526
  """
301
527
  :return: Dictionary of dictionaries with predefined slot values for each class
302
528
  """
303
- sv = self.schemaview
304
- slot_values = defaultdict(dict)
305
- for class_def in sv.all_classes().values():
306
- for slot_name in sv.class_slots(class_def.name):
307
- slot = sv.induced_slot(slot_name, class_def.name)
308
- if slot.designates_type:
309
- target_value = get_type_designator_value(sv, slot, class_def)
310
- slot_values[camelcase(class_def.name)][slot.name] = f'"{target_value}"'
311
- if slot.multivalued:
312
- slot_values[camelcase(class_def.name)][slot.name] = (
313
- "[" + slot_values[camelcase(class_def.name)][slot.name] + "]"
314
- )
315
- slot_values[camelcase(class_def.name)][slot.name] = slot_values[camelcase(class_def.name)][
316
- slot.name
317
- ]
318
- elif slot.ifabsent is not None:
319
- value = ifabsent_value_declaration(slot.ifabsent, sv, class_def, slot)
320
- slot_values[camelcase(class_def.name)][slot.name] = value
321
- # Multivalued slots that are either not inlined (just an identifier) or are
322
- # inlined as lists should get default_factory list, if they're inlined but
323
- # not as a list, that means a dictionary
324
- elif "linkml:elements" in slot.implements:
325
- slot_values[camelcase(class_def.name)][slot.name] = None
326
- elif slot.multivalued:
327
- has_identifier_slot = self.range_class_has_identifier_slot(slot)
328
-
329
- if slot.inlined and not slot.inlined_as_list and has_identifier_slot:
330
- slot_values[camelcase(class_def.name)][slot.name] = "default_factory=dict"
331
- else:
332
- slot_values[camelcase(class_def.name)][slot.name] = "default_factory=list"
333
-
334
- return slot_values
335
-
336
- def range_class_has_identifier_slot(self, slot):
337
- """
338
- Check if the range class of a slot has an identifier slot, via both slot.any_of and slot.range
339
- Should return False if the range is not a class, and also if the range is a class but has no
340
- identifier slot
341
-
342
- :param slot: SlotDefinition
343
- :return: bool
344
- """
345
- sv = self.schemaview
346
- has_identifier_slot = False
347
- if slot.any_of:
348
- for slot_range in slot.any_of:
349
- any_of_range = slot_range.range
350
- if any_of_range in sv.all_classes() and sv.get_identifier_slot(any_of_range, use_key=True) is not None:
351
- has_identifier_slot = True
352
- if slot.range in sv.all_classes() and sv.get_identifier_slot(slot.range, use_key=True) is not None:
353
- has_identifier_slot = True
354
- return has_identifier_slot
355
-
356
- def get_class_isa_plus_mixins(self) -> Dict[str, List[str]]:
529
+ if self._predefined_slot_values is None:
530
+ sv = self.schemaview
531
+ ifabsent_processor = PythonIfAbsentProcessor(sv)
532
+ slot_values = defaultdict(dict)
533
+ for class_def in sv.all_classes().values():
534
+ for slot_name in sv.class_slots(class_def.name):
535
+ slot = sv.induced_slot(slot_name, class_def.name)
536
+ if slot.designates_type:
537
+ target_value = get_type_designator_value(sv, slot, class_def)
538
+ slot_values[camelcase(class_def.name)][slot.name] = f'"{target_value}"'
539
+ if slot.multivalued:
540
+ slot_values[camelcase(class_def.name)][slot.name] = (
541
+ "[" + slot_values[camelcase(class_def.name)][slot.name] + "]"
542
+ )
543
+ slot_values[camelcase(class_def.name)][slot.name] = slot_values[camelcase(class_def.name)][
544
+ slot.name
545
+ ]
546
+ elif slot.ifabsent is not None:
547
+ value = ifabsent_processor.process_slot(slot, class_def)
548
+ slot_values[camelcase(class_def.name)][slot.name] = value
549
+
550
+ self._predefined_slot_values = slot_values
551
+
552
+ return self._predefined_slot_values
553
+
554
+ @property
555
+ def class_bases(self) -> Dict[str, List[str]]:
357
556
  """
358
557
  Generate the inheritance list for each class from is_a plus mixins
359
558
  :return:
360
559
  """
361
- sv = self.schemaview
362
- parents = {}
363
- for class_def in sv.all_classes().values():
364
- class_parents = []
365
- if class_def.is_a:
366
- class_parents.append(camelcase(class_def.is_a))
367
- if self.gen_mixin_inheritance and class_def.mixins:
368
- class_parents.extend([camelcase(mixin) for mixin in class_def.mixins])
369
- if len(class_parents) > 0:
370
- # Use the sorted list of classes to order the parent classes, but reversed to match MRO needs
371
- class_parents.sort(key=lambda x: self.sorted_class_names.index(x))
372
- class_parents.reverse()
373
- parents[camelcase(class_def.name)] = class_parents
374
- return parents
560
+ if self._class_bases is None:
561
+ sv = self.schemaview
562
+ parents = {}
563
+ for class_def in sv.all_classes().values():
564
+ class_parents = []
565
+ if class_def.is_a:
566
+ class_parents.append(camelcase(class_def.is_a))
567
+ if self.gen_mixin_inheritance and class_def.mixins:
568
+ class_parents.extend([camelcase(mixin) for mixin in class_def.mixins])
569
+ if len(class_parents) > 0:
570
+ # Use the sorted list of classes to order the parent classes, but reversed to match MRO needs
571
+ class_parents.sort(
572
+ key=lambda x: self.sorted_class_names.index(x) if x in self.sorted_class_names else -1
573
+ )
574
+ class_parents.reverse()
575
+ parents[camelcase(class_def.name)] = class_parents
576
+ self._class_bases = parents
577
+ return self._class_bases
375
578
 
376
579
  def get_mixin_identifier_range(self, mixin) -> str:
377
580
  sv = self.schemaview
@@ -442,6 +645,10 @@ class PydanticGenerator(OOCodeGenerator):
442
645
  + ",".join(['"' + x + '"' for x in get_accepted_type_designator_values(sv, slot_def, class_def)])
443
646
  + "]"
444
647
  )
648
+ elif slot_def.equals_string:
649
+ pyrange = f'Literal["{slot_def.equals_string}"]'
650
+ elif slot_def.equals_string_in:
651
+ pyrange = "Literal[" + ", ".join([f'"{a_string}"' for a_string in slot_def.equals_string_in]) + "]"
445
652
  elif slot_range in sv.all_classes():
446
653
  pyrange = self.get_class_slot_range(
447
654
  slot_range,
@@ -497,8 +704,18 @@ class PydanticGenerator(OOCodeGenerator):
497
704
  return list(collection_keys)[0]
498
705
  return None
499
706
 
500
- @staticmethod
501
- def _inline_as_simple_dict_with_value(slot_def: SlotDefinition, sv: SchemaView) -> Optional[str]:
707
+ def _clean_injected_classes(self, injected_classes: List[Union[str, Type]]) -> Optional[List[str]]:
708
+ """Get source, deduplicate, and dedent injected classes"""
709
+ if len(injected_classes) == 0:
710
+ return None
711
+
712
+ injected_classes = list(
713
+ dict.fromkeys([c if isinstance(c, str) else inspect.getsource(c) for c in injected_classes])
714
+ )
715
+ injected_classes = [textwrap.dedent(c) for c in injected_classes]
716
+ return injected_classes
717
+
718
+ def _inline_as_simple_dict_with_value(self, slot_def: SlotDefinition) -> Optional[str]:
502
719
  """
503
720
  Determine if a slot should be inlined as a simple dict with a value.
504
721
 
@@ -521,21 +738,21 @@ class PydanticGenerator(OOCodeGenerator):
521
738
  :return: str
522
739
  """
523
740
  if slot_def.inlined and not slot_def.inlined_as_list:
524
- if slot_def.range in sv.all_classes():
525
- id_slot = sv.get_identifier_slot(slot_def.range, use_key=True)
741
+ if slot_def.range in self.schemaview.all_classes():
742
+ id_slot = self.schemaview.get_identifier_slot(slot_def.range, use_key=True)
526
743
  if id_slot is not None:
527
- range_cls_slots = sv.class_induced_slots(slot_def.range)
744
+ range_cls_slots = self.schemaview.class_induced_slots(slot_def.range)
528
745
  if len(range_cls_slots) == 2:
529
746
  non_id_slots = [slot for slot in range_cls_slots if slot.name != id_slot.name]
530
747
  if len(non_id_slots) == 1:
531
748
  value_slot = non_id_slots[0]
532
- value_slot_range_type = sv.get_type(value_slot.range)
749
+ value_slot_range_type = self.schemaview.get_type(value_slot.range)
533
750
  if value_slot_range_type is not None:
534
- return _get_pyrange(value_slot_range_type, sv)
751
+ return _get_pyrange(value_slot_range_type, self.schemaview)
535
752
  return None
536
753
 
537
754
  def _template_environment(self) -> Environment:
538
- env = TemplateModel.environment()
755
+ env = PydanticTemplateModel.environment()
539
756
  if self.template_dir is not None:
540
757
  loader = ChoiceLoader([FileSystemLoader(self.template_dir), env.loader])
541
758
  env.loader = loader
@@ -548,7 +765,7 @@ class PydanticGenerator(OOCodeGenerator):
548
765
  array_reps = []
549
766
  for repr in self.array_representations:
550
767
  generator = ArrayRangeGenerator.get_generator(repr)
551
- result = generator(slot.array, range, self.pydantic_version).make()
768
+ result = generator(slot.array, range).make()
552
769
  array_reps.append(result)
553
770
 
554
771
  if len(array_reps) == 0:
@@ -572,7 +789,7 @@ class PydanticGenerator(OOCodeGenerator):
572
789
  Metadata inclusion mode is dependent on :attr:`.metadata_mode` - see:
573
790
 
574
791
  - :class:`.MetadataMode`
575
- - :meth:`.TemplateModel.exclude_from_meta`
792
+ - :meth:`.PydanticTemplateModel.exclude_from_meta`
576
793
 
577
794
  """
578
795
  if self.metadata_mode is None or self.metadata_mode == MetadataMode.NONE:
@@ -591,13 +808,15 @@ class PydanticGenerator(OOCodeGenerator):
591
808
  continue
592
809
 
593
810
  model_attr = getattr(model, k)
594
- if isinstance(model_attr, list) and not any([isinstance(item, TemplateModel) for item in model_attr]):
811
+ if isinstance(model_attr, list) and not any(
812
+ [isinstance(item, PydanticTemplateModel) for item in model_attr]
813
+ ):
595
814
  meta[k] = v
596
815
  elif isinstance(model_attr, dict) and not any(
597
- [isinstance(item, TemplateModel) for item in model_attr.values()]
816
+ [isinstance(item, PydanticTemplateModel) for item in model_attr.values()]
598
817
  ):
599
818
  meta[k] = v
600
- elif not isinstance(model_attr, (list, dict, TemplateModel)):
819
+ elif not isinstance(model_attr, (list, dict, PydanticTemplateModel)):
601
820
  meta[k] = v
602
821
 
603
822
  elif self.metadata_mode in (MetadataMode.FULL, MetadataMode.FULL.value):
@@ -611,155 +830,145 @@ class PydanticGenerator(OOCodeGenerator):
611
830
  model.meta = meta
612
831
  return model
613
832
 
833
+ def _get_imports(self, element: Union[ClassDefinition, SlotDefinition, None] = None) -> Imports:
834
+ """
835
+ Get imports that are implied by their usage in slots or classes
836
+ (and thus need to be imported when generating schemas in :attr:`.split` == ``True`` mode).
837
+
838
+ **Note:**
839
+ Since in pydantic (currently) the only things that are materialized are classes, we don't
840
+ import class slots from imported schemas and abandon slots, directly expressing them
841
+ in the model.
842
+
843
+ This is a parent placeholder method in case that changes, "give me something and return
844
+ a set of imports" that calls subordinate methods. If slots become materialized, keep
845
+ this as the directly called method rather than spaghetti-ing out another
846
+ independent method. This method is also isolated in anticipation of structured imports,
847
+ where we will need to revise our expectations of what is imported when.
848
+
849
+ Args:
850
+ element (:class:`.ClassDefinition` , :class:`.SlotDefinition` , None): The element
851
+ to get import for. If ``None`` , get all needed imports (see :attr:`.split_mode`
852
+ """
853
+ # import from local references, rather than serializing every class in every file
854
+ if not self.split or (self.split_mode == SplitMode.FULL and element is not None):
855
+ # we are either compiling this whole thing in one big file (default)
856
+ # or going to import all classes from the imported schemas,
857
+ # so we don't import anything
858
+ return Imports()
859
+
860
+ # gather a list of class names,
861
+ # remove local classes and transform to Imports later.
862
+ needed_classes = []
863
+
864
+ # fine to call rather than pass bc it's cached
865
+ all_classes = self.schemaview.all_classes(imports=True)
866
+ local_classes = self.schemaview.all_classes(imports=False)
867
+
868
+ if isinstance(element, ClassDefinition):
869
+ if element.is_a:
870
+ needed_classes.append(element.is_a)
871
+ if element.mixins:
872
+ needed_classes.extend(element.mixins)
873
+
874
+ elif isinstance(element, SlotDefinition):
875
+ # collapses `slot.range`, `slot.any_of`, and `slot.one_of` to a list
876
+ slot_ranges = self.schemaview.slot_range_as_union(element)
877
+ needed_classes.extend([a_range for a_range in slot_ranges if a_range in all_classes])
878
+
879
+ elif element is None:
880
+ # get all imports
881
+ needed_classes.extend([cls for cls in all_classes if cls not in local_classes])
882
+
883
+ else:
884
+ raise ValueError(f"Unsupported type of element to get imports from: f{type(element)}")
885
+
886
+ # SPECIAL CASE: classes that are not generated for structural reasons.
887
+ # TODO: Do we want to have a general means of skipping class generation?
888
+ skips = ("AnyType",)
889
+
890
+ class_imports = [
891
+ self._get_element_import(cls) for cls in needed_classes if (cls not in local_classes and cls not in skips)
892
+ ]
893
+ imports = Imports(imports=class_imports)
894
+
895
+ return imports
896
+
897
+ def generate_module_import(self, schema: SchemaDefinition, context: Optional[dict] = None) -> str:
898
+ """
899
+ Generate the module string for importing from python modules generated from imported schemas
900
+ when in :attr:`.split` mode.
901
+
902
+ Use the :attr:`.split_pattern` as a jinja template rendered with the :class:`.SchemaDefinition`
903
+ and any passed ``context``. Apply the :attr:`.SNAKE_CASE` regex to substitute matches with
904
+ ``_`` and ensure lowercase.
905
+ """
906
+ if context is None:
907
+ context = {}
908
+ module = Template(self.split_pattern).render(schema=schema, **context)
909
+ module = re.sub(self.SNAKE_CASE, "_", module) if self.SNAKE_CASE else module
910
+ module = module.lower()
911
+ return module
912
+
913
+ def _get_element_import(self, class_name: ElementName) -> Import:
914
+ """
915
+ Make an import object for an element from another schema, using the
916
+ :attr:`.split_import_pattern` to generate the module import part.
917
+ """
918
+ schema_name = self.schemaview.element_by_schema_map()[class_name]
919
+ schema = [s for s in self.schemaview.schema_map.values() if s.name == schema_name][0]
920
+ module = self.generate_module_import(schema, self.split_context)
921
+ return Import(module=module, objects=[ObjectImport(name=camelcase(class_name))], is_schema=True)
922
+
614
923
  def render(self) -> PydanticModule:
615
924
  sv: SchemaView
616
925
  sv = self.schemaview
617
- schema = sv.schema
618
- pyschema = SchemaDefinition(
619
- id=schema.id,
620
- name=schema.name,
621
- description=schema.description.replace('"', '\\"') if schema.description else None,
622
- )
623
- enums = self.generate_enums(sv.all_enums())
624
- injected_classes = copy(DEFAULT_INJECTS[self.pydantic_version])
625
- if self.injected_classes is not None:
626
- injected_classes += self.injected_classes
627
926
 
927
+ # imports
628
928
  imports = DEFAULT_IMPORTS
629
929
  if self.imports is not None:
630
930
  for i in self.imports:
631
931
  imports += i
932
+ if self.split_mode == SplitMode.FULL:
933
+ imports += self._get_imports()
632
934
 
633
- sorted_classes = self.sort_classes(list(sv.all_classes().values()))
634
- self.sorted_class_names = [camelcase(c.name) for c in sorted_classes]
935
+ # injected classes
936
+ injected_classes = DEFAULT_INJECTS.copy()
937
+ if self.injected_classes is not None:
938
+ injected_classes += self.injected_classes.copy()
939
+
940
+ # enums
941
+ enums = self.before_generate_enums(list(sv.all_enums().values()), sv)
942
+ enums = self.generate_enums({e.name: e for e in enums})
943
+
944
+ base_model = PydanticBaseModel(extra_fields=self.extra_fields, fields=self.injected_fields)
635
945
 
946
+ # schema classes
947
+ class_results = []
948
+ source_classes, imported_classes = self._get_classes(sv)
949
+ source_classes = self.sort_classes(source_classes, imported_classes)
636
950
  # Don't want to generate classes when class_uri is linkml:Any, will
637
951
  # just swap in typing.Any instead down below
638
- sorted_classes = [c for c in sorted_classes if c.class_uri != "linkml:Any"]
639
-
640
- for class_original in sorted_classes:
641
- class_def: ClassDefinition
642
- class_def = deepcopy(class_original)
643
- class_name = class_original.name
644
- class_def.name = camelcase(class_original.name)
645
- if class_def.is_a:
646
- class_def.is_a = camelcase(class_def.is_a)
647
- class_def.mixins = [camelcase(p) for p in class_def.mixins]
648
- if class_def.description:
649
- class_def.description = class_def.description.replace('"', '\\"')
650
- pyschema.classes[class_def.name] = class_def
651
- for attribute in list(class_def.attributes.keys()):
652
- del class_def.attributes[attribute]
653
- for sn in sv.class_slots(class_name):
654
- # TODO: fix runtime, copy should not be necessary
655
- s = deepcopy(sv.induced_slot(sn, class_name))
656
- # logging.error(f'Induced slot {class_name}.{sn} == {s.name} {s.range}')
657
- s.name = underscore(s.name)
658
- if s.description:
659
- s.description = s.description.replace('"', '\\"')
660
- class_def.attributes[s.name] = s
661
-
662
- slot_ranges: List[str] = []
663
-
664
- # Confirm that the original slot range (ignoring the default that comes in from
665
- # induced_slot) isn't in addition to setting any_of
666
- any_of_ranges = [a.range if a.range else s.range for a in s.any_of]
667
- if any_of_ranges:
668
- # list comprehension here is pulling ranges from within AnonymousSlotExpression
669
- slot_ranges.extend(any_of_ranges)
670
- else:
671
- slot_ranges.append(s.range)
952
+ source_classes = [c for c in source_classes if c.class_uri != "linkml:Any"]
953
+ source_classes = self.before_generate_classes(source_classes, sv)
954
+ self.sorted_class_names = [camelcase(c.name) for c in source_classes]
955
+ for cls in source_classes:
956
+ cls = self.before_generate_class(cls, sv)
957
+ result = self.generate_class(cls)
958
+ result = self.after_generate_class(result, sv)
959
+ class_results.append(result)
960
+ if result.imports is not None:
961
+ imports += result.imports
962
+ if result.injected_classes is not None:
963
+ injected_classes.extend(result.injected_classes)
672
964
 
673
- pyranges = [self.generate_python_range(slot_range, s, class_def) for slot_range in slot_ranges]
965
+ class_results = self.after_generate_classes(class_results, sv)
674
966
 
675
- pyranges = list(set(pyranges)) # remove duplicates
676
- pyranges.sort()
967
+ classes = {r.cls.name: r.cls for r in class_results}
677
968
 
678
- if len(pyranges) == 1:
679
- pyrange = pyranges[0]
680
- elif len(pyranges) > 1:
681
- pyrange = f"Union[{', '.join(pyranges)}]"
682
- else:
683
- raise Exception(f"Could not generate python range for {class_name}.{s.name}")
684
-
685
- if s.array is not None:
686
- # TODO add support for xarray
687
- results = self.get_array_representations_range(s, pyrange)
688
- # TODO: Move results unpacking to own function that is used after each slot build stage :)
689
- for res in results:
690
- if res.injected_classes:
691
- injected_classes += res.injected_classes
692
- if res.imports:
693
- imports += res.imports
694
- if len(results) == 1:
695
- pyrange = results[0].annotation
696
- else:
697
- pyrange = f"Union[{', '.join([res.annotation for res in results])}]"
698
-
699
- if "linkml:ColumnOrderedArray" in class_def.implements:
700
- raise NotImplementedError("Cannot generate Pydantic code for ColumnOrderedArrays.")
701
- elif s.multivalued:
702
- if s.inlined or s.inlined_as_list:
703
- collection_key = self.generate_collection_key(slot_ranges, s, class_def)
704
- else:
705
- collection_key = None
706
- if s.inlined is False or collection_key is None or s.inlined_as_list is True:
707
- pyrange = f"List[{pyrange}]"
708
- else:
709
- simple_dict_value = None
710
- if len(slot_ranges) == 1:
711
- simple_dict_value = self._inline_as_simple_dict_with_value(s, sv)
712
- if simple_dict_value:
713
- # inlining as simple dict
714
- pyrange = f"Dict[str, {simple_dict_value}]"
715
- else:
716
- pyrange = f"Dict[{collection_key}, {pyrange}]"
717
- if not (s.required or s.identifier or s.key) and not s.designates_type:
718
- pyrange = f"Optional[{pyrange}]"
719
- ann = Annotation("python_range", pyrange)
720
- s.annotations[ann.tag] = ann
721
-
722
- # TODO: Make cleaning injected classes its own method
723
- injected_classes = list(
724
- dict.fromkeys([c if isinstance(c, str) else inspect.getsource(c) for c in injected_classes])
725
- )
726
- injected_classes = [textwrap.dedent(c) for c in injected_classes]
727
-
728
- base_model = PydanticBaseModel(
729
- pydantic_ver=self.pydantic_version, extra_fields=self.extra_fields, fields=self.injected_fields
730
- )
731
-
732
- classes = {}
733
- predefined = self.get_predefined_slot_values()
734
- bases = self.get_class_isa_plus_mixins()
735
- for k, c in pyschema.classes.items():
736
- attrs = {}
737
- for attr_name, src_attr in c.attributes.items():
738
- src_attr = src_attr._as_dict
739
- new_fields = {
740
- k: src_attr.get(k, None)
741
- for k in PydanticAttribute.model_fields.keys()
742
- if src_attr.get(k, None) is not None
743
- }
744
- predef_slot = predefined.get(k, {}).get(attr_name, None)
745
- if predef_slot is not None:
746
- predef_slot = str(predef_slot)
747
- new_fields["predefined"] = predef_slot
748
- new_fields["name"] = attr_name
749
-
750
- attrs[attr_name] = PydanticAttribute(**new_fields, pydantic_ver=self.pydantic_version)
751
- attrs[attr_name] = self.include_metadata(attrs[attr_name], src_attr)
752
-
753
- new_class = PydanticClass(
754
- name=k, attributes=attrs, description=c.description, pydantic_ver=self.pydantic_version
755
- )
756
- new_class = self.include_metadata(new_class, c)
757
- if k in bases:
758
- new_class.bases = bases[k]
759
- classes[k] = new_class
969
+ injected_classes = self._clean_injected_classes(injected_classes)
760
970
 
761
971
  module = PydanticModule(
762
- pydantic_ver=self.pydantic_version,
763
972
  metamodel_version=self.schema.metamodel_version,
764
973
  version=self.schema.version,
765
974
  python_imports=imports.imports,
@@ -768,22 +977,166 @@ class PydanticGenerator(OOCodeGenerator):
768
977
  enums=enums,
769
978
  classes=classes,
770
979
  )
771
- module = self.include_metadata(module, schema)
980
+ module = self.include_metadata(module, self.schemaview.schema)
981
+ module = self.before_render_template(module, self.schemaview)
772
982
  return module
773
983
 
774
- def serialize(self) -> str:
775
- module = self.render()
776
- return module.render(self._template_environment(), self.black)
984
+ def serialize(self, rendered_module: Optional[PydanticModule] = None) -> str:
985
+ """
986
+ Serialize the schema to a pydantic module as a string
987
+
988
+ Args:
989
+ rendered_module ( :class:`.PydanticModule` ): Optional, if schema was previously
990
+ rendered with :meth:`.render` , use that, otherwise :meth:`.render` fresh.
991
+ """
992
+ if rendered_module is not None:
993
+ module = rendered_module
994
+ else:
995
+ module = self.render()
996
+ serialized = module.render(self._template_environment(), self.black)
997
+ serialized = self.after_render_template(serialized, self.schemaview)
998
+ return serialized
777
999
 
778
1000
  def default_value_for_type(self, typ: str) -> str:
779
1001
  return "None"
780
1002
 
1003
+ @classmethod
1004
+ def generate_split(
1005
+ cls,
1006
+ schema: Union[str, Path, SchemaDefinition],
1007
+ output_path: Union[str, Path] = Path("."),
1008
+ split_pattern: Optional[str] = None,
1009
+ split_context: Optional[dict] = None,
1010
+ split_mode: SplitMode = SplitMode.AUTO,
1011
+ **kwargs,
1012
+ ) -> List[SplitResult]:
1013
+ """
1014
+ Generate a schema that imports from other schema as a set of python modules that
1015
+ import from one another, rather than generating all imported classes in a single schema.
1016
+
1017
+ Uses ``output_path`` for the main schema from ``schema`` , and then
1018
+ generates any imported schema (from which classes are actually used)
1019
+ to modules whose locations are determined by the module names generated
1020
+ by the ``split_pattern`` (see :attr:`.PydanticGenerator.split_pattern` ).
1021
+
1022
+ For example, for
1023
+
1024
+ * a ``output_path`` of ``my_dir/v1_2_3/main.py``
1025
+ * a schema ``main`` with a version ``v1.2.3``
1026
+ * that imports from ``s2`` with version ``v4.5.6``,
1027
+ * and a ``split_pattern`` of ``..{{ schema.version | replace('.', '_') }}.{{ schema.name }}``
1028
+
1029
+ One would get:
1030
+ * ``my_dir/v1_2_3/main.py`` , as expected
1031
+ * that imports ``from ..v4_5_6.s2``
1032
+ * a module at ``my_dir/v4_5_6/s2.py``
1033
+
1034
+ ``__init__.py`` files are generated for any directories that are between
1035
+ the generated modules and their highest common directory.
1036
+
1037
+ Args:
1038
+ schema (str, :class:`.Path` , :class:`.SchemaDefinition` ): Main schema to generate
1039
+ output_path (str, :class:`.Path` ): Python ``.py`` module to generate main schema to
1040
+ split_pattern (str): Pattern to use to generate module names, see :attr:`.PydanticGenerator.split_pattern`
1041
+ split_context (dict): Additional variables to pass into jinja context when generating module import names.
1042
+
1043
+ Returns:
1044
+ list[:class:`.SplitResult`]
1045
+ """
1046
+ output_path = Path(output_path)
1047
+ if not output_path.suffix == ".py":
1048
+ raise ValueError(f"output path must be a python file to write the main schema to, got {output_path}")
1049
+
1050
+ results = []
1051
+
1052
+ # --------------------------------------------------
1053
+ # Main schema
1054
+ # --------------------------------------------------
1055
+ gen_kwargs = kwargs
1056
+ gen_kwargs.update(
1057
+ {"split": True, "split_pattern": split_pattern, "split_context": split_context, "split_mode": split_mode}
1058
+ )
1059
+ generator = cls(schema, **gen_kwargs)
1060
+ # Generate the initial schema to figure out which of the imported schema actually need
1061
+ # to be generated
1062
+ rendered = generator.render()
1063
+ # write schema - we use the ``output_path`` for the main schema, and then
1064
+ # interpret all imported schema paths as relative to that
1065
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1066
+ serialized = generator.serialize(rendered_module=rendered)
1067
+ with open(output_path, "w", encoding="utf-8") as ofile:
1068
+ ofile.write(serialized)
1069
+
1070
+ results.append(
1071
+ SplitResult(main=True, source=generator.schemaview.schema, path=output_path, serialized_module=serialized)
1072
+ )
1073
+
1074
+ # --------------------------------------------------
1075
+ # Imported schemas
1076
+ # --------------------------------------------------
1077
+ imported_schema = {
1078
+ generator.generate_module_import(sch): sch for sch in generator.schemaview.schema_map.values()
1079
+ }
1080
+ for generated_import in [i for i in rendered.python_imports if i.is_schema]:
1081
+ import_generator = cls(imported_schema[generated_import.module], **gen_kwargs)
1082
+ serialized = import_generator.serialize()
1083
+ rel_path = _import_to_path(generated_import.module)
1084
+ abs_path = (output_path.parent / rel_path).resolve()
1085
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
1086
+ with open(abs_path, "w", encoding="utf-8") as ofile:
1087
+ ofile.write(serialized)
1088
+
1089
+ results.append(
1090
+ SplitResult(
1091
+ main=False,
1092
+ source=imported_schema[generated_import.module],
1093
+ path=abs_path,
1094
+ serialized_module=serialized,
1095
+ module_import=generated_import.module,
1096
+ )
1097
+ )
1098
+
1099
+ _ensure_inits([r.path for r in results])
1100
+ return results
1101
+
781
1102
 
782
1103
  def _subclasses(cls: Type):
783
1104
  return set(cls.__subclasses__()).union([s for c in cls.__subclasses__() for s in _subclasses(c)])
784
1105
 
785
1106
 
786
- _TEMPLATE_NAMES = sorted(list(set([c.template for c in _subclasses(TemplateModel)])))
1107
+ _TEMPLATE_NAMES = sorted(list(set([c.template for c in _subclasses(PydanticTemplateModel)])))
1108
+
1109
+
1110
+ def _import_to_path(module: str) -> Path:
1111
+ """Make a (relative) ``Path`` object from a python module import string"""
1112
+ # handle leading .'s separately..
1113
+ _, dots, module = re.split(r"(^\.*)(?=\w)", module, maxsplit=1)
1114
+ # treat zero or one dots as a relative import to the current directory
1115
+ dir_pieces = ["../" for _ in range(max(len(dots) - 1, 0))]
1116
+ dir_pieces.extend(module.split("."))
1117
+ dir_pieces[-1] = dir_pieces[-1] + ".py"
1118
+ return Path(*dir_pieces)
1119
+
1120
+
1121
+ def _ensure_inits(paths: List[Path]):
1122
+ """For a set of paths, find the common root and it and all the subdirectories have an __init__.py"""
1123
+ # if there is only one file, there is no relative importing to be done
1124
+ if len(paths) <= 1:
1125
+ return
1126
+ common_path = Path(os.path.commonpath(paths))
1127
+
1128
+ if not (ipath := (common_path / "__init__.py")).exists():
1129
+ with open(ipath, "w", encoding="utf-8") as ifile:
1130
+ ifile.write(" \n")
1131
+
1132
+ for path in paths:
1133
+ # ensure __init__ for each directory from this path up to the common path
1134
+ path = path.parent
1135
+ while path != common_path:
1136
+ if not (ipath := (path / "__init__.py")).exists():
1137
+ with open(ipath, "w", encoding="utf-8") as ifile:
1138
+ ifile.write(" \n")
1139
+ path = path.parent
787
1140
 
788
1141
 
789
1142
  @shared_arguments(PydanticGenerator)
@@ -795,22 +1148,16 @@ _TEMPLATE_NAMES = sorted(list(set([c.template for c in _subclasses(TemplateModel
795
1148
  Optional jinja2 template directory to use for class generation.
796
1149
 
797
1150
  Pass a directory containing templates with the same name as any of the default
798
- :class:`.TemplateModel` templates to override them. The given directory will be
1151
+ :class:`.PydanticTemplateModel` templates to override them. The given directory will be
799
1152
  searched for matching templates, and use the default templates as a fallback
800
1153
  if an override is not found
801
-
1154
+
802
1155
  Available templates to override:
803
1156
 
804
1157
  \b
805
1158
  """
806
1159
  + "\n".join(["- " + name for name in _TEMPLATE_NAMES]),
807
1160
  )
808
- @click.option(
809
- "--pydantic-version",
810
- type=click.IntRange(1, 2),
811
- default=int(PYDANTIC_VERSION[0]),
812
- help="Pydantic version to use (1 or 2)",
813
- )
814
1161
  @click.option(
815
1162
  "--array-representations",
816
1163
  type=click.Choice([k.value for k in ArrayRepresentation]),
@@ -839,7 +1186,7 @@ Available templates to override:
839
1186
  "Default (auto) is to include all metadata that can't be otherwise represented",
840
1187
  )
841
1188
  @click.version_option(__version__, "-V", "--version")
842
- @click.command()
1189
+ @click.command(name="pydantic")
843
1190
  def cli(
844
1191
  yamlfile,
845
1192
  template_file=None,
@@ -849,7 +1196,6 @@ def cli(
849
1196
  classvars=True,
850
1197
  slots=True,
851
1198
  array_representations=list("list"),
852
- pydantic_version=int(PYDANTIC_VERSION[0]),
853
1199
  extra_fields: Literal["allow", "forbid", "ignore"] = "forbid",
854
1200
  black: bool = False,
855
1201
  meta: MetadataMode = "auto",
@@ -870,7 +1216,6 @@ def cli(
870
1216
 
871
1217
  gen = PydanticGenerator(
872
1218
  yamlfile,
873
- pydantic_version=pydantic_version,
874
1219
  array_representations=[ArrayRepresentation(x) for x in array_representations],
875
1220
  extra_fields=extra_fields,
876
1221
  emit_metadata=head,