linkml 1.9.4rc1__py3-none-any.whl → 1.9.5rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. linkml/cli/main.py +4 -0
  2. linkml/generators/__init__.py +2 -0
  3. linkml/generators/common/build.py +5 -20
  4. linkml/generators/common/template.py +289 -3
  5. linkml/generators/docgen.py +55 -10
  6. linkml/generators/erdiagramgen.py +9 -5
  7. linkml/generators/graphqlgen.py +32 -6
  8. linkml/generators/jsonldcontextgen.py +78 -12
  9. linkml/generators/jsonschemagen.py +29 -12
  10. linkml/generators/mermaidclassdiagramgen.py +21 -3
  11. linkml/generators/owlgen.py +4 -1
  12. linkml/generators/panderagen/dataframe_class.py +13 -0
  13. linkml/generators/panderagen/dataframe_field.py +50 -0
  14. linkml/generators/panderagen/linkml_pandera_validator.py +186 -0
  15. linkml/generators/panderagen/panderagen.py +22 -5
  16. linkml/generators/panderagen/panderagen_class_based/class.jinja2 +70 -13
  17. linkml/generators/panderagen/panderagen_class_based/custom_checks.jinja2 +27 -0
  18. linkml/generators/panderagen/panderagen_class_based/enums.jinja2 +3 -3
  19. linkml/generators/panderagen/panderagen_class_based/pandera.jinja2 +12 -2
  20. linkml/generators/panderagen/panderagen_class_based/slots.jinja2 +19 -17
  21. linkml/generators/panderagen/slot_generator_mixin.py +143 -16
  22. linkml/generators/panderagen/transforms/__init__.py +19 -0
  23. linkml/generators/panderagen/transforms/collection_dict_model_transform.py +62 -0
  24. linkml/generators/panderagen/transforms/list_dict_model_transform.py +66 -0
  25. linkml/generators/panderagen/transforms/model_transform.py +8 -0
  26. linkml/generators/panderagen/transforms/nested_struct_model_transform.py +27 -0
  27. linkml/generators/panderagen/transforms/simple_dict_model_transform.py +86 -0
  28. linkml/generators/plantumlgen.py +17 -11
  29. linkml/generators/pydanticgen/pydanticgen.py +53 -2
  30. linkml/generators/pydanticgen/template.py +45 -233
  31. linkml/generators/pydanticgen/templates/attribute.py.jinja +1 -0
  32. linkml/generators/pydanticgen/templates/base_model.py.jinja +16 -2
  33. linkml/generators/pydanticgen/templates/imports.py.jinja +1 -1
  34. linkml/generators/rdfgen.py +11 -2
  35. linkml/generators/rustgen/__init__.py +3 -0
  36. linkml/generators/rustgen/build.py +94 -0
  37. linkml/generators/rustgen/cli.py +65 -0
  38. linkml/generators/rustgen/rustgen.py +1038 -0
  39. linkml/generators/rustgen/template.py +865 -0
  40. linkml/generators/rustgen/templates/Cargo.toml.jinja +42 -0
  41. linkml/generators/rustgen/templates/anything.rs.jinja +142 -0
  42. linkml/generators/rustgen/templates/as_key_value.rs.jinja +56 -0
  43. linkml/generators/rustgen/templates/class_module.rs.jinja +8 -0
  44. linkml/generators/rustgen/templates/enum.rs.jinja +54 -0
  45. linkml/generators/rustgen/templates/file.rs.jinja +62 -0
  46. linkml/generators/rustgen/templates/import.rs.jinja +4 -0
  47. linkml/generators/rustgen/templates/imports.rs.jinja +8 -0
  48. linkml/generators/rustgen/templates/poly.rs.jinja +9 -0
  49. linkml/generators/rustgen/templates/poly_containers.rs.jinja +439 -0
  50. linkml/generators/rustgen/templates/poly_trait.rs.jinja +15 -0
  51. linkml/generators/rustgen/templates/poly_trait_impl.rs.jinja +5 -0
  52. linkml/generators/rustgen/templates/poly_trait_impl_orsubtype.rs.jinja +5 -0
  53. linkml/generators/rustgen/templates/poly_trait_property.rs.jinja +8 -0
  54. linkml/generators/rustgen/templates/poly_trait_property_impl.rs.jinja +132 -0
  55. linkml/generators/rustgen/templates/poly_trait_property_match.rs.jinja +10 -0
  56. linkml/generators/rustgen/templates/property.rs.jinja +19 -0
  57. linkml/generators/rustgen/templates/pyproject.toml.jinja +10 -0
  58. linkml/generators/rustgen/templates/serde_utils.rs.jinja +310 -0
  59. linkml/generators/rustgen/templates/slot_range_as_union.rs.jinja +61 -0
  60. linkml/generators/rustgen/templates/struct.rs.jinja +75 -0
  61. linkml/generators/rustgen/templates/struct_or_subtype_enum.rs.jinja +108 -0
  62. linkml/generators/rustgen/templates/typealias.rs.jinja +13 -0
  63. linkml/generators/sqltablegen.py +18 -16
  64. linkml/generators/yarrrmlgen.py +157 -0
  65. linkml/linter/config/datamodel/config.py +160 -293
  66. linkml/linter/config/datamodel/config.yaml +34 -26
  67. linkml/linter/config/default.yaml +4 -0
  68. linkml/linter/config/recommended.yaml +4 -0
  69. linkml/linter/linter.py +1 -2
  70. linkml/linter/rules.py +37 -0
  71. linkml/utils/schemaloader.py +55 -3
  72. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/METADATA +2 -2
  73. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/RECORD +76 -38
  74. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/entry_points.txt +1 -0
  75. linkml/generators/panderagen/panderagen_class_based/mixins.jinja2 +0 -26
  76. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/WHEEL +0 -0
  77. {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,27 @@
1
+ from .model_transform import ModelTransform
2
+
3
+
4
+ class NestedStructModelTransform(ModelTransform):
5
+ """This class assists in converting a LinkML 'nested struct' inline column
6
+ into a form that is better for representing in a PolaRS dataframe and
7
+ validating with a Pandera model.
8
+ """
9
+
10
+ def __init__(self, polars_schema):
11
+ self.polars_schema = polars_schema
12
+ """A polars schema representing a nested struct column"""
13
+
14
+ def transform(self, linkml_nested_struct):
15
+ """Transforms a nested struct column.
16
+ This is a pass-through since nested structs are already in the correct format.
17
+ """
18
+ return linkml_nested_struct
19
+
20
+ def explode_unnest_dataframe(self, df, column_name):
21
+ """Unnest for nested struct."""
22
+ return df.lazy().select(column_name).unnest(column_name).collect()
23
+
24
+ @classmethod
25
+ def prepare_dataframe(cls, data, column_name, nested_cls):
26
+ """Returns the nested struct column as-is since no transformation needed"""
27
+ return data.lazyframe.collect()
@@ -0,0 +1,86 @@
1
+ import polars as pl
2
+
3
+ from .model_transform import ModelTransform
4
+
5
+
6
+ class SimpleDictModelTransform(ModelTransform):
7
+ """This class assists in converting a LinkML 'simple dict' inline column
8
+ into a form that is better for representing in a PolaRS dataframe and
9
+ validating with a Pandera model.
10
+ """
11
+
12
+ def __init__(self, polars_schema, id_col, other_col):
13
+ self.polars_schema = polars_schema
14
+ """A polars schema representing a simple dict column"""
15
+
16
+ self.id_col = id_col
17
+ """The ID column in the sense of a LinkML inline simple dict"""
18
+
19
+ self.other_col = other_col
20
+ """The 'other' column in the sense of a LinkML inline simple dict"""
21
+
22
+ self.id_col_type = None
23
+ self.other_col_type = None
24
+ self.polars_struct = self._build_polars_struct()
25
+ """A pl.Struct representing the schema of the other range."""
26
+
27
+ def _build_polars_struct_simple(self):
28
+ """Handles the two column (id, other) form of the simple dict"""
29
+ self.id_col_type = self.polars_schema.columns[self.id_col].dtype.type
30
+ self.other_col_type = self.polars_schema.columns[self.other_col].dtype.type
31
+
32
+ return pl.Struct({self.id_col: self.id_col_type, self.other_col: self.other_col_type})
33
+
34
+ def _build_polars_struct_complex(self):
35
+ """Handles the non-two-column simple dict cases."""
36
+ struct_items = {}
37
+ for k, v in self.polars_schema.columns.items():
38
+ if v.dtype.type == pl.Object:
39
+ v.dtype.type = pl.Struct
40
+ else:
41
+ struct_items[k] = v.dtype.type
42
+ return pl.Struct(struct_items)
43
+
44
+ def _build_polars_struct(self):
45
+ if len(self.polars_schema.columns.keys()) == 2:
46
+ return self._build_polars_struct_simple()
47
+ else:
48
+ return self._build_polars_struct_complex()
49
+
50
+ def transform(self, linkml_simple_dict):
51
+ """Converts a simple dict nested column to a list of dicts.
52
+ { 'A': 1, 'B': 2, ... } -> [{'id': 'other': 1}, {'id': 'B', 'other': 2}, ...]
53
+ """
54
+ return self._simple_dict_to_list_of_structs(linkml_simple_dict)
55
+
56
+ def _simple_dict_to_list_of_structs(self, linkml_simple_dict):
57
+ """Converts a simple dict nested column to a list of dicts.
58
+ { 'A': 1, 'B': 2, ... } -> [{'id': 'other': 1}, {'id': 'B', 'other': 2}, ...]
59
+
60
+ An inefficient conversion (relative to native PolaRS operations)
61
+ from a simple dict form to a dataframe struct column.
62
+
63
+ e : dict
64
+ e is a single row entry in a dataframe column (one cell), which itself is a dict.
65
+ The value entries of e may also be dicts.
66
+ """
67
+ arr = []
68
+ for id_value, range_value in linkml_simple_dict.items():
69
+ if isinstance(range_value, dict) and (set(range_value.keys()) <= set(self.polars_schema.columns.keys())):
70
+ range_dict = range_value
71
+ range_dict[self.id_col] = id_value
72
+ for column_key in self.polars_schema.columns.keys():
73
+ if column_key not in range_dict:
74
+ range_dict[column_key] = None
75
+ else:
76
+ range_dict = {self.id_col: id_value, self.other_col: range_value}
77
+ arr.append(range_dict)
78
+
79
+ return arr
80
+
81
+ def list_dtype(self):
82
+ return pl.List(self.polars_struct)
83
+
84
+ def explode_unnest_dataframe(self, df, column_name):
85
+ """Explode and unnest for simple dict."""
86
+ return df.lazy().explode(column_name).unnest(column_name).collect()
@@ -37,6 +37,7 @@ class PlantumlGenerator(Generator):
37
37
  generatorversion = "0.1.1"
38
38
  valid_formats = ["puml", "plantuml", "png", "pdf", "jpg", "json", "svg"]
39
39
  visit_all_class_slots = False
40
+ preserve_names: bool = False
40
41
 
41
42
  referenced: Optional[set[ClassDefinitionName]] = None # List of classes that have to be emitted
42
43
  generated: Optional[set[ClassDefinitionName]] = None # List of classes that have been emitted
@@ -99,10 +100,9 @@ class PlantumlGenerator(Generator):
99
100
  return plantuml_url
100
101
  if directory:
101
102
  file_suffix = ".svg" if self.format == "puml" or self.format == "puml" else "." + self.format
102
- self.output_file_name = os.path.join(
103
- directory,
104
- camelcase(sorted(classes)[0] if classes else self.schema.name) + file_suffix,
105
- )
103
+ schema_name = sorted(classes)[0] if classes else self.schema.name
104
+ filename = schema_name if self.preserve_names else camelcase(schema_name)
105
+ self.output_file_name = os.path.join(directory, filename + file_suffix)
106
106
  resp = requests.get(plantuml_url, stream=True, timeout=REQUESTS_TIMEOUT)
107
107
  if resp.ok:
108
108
  with open(self.output_file_name, "wb") as f:
@@ -133,14 +133,14 @@ class PlantumlGenerator(Generator):
133
133
  for slot in self.filtered_cls_slots(cn, all_slots=True, filtr=lambda s: s.range not in self.schema.classes):
134
134
  if True or cn in slot.domain_of:
135
135
  mod = self.prop_modifier(cls, slot)
136
+ slot_name = (
137
+ self.aliased_slot_name(slot)
138
+ if self.preserve_names
139
+ else underscore(self.aliased_slot_name(slot))
140
+ )
141
+ range_name = slot.range if self.preserve_names else underscore(slot.range)
136
142
  slot_defs.append(
137
- " {field} "
138
- + underscore(self.aliased_slot_name(slot))
139
- + mod
140
- + " : "
141
- + underscore(slot.range)
142
- + " "
143
- + self.cardinality(slot)
143
+ " {field} " + slot_name + mod + " : " + range_name + " " + self.cardinality(slot)
144
144
  )
145
145
  self.class_generated.add(cn)
146
146
  self.referenced.add(cn)
@@ -359,6 +359,12 @@ class PlantumlGenerator(Generator):
359
359
  show_default=True,
360
360
  help="Print out Kroki URL calls instead of sending the real requests",
361
361
  )
362
+ @click.option(
363
+ "--preserve-names/--normalize-names",
364
+ default=False,
365
+ show_default=True,
366
+ help="Preserve original LinkML names in PlantUML diagram output (e.g., for class names, slot names, file names).",
367
+ )
362
368
  @click.version_option(__version__, "-V", "--version")
363
369
  def cli(yamlfile, **args):
364
370
  """Generate a UML representation of a LinkML model"""
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import keyword
2
3
  import logging
3
4
  import os
4
5
  import re
@@ -94,7 +95,10 @@ DEFAULT_IMPORTS = (
94
95
  ObjectImport(name="ConfigDict"),
95
96
  ObjectImport(name="Field"),
96
97
  ObjectImport(name="RootModel"),
98
+ ObjectImport(name="SerializationInfo"),
99
+ ObjectImport(name="SerializerFunctionWrapHandler"),
97
100
  ObjectImport(name="field_validator"),
101
+ ObjectImport(name="model_serializer"),
98
102
  ],
99
103
  )
100
104
  )
@@ -143,6 +147,41 @@ DefinitionType = TypeVar("DefinitionType", bound=Union[SchemaDefinition, ClassDe
143
147
  TemplateType = TypeVar("TemplateType", bound=Union[PydanticModule, PydanticClass, PydanticAttribute])
144
148
 
145
149
 
150
+ def make_valid_python_identifier(name: str) -> str:
151
+ """
152
+ Convert a string to a valid Python identifier.
153
+
154
+ This is used when slot names contain characters that are not valid in Python
155
+ identifiers (e.g., '@id', '@type'). The original name can be preserved using
156
+ Pydantic field aliases.
157
+
158
+ Args:
159
+ name: The original name that may contain invalid characters
160
+
161
+ Returns:
162
+ A valid Python identifier that doesn't start with underscore (Pydantic restriction)
163
+ """
164
+ # Replace invalid characters with underscores
165
+ identifier = re.sub(r"[^a-zA-Z0-9_]", "_", name)
166
+
167
+ # Remove leading underscores (Pydantic doesn't allow field names starting with _)
168
+ identifier = identifier.lstrip("_")
169
+
170
+ # Ensure it doesn't start with a number
171
+ if identifier and identifier[0].isdigit():
172
+ identifier = f"field_{identifier}"
173
+
174
+ # Ensure it's not a keyword
175
+ if keyword.iskeyword(identifier):
176
+ identifier = f"{identifier}_"
177
+
178
+ # Ensure it's not empty
179
+ if not identifier:
180
+ identifier = "field"
181
+
182
+ return identifier
183
+
184
+
146
185
  @dataclass
147
186
  class PydanticGenerator(OOCodeGenerator, LifecycleMixin):
148
187
  """
@@ -461,7 +500,19 @@ class PydanticGenerator(OOCodeGenerator, LifecycleMixin):
461
500
  if getattr(slot, k, None) is not None
462
501
  }
463
502
  slot_alias = slot.alias if slot.alias else slot.name
464
- slot_args["name"] = underscore(slot_alias)
503
+
504
+ # Create a valid Python identifier for the field name
505
+ python_field_name = make_valid_python_identifier(underscore(slot_alias))
506
+ slot_args["name"] = python_field_name
507
+
508
+ # If the original name is different from the Python identifier, set an alias
509
+ if slot_alias != python_field_name:
510
+ slot_args["alias"] = slot_alias
511
+ else:
512
+ # Remove any existing alias if the names are the same
513
+ if "alias" in slot_args:
514
+ del slot_args["alias"]
515
+
465
516
  slot_args["description"] = slot.description.replace('"', '\\"') if slot.description is not None else None
466
517
  predef = self.predefined_slot_values.get(camelcase(cls.name), {}).get(slot.name, None)
467
518
  if predef is not None:
@@ -1241,7 +1292,7 @@ def cli(
1241
1292
  metadata_mode=meta,
1242
1293
  **args,
1243
1294
  )
1244
- print(gen.serialize())
1295
+ print(gen.serialize(), end="")
1245
1296
 
1246
1297
 
1247
1298
  if __name__ == "__main__":
@@ -1,13 +1,30 @@
1
1
  import sys
2
- from collections.abc import Generator
3
2
  from importlib.util import find_spec
3
+ from keyword import iskeyword
4
4
  from typing import Any, ClassVar, Literal, Optional, Union, get_args
5
5
 
6
+ try:
7
+ from typing import Self
8
+ except ImportError:
9
+ from typing_extensions import Self
10
+
6
11
  from jinja2 import Environment, PackageLoader
7
- from pydantic import BaseModel, Field, field_validator
12
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
13
  from pydantic.version import VERSION as PYDANTIC_VERSION
9
14
 
10
- from linkml.generators.common.template import TemplateModel
15
+ from linkml.generators.common.template import (
16
+ ConditionalImport as ConditionalImport_,
17
+ )
18
+ from linkml.generators.common.template import (
19
+ Import as Import_,
20
+ )
21
+ from linkml.generators.common.template import (
22
+ Imports as Imports_,
23
+ )
24
+ from linkml.generators.common.template import (
25
+ ObjectImport, # noqa: F401
26
+ TemplateModel,
27
+ )
11
28
  from linkml.utils.deprecation import deprecation_warning
12
29
 
13
30
  try:
@@ -113,9 +130,19 @@ class EnumValue(BaseModel):
113
130
  """
114
131
 
115
132
  label: str
133
+ alias: Optional[str] = None
116
134
  value: str
117
135
  description: Optional[str] = None
118
136
 
137
+ @model_validator(mode="after")
138
+ def alias_python_keywords(self) -> Self:
139
+ """Mask Python keywords used for `label` by appending `_`"""
140
+ if iskeyword(self.label):
141
+ if self.alias is None:
142
+ self.alias = self.label
143
+ self.label = self.label + "_"
144
+ return self
145
+
119
146
 
120
147
  class PydanticEnum(PydanticTemplateModel):
121
148
  """
@@ -170,6 +197,7 @@ class PydanticAttribute(PydanticTemplateModel):
170
197
  meta_exclude: ClassVar[list[str]] = ["from_schema", "owner", "range", "inlined", "inlined_as_list"]
171
198
 
172
199
  name: str
200
+ alias: Optional[str] = None
173
201
  required: bool = False
174
202
  identifier: bool = False
175
203
  key: bool = False
@@ -200,8 +228,19 @@ class PydanticAttribute(PydanticTemplateModel):
200
228
  elif self.required or self.identifier or self.key:
201
229
  return "..."
202
230
  else:
231
+ if self.range and self.range.startswith("Optional[list"):
232
+ return "[]"
203
233
  return "None"
204
234
 
235
+ @model_validator(mode="after")
236
+ def alias_python_keywords(self) -> Self:
237
+ """Mask Python keywords used for `name` by appending `_`"""
238
+ if iskeyword(self.name):
239
+ if self.alias is None:
240
+ self.alias = self.name
241
+ self.name = self.name + "_"
242
+ return self
243
+
205
244
 
206
245
  class PydanticValidator(PydanticAttribute):
207
246
  """
@@ -250,18 +289,7 @@ class PydanticClass(PydanticTemplateModel):
250
289
  return self.attributes
251
290
 
252
291
 
253
- class ObjectImport(BaseModel):
254
- """
255
- An object to be imported from within a module.
256
-
257
- See :class:`.Import` for examples
258
- """
259
-
260
- name: str
261
- alias: Optional[str] = None
262
-
263
-
264
- class Import(PydanticTemplateModel):
292
+ class Import(Import_, PydanticTemplateModel):
265
293
  """
266
294
  A python module, or module and classes to be imported.
267
295
 
@@ -292,15 +320,6 @@ class Import(PydanticTemplateModel):
292
320
  """
293
321
 
294
322
  template: ClassVar[str] = "imports.py.jinja"
295
- module: str
296
- alias: Optional[str] = None
297
- objects: Optional[list[ObjectImport]] = None
298
- is_schema: bool = False
299
- """
300
- Whether or not this ``Import`` is importing another schema imported by the main schema --
301
- ie. that it is not expected to be provided by the environment, but imported locally from within the package.
302
- Used primarily in split schema generation, see :func:`.pydanticgen.generate_split` for example usage.
303
- """
304
323
 
305
324
  @computed_field
306
325
  def group(self) -> IMPORT_GROUPS:
@@ -324,72 +343,8 @@ class Import(PydanticTemplateModel):
324
343
  else:
325
344
  return "thirdparty"
326
345
 
327
- def merge(self, other: "Import") -> list["Import"]:
328
- """
329
- Merge one import with another, see :meth:`.Imports` for an example.
330
-
331
- * If module don't match, return both
332
- * If one or the other are a :class:`.ConditionalImport`, return both
333
- * If modules match, neither contain objects, but the other has an alias, return the other
334
- * If modules match, one contains objects but the other doesn't, return both
335
- * If modules match, both contain objects, merge the object lists, preferring objects with aliases
336
- """
337
- # return both if we are orthogonal
338
- if self.module != other.module:
339
- return [self, other]
340
-
341
- # handle conditionals
342
- if isinstance(self, ConditionalImport) and isinstance(other, ConditionalImport):
343
- # If our condition is the same, return the newer version
344
- if self.condition == other.condition:
345
- return [other]
346
- if isinstance(self, ConditionalImport) or isinstance(other, ConditionalImport):
347
- # we don't have a good way of combining conditionals, just return both
348
- return [self, other]
349
-
350
- # handle module vs. object imports
351
- elif other.objects is None and self.objects is None:
352
- # both are modules, return the other only if it updates the alias
353
- if other.alias:
354
- return [other]
355
- else:
356
- return [self]
357
- elif other.objects is not None and self.objects is not None:
358
- # both are object imports, merge and return
359
- alias = self.alias if other.alias is None else other.alias
360
- # FIXME: super awkward implementation
361
- # keep ours if it has an alias and the other doesn't,
362
- # otherwise take the other's version
363
- self_objs = {obj.name: obj for obj in self.objects}
364
- other_objs = {
365
- obj.name: obj for obj in other.objects if obj.name not in self_objs or self_objs[obj.name].alias is None
366
- }
367
- self_objs.update(other_objs)
368
-
369
- return [
370
- Import(
371
- module=self.module,
372
- alias=alias,
373
- objects=list(self_objs.values()),
374
- is_schema=self.is_schema or other.is_schema,
375
- )
376
- ]
377
- else:
378
- # one is a module, the other imports objects, keep both
379
- return [self, other]
380
-
381
- def sort(self) -> None:
382
- """
383
- Sort imported objects
384
-
385
- * First by whether the first letter is capitalized or not,
386
- * Then alphabetically (by object name rather than alias)
387
- """
388
- if self.objects:
389
- self.objects = sorted(self.objects, key=lambda obj: (obj.name[0].islower(), obj.name))
390
-
391
346
 
392
- class ConditionalImport(Import):
347
+ class ConditionalImport(ConditionalImport_, PydanticTemplateModel):
393
348
  """
394
349
  Import that depends on some condition in the environment, common when
395
350
  using backported features or straddling dependency versions.
@@ -430,22 +385,13 @@ class ConditionalImport(Import):
430
385
  """
431
386
 
432
387
  template: ClassVar[str] = "conditional_import.py.jinja"
433
- condition: str
434
- alternative: Import
435
388
 
436
389
  @computed_field
437
390
  def group(self) -> Literal["conditional"]:
438
391
  return "conditional"
439
392
 
440
- def sort(self) -> None:
441
- """
442
- :meth:`.Import.sort` called for self and :attr:`.alternative`
443
- """
444
- super().sort()
445
- self.alternative.sort()
446
-
447
393
 
448
- class Imports(PydanticTemplateModel):
394
+ class Imports(Imports_, PydanticTemplateModel):
449
395
  """
450
396
  Container class for imports that can handle merging!
451
397
 
@@ -482,135 +428,6 @@ class Imports(PydanticTemplateModel):
482
428
  imports: list[Union[Import, ConditionalImport]] = Field(default_factory=list)
483
429
  group_order: tuple[str, ...] = get_args(IMPORT_GROUPS)
484
430
  """Order in which to sort imports by their :attr:`.Import.group`"""
485
- render_sorted: bool = True
486
- """When rendering, render in sorted groups"""
487
-
488
- @classmethod
489
- def _merge(
490
- cls, imports: list[Union[Import, ConditionalImport]], other: Union[Import, "Imports", list[Import]]
491
- ) -> list[Union[Import, ConditionalImport]]:
492
- """
493
- Add a new import to an existing imports list, handling deduplication and flattening.
494
-
495
- Mutates and returns ``imports``
496
-
497
- Generally will prefer the imports in ``other`` , updating those in ``imports``.
498
- If ``other`` ...
499
- - doesn't match any ``module`` in ``imports``, add it!
500
- - matches a single ``module`` in imports, :meth:`.Import.merge` the object imports
501
- - matches multiple ``module``s in imports, then there must have been another
502
- :class:`.ConditionalImport` already present, so we :meth:`.Import.merge` the existing
503
- :class:`.Import` if it is one, and if it's a :class:`.ConditionalImport` just YOLO
504
- and append it since there isn't a principled way to merge them from strings.
505
- - is :class:`.Imports` or a list of :class:`.Import` s, call this recursively for each
506
- item.
507
-
508
- Since imports can be merged in an undefined order depending on the generator configuration,
509
- default behavior for imports with matching ``module`` is to remove them and append to the
510
- end of the imports list (rather than keeping it in the position of the existing
511
- :class:`.Import` ). :class:`.ConditionalImports` make it possible to have namespace
512
- conflicts, so in imperative import style we assume the most recently added :class:`.Import`
513
- is the one that should prevail.
514
- """
515
- #
516
- if isinstance(other, Imports) or (isinstance(other, list) and all([isinstance(i, Import) for i in other])):
517
- for i in other:
518
- imports = cls._merge(imports, i)
519
- return imports
520
-
521
- existing = [i for i in imports if i.module == other.module]
522
-
523
- # if we have nothing importing from this module yet, add it!
524
- if len(existing) == 0:
525
- imports.append(other)
526
- elif len(existing) == 1:
527
- imports.remove(existing[0])
528
- imports.extend(existing[0].merge(other))
529
- else:
530
- # we have both a conditional and at least one nonconditional already.
531
- # If this is another conditional, we just add it, otherwise, we merge it
532
- # with the single nonconditional
533
- if isinstance(other, ConditionalImport):
534
- imports.append(other)
535
- else:
536
- for e in existing:
537
- if isinstance(e, Import):
538
- imports.remove(e)
539
- merged = e.merge(other)
540
- imports.extend(merged)
541
- break
542
-
543
- # SPECIAL CASE - __future__ annotations must happen at the top of a file
544
- # sort here outside of sort method because our imports are invalid without it,
545
- # where calling ``sort`` should be optional.
546
- imports = sorted(imports, key=lambda i: i.module == "__future__", reverse=True)
547
- return imports
548
-
549
- def __add__(self, other: Union[Import, "Imports", list[Import]]) -> "Imports":
550
- imports = self.imports.copy()
551
- imports = self._merge(imports, other)
552
- return Imports.model_construct(
553
- imports=imports, **{k: getattr(self, k, None) for k in self.model_fields if k != "imports"}
554
- )
555
-
556
- def __len__(self) -> int:
557
- return len(self.imports)
558
-
559
- def __iter__(self) -> Generator[Import, None, None]:
560
- yield from self.imports
561
-
562
- def __getitem__(self, item: Union[int, str]) -> Import:
563
- if isinstance(item, int):
564
- return self.imports[item]
565
- elif isinstance(item, str):
566
- # the name of the module
567
- an_import = [i for i in self.imports if i.module == item]
568
- if len(an_import) == 0:
569
- raise KeyError(f"No import with module {item} was found.\nWe have: {self.imports}")
570
- return an_import[0]
571
- else:
572
- raise TypeError(f"Can only index with an int or a string as the name of the module,\nGot: {type(item)}")
573
-
574
- def __contains__(self, item: Union[Import, "Imports", list[Import]]) -> bool:
575
- """
576
- Check if all the objects are imported from the given module(s)
577
-
578
- If the import is a bare module import (ie its :attr:`~.Import.objects` is ``None`` )
579
- then we must also have a bare module import in this Imports (because even if
580
- we import from the module, unless we import it specifically its name won't be
581
- available in the namespace.
582
-
583
- :attr:`.Import.alias` must always match for the same reason.
584
- """
585
- if isinstance(item, Imports):
586
- return all([i in self for i in item.imports])
587
- elif isinstance(item, list):
588
- return all([i in self for i in item])
589
- elif isinstance(item, Import):
590
- try:
591
- an_import = self[item.module]
592
- except KeyError:
593
- return False
594
- if item.objects is None:
595
- return an_import == item
596
- else:
597
- return all([obj in an_import.objects for obj in item.objects])
598
- else:
599
- raise TypeError(f"Imports only contains single Import objects or other Imports\nGot: {type(item)}")
600
-
601
- @field_validator("imports", mode="after")
602
- @classmethod
603
- def imports_are_merged(
604
- cls, imports: list[Union[Import, ConditionalImport]]
605
- ) -> list[Union[Import, ConditionalImport]]:
606
- """
607
- When creating from a list of imports, construct model as if we have done so by iteratively
608
- constructing with __add__ calls
609
- """
610
- merged_imports = []
611
- for i in imports:
612
- merged_imports = cls._merge(merged_imports, i)
613
- return merged_imports
614
431
 
615
432
  @computed_field
616
433
  def import_groups(self) -> list[IMPORT_GROUPS]:
@@ -637,11 +454,6 @@ class Imports(PydanticTemplateModel):
637
454
  i.sort()
638
455
  self.imports = imports
639
456
 
640
- def render(self, environment: Optional[Environment] = None, black: bool = False) -> str:
641
- if self.render_sorted:
642
- self.sort()
643
- return super().render(environment=environment, black=black)
644
-
645
457
 
646
458
  class PydanticModule(PydanticTemplateModel):
647
459
  """
@@ -1,4 +1,5 @@
1
1
  {{name}}: {{ range }} = Field(default={{ field }}
2
+ {%- if alias %}, alias="{{alias}}"{% endif -%}
2
3
  {%- if title != None %}, title="{{title}}"{% endif -%}
3
4
  {%- if description %}, description="""{{description}}"""{% endif -%}
4
5
  {%- if equals_number != None %}
@@ -1,5 +1,7 @@
1
1
  class {{ name }}(BaseModel):
2
2
  model_config = ConfigDict(
3
+ serialize_by_alias = True,
4
+ validate_by_name = True,
3
5
  validate_assignment = True,
4
6
  validate_default = True,
5
7
  extra = "{{ extra_fields }}",
@@ -11,6 +13,18 @@ class {{ name }}(BaseModel):
11
13
  {% for field in fields %}
12
14
  {{ field }}
13
15
  {% endfor %}
14
- {% else %}
15
- {{ "pass" }}
16
16
  {% endif %}
17
+
18
+ @model_serializer(mode='wrap', when_used='unless-none')
19
+ def treat_empty_lists_as_none(
20
+ self, handler: SerializerFunctionWrapHandler,
21
+ info: SerializationInfo) -> dict[str, Any]:
22
+ if info.exclude_none:
23
+ _instance = self.model_copy()
24
+ for field, field_info in type(_instance).model_fields.items():
25
+ if getattr(_instance, field) == [] and not(
26
+ field_info.is_required()):
27
+ setattr(_instance, field, None)
28
+ else:
29
+ _instance = self
30
+ return handler(_instance, info)
@@ -5,7 +5,7 @@ import {{ module }}
5
5
  import {{ module }} as {{ alias }}
6
6
  {%- else %}
7
7
  {% if objects | length == 1 %}
8
- from {{ module }} import {{ objects[0]['name'] }} {% if objects[0]['alias'] is not none %} as {{ objects[0]['alias'] }} {% endif %}
8
+ from {{ module }} import {{ objects[0]['name'] }}{% if objects[0]['alias'] is not none %} as {{ objects[0]['alias'] }}{% endif %}
9
9
  {%- else %}
10
10
  from {{ module }} import (
11
11
  {% for object in objects %}