linkml 1.7.5__py3-none-any.whl → 1.7.7__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 (44) hide show
  1. linkml/generators/__init__.py +2 -0
  2. linkml/generators/docgen/class.md.jinja2 +15 -2
  3. linkml/generators/docgen/slot.md.jinja2 +18 -4
  4. linkml/generators/docgen.py +17 -3
  5. linkml/generators/jsonldcontextgen.py +40 -17
  6. linkml/generators/jsonldgen.py +3 -1
  7. linkml/generators/owlgen.py +16 -0
  8. linkml/generators/prefixmapgen.py +5 -4
  9. linkml/generators/projectgen.py +14 -2
  10. linkml/generators/pydanticgen/__init__.py +29 -0
  11. linkml/generators/pydanticgen/array.py +457 -0
  12. linkml/generators/pydanticgen/black.py +29 -0
  13. linkml/generators/pydanticgen/build.py +79 -0
  14. linkml/generators/{pydanticgen.py → pydanticgen/pydanticgen.py} +252 -304
  15. linkml/generators/pydanticgen/template.py +577 -0
  16. linkml/generators/pydanticgen/templates/attribute.py.jinja +10 -0
  17. linkml/generators/pydanticgen/templates/base_model.py.jinja +29 -0
  18. linkml/generators/pydanticgen/templates/class.py.jinja +21 -0
  19. linkml/generators/pydanticgen/templates/conditional_import.py.jinja +9 -0
  20. linkml/generators/pydanticgen/templates/enum.py.jinja +16 -0
  21. linkml/generators/pydanticgen/templates/footer.py.jinja +13 -0
  22. linkml/generators/pydanticgen/templates/imports.py.jinja +31 -0
  23. linkml/generators/pydanticgen/templates/module.py.jinja +27 -0
  24. linkml/generators/pydanticgen/templates/validator.py.jinja +15 -0
  25. linkml/generators/pythongen.py +13 -7
  26. linkml/generators/shacl/__init__.py +3 -0
  27. linkml/generators/shacl/ifabsent_processor.py +59 -0
  28. linkml/generators/shacl/shacl_data_type.py +40 -0
  29. linkml/generators/shaclgen.py +105 -82
  30. linkml/generators/shexgen.py +1 -1
  31. linkml/generators/sqlalchemygen.py +1 -1
  32. linkml/generators/sqltablegen.py +32 -22
  33. linkml/generators/terminusdbgen.py +7 -1
  34. linkml/linter/config/datamodel/config.py +8 -0
  35. linkml/linter/rules.py +11 -2
  36. linkml/utils/generator.py +7 -6
  37. linkml/utils/ifabsent_functions.py +7 -9
  38. linkml/utils/schemaloader.py +1 -9
  39. linkml/utils/sqlutils.py +39 -25
  40. {linkml-1.7.5.dist-info → linkml-1.7.7.dist-info}/METADATA +9 -4
  41. {linkml-1.7.5.dist-info → linkml-1.7.7.dist-info}/RECORD +44 -27
  42. {linkml-1.7.5.dist-info → linkml-1.7.7.dist-info}/LICENSE +0 -0
  43. {linkml-1.7.5.dist-info → linkml-1.7.7.dist-info}/WHEEL +0 -0
  44. {linkml-1.7.5.dist-info → linkml-1.7.7.dist-info}/entry_points.txt +0 -0
@@ -1,16 +1,24 @@
1
1
  import inspect
2
2
  import logging
3
3
  import os
4
+ import textwrap
4
5
  from collections import defaultdict
5
6
  from copy import deepcopy
6
7
  from dataclasses import dataclass, field
8
+ from pathlib import Path
7
9
  from types import ModuleType
8
- from typing import Dict, List, Optional, Set, Type, Union
10
+ from typing import (
11
+ Dict,
12
+ List,
13
+ Literal,
14
+ Optional,
15
+ Set,
16
+ Type,
17
+ Union,
18
+ )
9
19
 
10
20
  import click
11
- from jinja2 import Template
12
-
13
- # from linkml.generators import pydantic_GEN_VERSION
21
+ from jinja2 import ChoiceLoader, Environment, FileSystemLoader
14
22
  from linkml_runtime.linkml_model.meta import (
15
23
  Annotation,
16
24
  ClassDefinition,
@@ -29,255 +37,23 @@ from linkml.generators.common.type_designators import (
29
37
  get_type_designator_value,
30
38
  )
31
39
  from linkml.generators.oocodegen import OOCodeGenerator
40
+ from linkml.generators.pydanticgen.array import ArrayRangeGenerator, ArrayRepresentation
41
+ from linkml.generators.pydanticgen.build import SlotResult
42
+ from linkml.generators.pydanticgen.template import (
43
+ ConditionalImport,
44
+ Import,
45
+ Imports,
46
+ ObjectImport,
47
+ PydanticAttribute,
48
+ PydanticBaseModel,
49
+ PydanticClass,
50
+ PydanticModule,
51
+ TemplateModel,
52
+ )
32
53
  from linkml.utils.generator import shared_arguments
33
54
  from linkml.utils.ifabsent_functions import ifabsent_value_declaration
34
55
 
35
56
 
36
- def default_template(
37
- pydantic_ver: str = "1", extra_fields: str = "forbid", injected_classes: Optional[List[Union[Type, str]]] = None
38
- ) -> str:
39
- """Constructs a default template for pydantic classes based on the version of pydantic"""
40
- ### HEADER ###
41
- template = """
42
- {#-
43
-
44
- Jinja2 Template for a pydantic classes
45
- -#}
46
- from __future__ import annotations
47
- from datetime import datetime, date
48
- from enum import Enum
49
- {% if uses_numpy -%}
50
- import numpy as np
51
- {%- endif %}
52
- from decimal import Decimal
53
- from typing import List, Dict, Optional, Any, Union"""
54
- if pydantic_ver == "1":
55
- template += """
56
- from pydantic import BaseModel as BaseModel, Field, validator"""
57
- elif pydantic_ver == "2":
58
- template += """
59
- from pydantic import BaseModel as BaseModel, ConfigDict, Field, field_validator"""
60
- template += """
61
- import re
62
- import sys
63
- if sys.version_info >= (3, 8):
64
- from typing import Literal
65
- else:
66
- from typing_extensions import Literal
67
-
68
- {% if imports is not none %}
69
- {%- for import_module, import_classes in imports.items() -%}
70
- {% if import_classes is none -%}
71
- import {{ import_module }}
72
- {% elif import_classes is mapping -%}
73
- import {{ import_module }} as {{ import_classes['as'] }}
74
- {% else -%}
75
- from {{ import_module }} import (
76
- {% for imported_class in import_classes %}
77
- {%- if imported_class is string -%}
78
- {{ imported_class }}
79
- {%- else -%}
80
- {{ imported_class['name'] }} as {{ imported_class['as'] }}
81
- {%- endif -%}
82
- {%- if not loop.last %},{{ '\n ' }}{% else %}{{ '\n' }}{%- endif -%}
83
- {% endfor -%}
84
- )
85
- {% endif -%}
86
- {% endfor -%}
87
- {% endif %}
88
- metamodel_version = "{{metamodel_version}}"
89
- version = "{{version if version else None}}"
90
- """
91
- ### BASE MODEL ###
92
- if pydantic_ver == "1":
93
- template += f"""
94
- class WeakRefShimBaseModel(BaseModel):
95
- __slots__ = '__weakref__'
96
-
97
- class ConfiguredBaseModel(WeakRefShimBaseModel,
98
- validate_assignment = True,
99
- validate_all = True,
100
- underscore_attrs_are_private = True,
101
- extra = '{extra_fields}',
102
- arbitrary_types_allowed = True,
103
- use_enum_values = True):
104
- """
105
- else:
106
- template += f"""
107
- class ConfiguredBaseModel(BaseModel):
108
- model_config = ConfigDict(
109
- validate_assignment=True,
110
- validate_default=True,
111
- extra = '{extra_fields}',
112
- arbitrary_types_allowed=True,
113
- use_enum_values = True)
114
- """
115
-
116
- ### Fields injected into base model
117
- template += """{% if injected_fields is not none %}
118
- {% for field in injected_fields -%}
119
- {{ field }}
120
- {% endfor %}
121
- {% else %}
122
- pass
123
- {% endif %}
124
- """
125
-
126
- ### Extra classes
127
- if injected_classes is not None and len(injected_classes) > 0:
128
- template += """{{ '\n\n' }}"""
129
- for cls in injected_classes:
130
- if isinstance(cls, str):
131
- template += cls + "\n\n"
132
- else:
133
- template += inspect.getsource(cls) + "\n\n"
134
-
135
- ### ENUMS ###
136
- template += """
137
- {% for e in enums.values() %}
138
- class {{ e.name }}(str{% if e['values'] %}, Enum{% endif %}):
139
- {% if e.description -%}
140
- \"\"\"
141
- {{ e.description }}
142
- \"\"\"
143
- {%- endif %}
144
- {% for _, pv in e['values'].items() -%}
145
- {% if pv.description -%}
146
- # {{pv.description}}
147
- {%- endif %}
148
- {{pv.label}} = "{{pv.value}}"
149
- {% endfor %}
150
- {% if not e['values'] -%}
151
- dummy = "dummy"
152
- {% endif %}
153
- {% endfor %}
154
- """
155
- ### CLASSES ###
156
- if pydantic_ver == "1":
157
- template += """
158
- {%- for c in schema.classes.values() %}
159
- class {{ c.name }}
160
- {%- if class_isa_plus_mixins[c.name] -%}
161
- ({{class_isa_plus_mixins[c.name]|join(', ')}})
162
- {%- else -%}
163
- (ConfiguredBaseModel)
164
- {%- endif -%}
165
- :
166
- {% if c.description -%}
167
- \"\"\"
168
- {{ c.description }}
169
- \"\"\"
170
- {%- endif %}
171
- {% for attr in c.attributes.values() if c.attributes -%}
172
- {{attr.name}}: {{ attr.annotations['python_range'].value }} = Field(
173
- {%- if predefined_slot_values[c.name][attr.name] is not callable -%}
174
- {{ predefined_slot_values[c.name][attr.name] }}
175
- {%- elif (attr.required or attr.identifier or attr.key) -%}
176
- ...
177
- {%- else -%}
178
- None
179
- {%- endif -%}
180
- {%- if attr.title != None %}, title="{{attr.title}}"{% endif -%}
181
- {%- if attr.description %}, description=\"\"\"{{attr.description}}\"\"\"{% endif -%}
182
- {%- if attr.equals_number != None %}, le={{attr.equals_number}}, ge={{attr.equals_number}}
183
- {%- else -%}
184
- {%- if attr.minimum_value != None %}, ge={{attr.minimum_value}}{% endif -%}
185
- {%- if attr.maximum_value != None %}, le={{attr.maximum_value}}{% endif -%}
186
- {%- endif -%}
187
- )
188
- {% else -%}
189
- None
190
- {% endfor %}
191
- {% for attr in c.attributes.values() if c.attributes -%}
192
- {%- if attr.pattern %}
193
- @validator('{{attr.name}}', allow_reuse=True)
194
- def pattern_{{attr.name}}(cls, v):
195
- pattern=re.compile(r"{{attr.pattern}}")
196
- if isinstance(v,list):
197
- for element in v:
198
- if not pattern.match(element):
199
- raise ValueError(f"Invalid {{attr.name}} format: {element}")
200
- elif isinstance(v,str):
201
- if not pattern.match(v):
202
- raise ValueError(f"Invalid {{attr.name}} format: {v}")
203
- return v
204
- {% endif -%}
205
- {% endfor %}
206
- {% endfor %}
207
- """
208
- elif pydantic_ver == "2":
209
- template += """
210
- {%- for c in schema.classes.values() %}
211
- class {{ c.name }}
212
- {%- if class_isa_plus_mixins[c.name] -%}
213
- ({{class_isa_plus_mixins[c.name]|join(', ')}})
214
- {%- else -%}
215
- (ConfiguredBaseModel)
216
- {%- endif -%}
217
- :
218
- {% if c.description -%}
219
- \"\"\"
220
- {{ c.description }}
221
- \"\"\"
222
- {%- endif %}
223
- {% for attr in c.attributes.values() if c.attributes -%}
224
- {{attr.name}}: {{ attr.annotations['python_range'].value }} = Field(
225
- {%- if predefined_slot_values[c.name][attr.name] is not callable -%}
226
- {{ predefined_slot_values[c.name][attr.name] }}
227
- {%- elif (attr.required or attr.identifier or attr.key) -%}
228
- ...
229
- {%- else -%}
230
- None
231
- {%- endif -%}
232
- {%- if attr.title != None %}, title="{{attr.title}}"{% endif -%}
233
- {%- if attr.description %}, description=\"\"\"{{attr.description}}\"\"\"{% endif -%}
234
- {%- if attr.equals_number != None %}, le={{attr.equals_number}}, ge={{attr.equals_number}}
235
- {%- else -%}
236
- {%- if attr.minimum_value != None %}, ge={{attr.minimum_value}}{% endif -%}
237
- {%- if attr.maximum_value != None %}, le={{attr.maximum_value}}{% endif -%}
238
- {%- endif -%}
239
- )
240
- {% else -%}
241
- None
242
- {% endfor %}
243
- {% for attr in c.attributes.values() if c.attributes -%}
244
- {%- if attr.pattern %}
245
- @field_validator('{{attr.name}}')
246
- def pattern_{{attr.name}}(cls, v):
247
- pattern=re.compile(r"{{attr.pattern}}")
248
- if isinstance(v,list):
249
- for element in v:
250
- if not pattern.match(element):
251
- raise ValueError(f"Invalid {{attr.name}} format: {element}")
252
- elif isinstance(v,str):
253
- if not pattern.match(v):
254
- raise ValueError(f"Invalid {{attr.name}} format: {v}")
255
- return v
256
- {% endif -%}
257
- {% endfor %}
258
- {% endfor %}
259
- """
260
-
261
- ### FWD REFS / REBUILD MODEL ###
262
- if pydantic_ver == "1":
263
- template += """
264
- # Update forward refs
265
- # see https://pydantic-docs.helpmanual.io/usage/postponed_annotations/
266
- {% for c in schema.classes.values() -%}
267
- {{ c.name }}.update_forward_refs()
268
- {% endfor %}
269
- """
270
- else:
271
- template += """
272
- # Model rebuild
273
- # see https://pydantic-docs.helpmanual.io/usage/models/#rebuilding-a-model
274
- {% for c in schema.classes.values() -%}
275
- {{ c.name }}.model_rebuild()
276
- {% endfor %}
277
- """
278
- return template
279
-
280
-
281
57
  def _get_pyrange(t: TypeDefinition, sv: SchemaView) -> str:
282
58
  pyrange = t.repr if t is not None else None
283
59
  if pyrange is None:
@@ -293,6 +69,42 @@ def _get_pyrange(t: TypeDefinition, sv: SchemaView) -> str:
293
69
  return pyrange
294
70
 
295
71
 
72
+ DEFAULT_IMPORTS = (
73
+ Imports()
74
+ + Import(module="__future__", objects=[ObjectImport(name="annotations")])
75
+ + Import(module="datetime", objects=[ObjectImport(name="datetime"), ObjectImport(name="date")])
76
+ + Import(module="decimal", objects=[ObjectImport(name="Decimal")])
77
+ + Import(module="enum", objects=[ObjectImport(name="Enum")])
78
+ + Import(module="re")
79
+ + Import(
80
+ module="typing",
81
+ objects=[
82
+ ObjectImport(name="Any"),
83
+ ObjectImport(name="List"),
84
+ ObjectImport(name="Literal"),
85
+ ObjectImport(name="Dict"),
86
+ ObjectImport(name="Optional"),
87
+ ObjectImport(name="Union"),
88
+ ],
89
+ )
90
+ + Import(module="pydantic.version", objects=[ObjectImport(name="VERSION", alias="PYDANTIC_VERSION")])
91
+ + ConditionalImport(
92
+ condition="int(PYDANTIC_VERSION[0])>=2",
93
+ module="pydantic",
94
+ objects=[
95
+ ObjectImport(name="BaseModel"),
96
+ ObjectImport(name="ConfigDict"),
97
+ ObjectImport(name="Field"),
98
+ ObjectImport(name="field_validator"),
99
+ ],
100
+ alternative=Import(
101
+ module="pydantic",
102
+ objects=[ObjectImport(name="BaseModel"), ObjectImport(name="Field"), ObjectImport(name="validator")],
103
+ ),
104
+ )
105
+ )
106
+
107
+
296
108
  @dataclass
297
109
  class PydanticGenerator(OOCodeGenerator):
298
110
  """
@@ -308,9 +120,21 @@ class PydanticGenerator(OOCodeGenerator):
308
120
  file_extension = "py"
309
121
 
310
122
  # ObjectVars
311
- pydantic_version: str = field(default_factory=lambda: PYDANTIC_VERSION[0])
312
- template_file: str = None
313
- extra_fields: str = "forbid"
123
+ array_representations: List[ArrayRepresentation] = field(default_factory=lambda: [ArrayRepresentation.LIST])
124
+ black: bool = False
125
+ """
126
+ If black is present in the environment, format the serialized code with it
127
+ """
128
+ pydantic_version: int = int(PYDANTIC_VERSION[0])
129
+ template_dir: Optional[Union[str, Path]] = None
130
+ """
131
+ Override templates for each TemplateModel.
132
+
133
+ Directory with templates that override the default :attr:`.TemplateModel.template`
134
+ for each class. If a matching template is not found in the override directory,
135
+ the default templates will be used.
136
+ """
137
+ extra_fields: Literal["allow", "forbid", "ignore"] = "forbid"
314
138
  gen_mixin_inheritance: bool = True
315
139
  injected_classes: Optional[List[Union[Type, str]]] = None
316
140
  """
@@ -334,44 +158,54 @@ class PydanticGenerator(OOCodeGenerator):
334
158
  )
335
159
 
336
160
  """
337
- imports: Optional[Dict[str, Union[None, Dict[str, str], List[Union[str, Dict[str, str]]]]]] = None
161
+ imports: Optional[List[Import]] = None
338
162
  """
339
163
  Additional imports to inject into generated module.
340
164
 
341
- A dictionary mapping a module to a list of objects to import.
342
- If the value of an entry is ``None`` , import the whole module.
343
-
344
- Import Aliases:
345
- If the value of a module import is a dictionary with a key "as",
346
- or a class is specified as a dictionary with a "name" and "as",
347
- then the import is renamed like "from module import class as renamedClass".
348
-
349
165
  Examples:
350
166
 
351
167
  .. code-block:: python
352
168
 
353
- imports = {
354
- 'typing': ['Dict', 'List', 'Union'],
355
- 'types': None,
356
- 'numpy': {'as': 'np'},
357
- 'collections': [{'name': 'OrderedDict', 'as': 'odict'}]
358
- }
169
+ from linkml.generators.pydanticgen.template import (
170
+ ConditionalImport,
171
+ ObjectImport,
172
+ Import,
173
+ Imports
174
+ )
175
+
176
+ imports = (Imports() +
177
+ Import(module='sys') +
178
+ Import(module='numpy', alias='np') +
179
+ Import(module='pathlib', objects=[
180
+ ObjectImport(name="Path"),
181
+ ObjectImport(name="PurePath", alias="RenamedPurePath")
182
+ ]) +
183
+ ConditionalImport(
184
+ module="typing",
185
+ objects=[ObjectImport(name="Literal")],
186
+ condition="sys.version_info >= (3, 8)",
187
+ alternative=Import(
188
+ module="typing_extensions",
189
+ objects=[ObjectImport(name="Literal")]
190
+ ),
191
+ ).imports
192
+ )
359
193
 
360
194
  becomes:
361
195
 
362
196
  .. code-block:: python
363
197
 
364
- from typing import (
365
- Dict,
366
- List,
367
- Union
368
- )
369
- import types
198
+ import sys
370
199
  import numpy as np
371
- from collections import (
372
- OrderedDict as odict
200
+ from pathlib import (
201
+ Path,
202
+ PurePath as RenamedPurePath
373
203
  )
374
-
204
+ if sys.version_info >= (3, 8):
205
+ from typing import Literal
206
+ else:
207
+ from typing_extensions import Literal
208
+
375
209
  """
376
210
 
377
211
  # ObjectVars (identical to pythongen)
@@ -664,13 +498,29 @@ class PydanticGenerator(OOCodeGenerator):
664
498
  return _get_pyrange(value_slot_range_type, sv)
665
499
  return None
666
500
 
667
- def serialize(self) -> str:
668
- if self.template_file is not None:
669
- with open(self.template_file) as template_file:
670
- template_obj = Template(template_file.read())
671
- else:
672
- template_obj = Template(default_template(self.pydantic_version, self.extra_fields, self.injected_classes))
501
+ def _template_environment(self) -> Environment:
502
+ env = TemplateModel.environment()
503
+ if self.template_dir is not None:
504
+ loader = ChoiceLoader([FileSystemLoader(self.template_dir), env.loader])
505
+ env.loader = loader
506
+ return env
507
+
508
+ def get_array_representations_range(self, slot: SlotDefinition, range: str) -> List[SlotResult]:
509
+ """
510
+ Generate the python range for array representations
511
+ """
512
+ array_reps = []
513
+ for repr in self.array_representations:
514
+ generator = ArrayRangeGenerator.get_generator(repr)
515
+ result = generator(slot.array, range, self.pydantic_version).make()
516
+ array_reps.append(result)
517
+
518
+ if len(array_reps) == 0:
519
+ raise ValueError("No array representation generated, but one was requested!")
673
520
 
521
+ return array_reps
522
+
523
+ def render(self) -> PydanticModule:
674
524
  sv: SchemaView
675
525
  sv = self.schemaview
676
526
  schema = sv.schema
@@ -680,8 +530,14 @@ class PydanticGenerator(OOCodeGenerator):
680
530
  description=schema.description.replace('"', '\\"') if schema.description else None,
681
531
  )
682
532
  enums = self.generate_enums(sv.all_enums())
533
+ injected_classes = []
534
+ if self.injected_classes is not None:
535
+ injected_classes += self.injected_classes
683
536
 
684
- uses_numpy = False
537
+ imports = DEFAULT_IMPORTS
538
+ if self.imports is not None:
539
+ for i in self.imports:
540
+ imports += i
685
541
 
686
542
  sorted_classes = self.sort_classes(list(sv.all_classes().values()))
687
543
  self.sorted_class_names = [camelcase(c.name) for c in sorted_classes]
@@ -735,12 +591,22 @@ class PydanticGenerator(OOCodeGenerator):
735
591
  else:
736
592
  raise Exception(f"Could not generate python range for {class_name}.{s.name}")
737
593
 
738
- if "linkml:elements" in s.implements:
594
+ if s.array is not None:
739
595
  # TODO add support for xarray
740
- pyrange = "np.ndarray"
596
+ results = self.get_array_representations_range(s, pyrange)
597
+ # TODO: Move results unpacking to own function that is used after each slot build stage :)
598
+ for res in results:
599
+ if res.injected_classes:
600
+ injected_classes += res.injected_classes
601
+ if res.imports:
602
+ imports += res.imports
603
+ if len(results) == 1:
604
+ pyrange = results[0].annotation
605
+ else:
606
+ pyrange = f"Union[{', '.join([res.annotation for res in results])}]"
607
+
741
608
  if "linkml:ColumnOrderedArray" in class_def.implements:
742
609
  raise NotImplementedError("Cannot generate Pydantic code for ColumnOrderedArrays.")
743
- uses_numpy = True
744
610
  elif s.multivalued:
745
611
  if s.inlined or s.inlined_as_list:
746
612
  collection_key = self.generate_collection_key(slot_ranges, s, class_def)
@@ -761,33 +627,101 @@ class PydanticGenerator(OOCodeGenerator):
761
627
  pyrange = f"Optional[{pyrange}]"
762
628
  ann = Annotation("python_range", pyrange)
763
629
  s.annotations[ann.tag] = ann
764
- code = template_obj.render(
765
- schema=pyschema,
766
- underscore=underscore,
767
- enums=enums,
768
- predefined_slot_values=self.get_predefined_slot_values(),
769
- extra_fields=self.extra_fields,
630
+
631
+ # TODO: Make cleaning injected classes its own method
632
+ injected_classes = list(
633
+ dict.fromkeys([c if isinstance(c, str) else inspect.getsource(c) for c in injected_classes])
634
+ )
635
+ injected_classes = [textwrap.dedent(c) for c in injected_classes]
636
+
637
+ base_model = PydanticBaseModel(
638
+ pydantic_ver=self.pydantic_version, extra_fields=self.extra_fields, fields=self.injected_fields
639
+ )
640
+
641
+ classes = {}
642
+ predefined = self.get_predefined_slot_values()
643
+ bases = self.get_class_isa_plus_mixins()
644
+ for k, c in pyschema.classes.items():
645
+ attrs = {}
646
+ for attr_name, src_attr in c.attributes.items():
647
+ new_fields = {
648
+ k: src_attr._as_dict.get(k, None)
649
+ for k in PydanticAttribute.model_fields.keys()
650
+ if src_attr._as_dict.get(k, None) is not None
651
+ }
652
+ predef_slot = predefined.get(k, {}).get(attr_name, None)
653
+ if predef_slot is not None:
654
+ predef_slot = str(predef_slot)
655
+ new_fields["predefined"] = predef_slot
656
+ new_fields["name"] = attr_name
657
+ attrs[attr_name] = PydanticAttribute(**new_fields, pydantic_ver=self.pydantic_version)
658
+
659
+ new_class = PydanticClass(
660
+ name=k, attributes=attrs, description=c.description, pydantic_ver=self.pydantic_version
661
+ )
662
+ if k in bases:
663
+ new_class.bases = bases[k]
664
+ classes[k] = new_class
665
+
666
+ module = PydanticModule(
667
+ pydantic_ver=self.pydantic_version,
770
668
  metamodel_version=self.schema.metamodel_version,
771
669
  version=self.schema.version,
772
- class_isa_plus_mixins=self.get_class_isa_plus_mixins(),
773
- imports=self.imports,
774
- injected_fields=self.injected_fields,
775
- uses_numpy=uses_numpy,
670
+ imports=imports.imports,
671
+ base_model=base_model,
672
+ injected_classes=injected_classes,
673
+ enums=enums,
674
+ classes=classes,
776
675
  )
777
- return code
676
+ return module
677
+
678
+ def serialize(self) -> str:
679
+ module = self.render()
680
+ return module.render(self._template_environment(), self.black)
778
681
 
779
682
  def default_value_for_type(self, typ: str) -> str:
780
683
  return "None"
781
684
 
782
685
 
686
+ def _subclasses(cls: Type):
687
+ return set(cls.__subclasses__()).union([s for c in cls.__subclasses__() for s in _subclasses(c)])
688
+
689
+
690
+ _TEMPLATE_NAMES = sorted(list(set([c.template for c in _subclasses(TemplateModel)])))
691
+
692
+
783
693
  @shared_arguments(PydanticGenerator)
784
- @click.option("--template-file", help="Optional jinja2 template to use for class generation")
694
+ @click.option("--template-file", hidden=True)
695
+ @click.option(
696
+ "--template-dir",
697
+ type=click.Path(),
698
+ help="""
699
+ Optional jinja2 template directory to use for class generation.
700
+
701
+ Pass a directory containing templates with the same name as any of the default
702
+ :class:`.TemplateModel` templates to override them. The given directory will be
703
+ searched for matching templates, and use the default templates as a fallback
704
+ if an override is not found
705
+
706
+ Available templates to override:
707
+
708
+ \b
709
+ """
710
+ + "\n".join(["- " + name for name in _TEMPLATE_NAMES]),
711
+ )
785
712
  @click.option(
786
713
  "--pydantic-version",
787
- type=click.Choice(["1", "2"]),
788
- default="1",
714
+ type=click.IntRange(1, 2),
715
+ default=1,
789
716
  help="Pydantic version to use (1 or 2)",
790
717
  )
718
+ @click.option(
719
+ "--array-representations",
720
+ type=click.Choice([k.value for k in ArrayRepresentation]),
721
+ multiple=True,
722
+ default=["list"],
723
+ help="List of array representations to accept for array slots. Default is list of lists.",
724
+ )
791
725
  @click.option(
792
726
  "--extra-fields",
793
727
  type=click.Choice(["allow", "ignore", "forbid"], case_sensitive=False),
@@ -799,25 +733,39 @@ class PydanticGenerator(OOCodeGenerator):
799
733
  def cli(
800
734
  yamlfile,
801
735
  template_file=None,
736
+ template_dir: Optional[str] = None,
802
737
  head=True,
803
- emit_metadata=False,
804
738
  genmeta=False,
805
739
  classvars=True,
806
740
  slots=True,
807
- pydantic_version="1",
808
- extra_fields="forbid",
741
+ array_representations=list("list"),
742
+ pydantic_version=1,
743
+ extra_fields: Literal["allow", "forbid", "ignore"] = "forbid",
809
744
  **args,
810
745
  ):
811
746
  """Generate pydantic classes to represent a LinkML model"""
747
+ if template_file is not None:
748
+ raise DeprecationWarning(
749
+ (
750
+ "Passing a single template_file is deprecated. Pass a directory of template files instead. "
751
+ "See help string for --template-dir"
752
+ )
753
+ )
754
+
755
+ if template_dir is not None:
756
+ if not Path(template_dir).exists():
757
+ raise FileNotFoundError(f"The template directory {template_dir} does not exist!")
758
+
812
759
  gen = PydanticGenerator(
813
760
  yamlfile,
814
- template_file=template_file,
815
761
  pydantic_version=pydantic_version,
762
+ array_representations=[ArrayRepresentation(x) for x in array_representations],
816
763
  extra_fields=extra_fields,
817
764
  emit_metadata=head,
818
765
  genmeta=genmeta,
819
766
  gen_classvars=classvars,
820
767
  gen_slots=slots,
768
+ template_dir=template_dir,
821
769
  **args,
822
770
  )
823
771
  print(gen.serialize())