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.
- linkml/cli/__init__.py +0 -0
- linkml/cli/__main__.py +4 -0
- linkml/cli/main.py +130 -0
- linkml/generators/common/build.py +105 -0
- linkml/generators/common/ifabsent_processor.py +286 -0
- linkml/generators/common/lifecycle.py +124 -0
- linkml/generators/common/template.py +89 -0
- linkml/generators/csvgen.py +1 -1
- linkml/generators/docgen/slot.md.jinja2 +4 -0
- linkml/generators/docgen.py +1 -1
- linkml/generators/dotgen.py +1 -1
- linkml/generators/erdiagramgen.py +1 -1
- linkml/generators/excelgen.py +1 -1
- linkml/generators/golanggen.py +1 -1
- linkml/generators/golrgen.py +1 -1
- linkml/generators/graphqlgen.py +1 -1
- linkml/generators/javagen.py +1 -1
- linkml/generators/jsonldcontextgen.py +4 -5
- linkml/generators/jsonldgen.py +1 -1
- linkml/generators/jsonschemagen.py +69 -22
- linkml/generators/linkmlgen.py +1 -1
- linkml/generators/markdowngen.py +1 -1
- linkml/generators/namespacegen.py +1 -1
- linkml/generators/oocodegen.py +2 -1
- linkml/generators/owlgen.py +1 -1
- linkml/generators/plantumlgen.py +1 -1
- linkml/generators/prefixmapgen.py +1 -1
- linkml/generators/projectgen.py +1 -1
- linkml/generators/protogen.py +1 -1
- linkml/generators/pydanticgen/__init__.py +8 -3
- linkml/generators/pydanticgen/array.py +114 -194
- linkml/generators/pydanticgen/build.py +64 -25
- linkml/generators/pydanticgen/includes.py +1 -31
- linkml/generators/pydanticgen/pydanticgen.py +621 -276
- linkml/generators/pydanticgen/template.py +152 -184
- linkml/generators/pydanticgen/templates/attribute.py.jinja +9 -7
- linkml/generators/pydanticgen/templates/base_model.py.jinja +0 -13
- linkml/generators/pydanticgen/templates/class.py.jinja +2 -2
- linkml/generators/pydanticgen/templates/footer.py.jinja +2 -10
- linkml/generators/pydanticgen/templates/module.py.jinja +2 -2
- linkml/generators/pydanticgen/templates/validator.py.jinja +0 -4
- linkml/generators/python/__init__.py +1 -0
- linkml/generators/python/python_ifabsent_processor.py +92 -0
- linkml/generators/pythongen.py +19 -23
- linkml/generators/rdfgen.py +1 -1
- linkml/generators/shacl/__init__.py +1 -3
- linkml/generators/shacl/shacl_data_type.py +1 -1
- linkml/generators/shacl/shacl_ifabsent_processor.py +89 -0
- linkml/generators/shaclgen.py +11 -5
- linkml/generators/shexgen.py +1 -1
- linkml/generators/sparqlgen.py +1 -1
- linkml/generators/sqlalchemygen.py +1 -1
- linkml/generators/sqltablegen.py +1 -1
- linkml/generators/sssomgen.py +1 -1
- linkml/generators/summarygen.py +1 -1
- linkml/generators/terminusdbgen.py +7 -4
- linkml/generators/typescriptgen.py +1 -1
- linkml/generators/yamlgen.py +1 -1
- linkml/generators/yumlgen.py +1 -1
- linkml/linter/cli.py +1 -1
- linkml/transformers/logical_model_transformer.py +117 -18
- linkml/utils/converter.py +1 -1
- linkml/utils/execute_tutorial.py +2 -0
- linkml/utils/logictools.py +142 -29
- linkml/utils/schema_builder.py +7 -6
- linkml/utils/schema_fixer.py +1 -1
- linkml/utils/sqlutils.py +1 -1
- linkml/validator/cli.py +4 -1
- linkml/validators/jsonschemavalidator.py +1 -1
- linkml/validators/sparqlvalidator.py +1 -1
- linkml/workspaces/example_runner.py +1 -1
- {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/METADATA +2 -2
- {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/RECORD +76 -68
- {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/entry_points.txt +1 -1
- linkml/generators/shacl/ifabsent_processor.py +0 -59
- linkml/utils/ifabsent_functions.py +0 -138
- {linkml-1.8.1.dist-info → linkml-1.8.3.dist-info}/LICENSE +0 -0
- {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
|
-
|
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(
|
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(
|
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 =
|
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
|
-
|
188
|
+
|
154
189
|
template_dir: Optional[Union[str, Path]] = None
|
155
190
|
"""
|
156
|
-
Override templates for each
|
191
|
+
Override templates for each PydanticTemplateModel.
|
157
192
|
|
158
|
-
Directory with templates that override the default :attr:`.
|
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(
|
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
|
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
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
slot_values[camelcase(class_def.name)][slot.name] =
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
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
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
501
|
-
|
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
|
525
|
-
id_slot =
|
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 =
|
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 =
|
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,
|
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 =
|
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
|
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:`.
|
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(
|
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,
|
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,
|
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
|
-
|
634
|
-
|
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
|
-
|
639
|
-
|
640
|
-
for
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
if
|
649
|
-
|
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
|
-
|
965
|
+
class_results = self.after_generate_classes(class_results, sv)
|
674
966
|
|
675
|
-
|
676
|
-
pyranges.sort()
|
967
|
+
classes = {r.cls.name: r.cls for r in class_results}
|
677
968
|
|
678
|
-
|
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
|
-
|
776
|
-
|
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(
|
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:`.
|
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,
|