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.
- linkml/cli/main.py +4 -0
- linkml/generators/__init__.py +2 -0
- linkml/generators/common/build.py +5 -20
- linkml/generators/common/template.py +289 -3
- linkml/generators/docgen.py +55 -10
- linkml/generators/erdiagramgen.py +9 -5
- linkml/generators/graphqlgen.py +32 -6
- linkml/generators/jsonldcontextgen.py +78 -12
- linkml/generators/jsonschemagen.py +29 -12
- linkml/generators/mermaidclassdiagramgen.py +21 -3
- linkml/generators/owlgen.py +4 -1
- linkml/generators/panderagen/dataframe_class.py +13 -0
- linkml/generators/panderagen/dataframe_field.py +50 -0
- linkml/generators/panderagen/linkml_pandera_validator.py +186 -0
- linkml/generators/panderagen/panderagen.py +22 -5
- linkml/generators/panderagen/panderagen_class_based/class.jinja2 +70 -13
- linkml/generators/panderagen/panderagen_class_based/custom_checks.jinja2 +27 -0
- linkml/generators/panderagen/panderagen_class_based/enums.jinja2 +3 -3
- linkml/generators/panderagen/panderagen_class_based/pandera.jinja2 +12 -2
- linkml/generators/panderagen/panderagen_class_based/slots.jinja2 +19 -17
- linkml/generators/panderagen/slot_generator_mixin.py +143 -16
- linkml/generators/panderagen/transforms/__init__.py +19 -0
- linkml/generators/panderagen/transforms/collection_dict_model_transform.py +62 -0
- linkml/generators/panderagen/transforms/list_dict_model_transform.py +66 -0
- linkml/generators/panderagen/transforms/model_transform.py +8 -0
- linkml/generators/panderagen/transforms/nested_struct_model_transform.py +27 -0
- linkml/generators/panderagen/transforms/simple_dict_model_transform.py +86 -0
- linkml/generators/plantumlgen.py +17 -11
- linkml/generators/pydanticgen/pydanticgen.py +53 -2
- linkml/generators/pydanticgen/template.py +45 -233
- linkml/generators/pydanticgen/templates/attribute.py.jinja +1 -0
- linkml/generators/pydanticgen/templates/base_model.py.jinja +16 -2
- linkml/generators/pydanticgen/templates/imports.py.jinja +1 -1
- linkml/generators/rdfgen.py +11 -2
- linkml/generators/rustgen/__init__.py +3 -0
- linkml/generators/rustgen/build.py +94 -0
- linkml/generators/rustgen/cli.py +65 -0
- linkml/generators/rustgen/rustgen.py +1038 -0
- linkml/generators/rustgen/template.py +865 -0
- linkml/generators/rustgen/templates/Cargo.toml.jinja +42 -0
- linkml/generators/rustgen/templates/anything.rs.jinja +142 -0
- linkml/generators/rustgen/templates/as_key_value.rs.jinja +56 -0
- linkml/generators/rustgen/templates/class_module.rs.jinja +8 -0
- linkml/generators/rustgen/templates/enum.rs.jinja +54 -0
- linkml/generators/rustgen/templates/file.rs.jinja +62 -0
- linkml/generators/rustgen/templates/import.rs.jinja +4 -0
- linkml/generators/rustgen/templates/imports.rs.jinja +8 -0
- linkml/generators/rustgen/templates/poly.rs.jinja +9 -0
- linkml/generators/rustgen/templates/poly_containers.rs.jinja +439 -0
- linkml/generators/rustgen/templates/poly_trait.rs.jinja +15 -0
- linkml/generators/rustgen/templates/poly_trait_impl.rs.jinja +5 -0
- linkml/generators/rustgen/templates/poly_trait_impl_orsubtype.rs.jinja +5 -0
- linkml/generators/rustgen/templates/poly_trait_property.rs.jinja +8 -0
- linkml/generators/rustgen/templates/poly_trait_property_impl.rs.jinja +132 -0
- linkml/generators/rustgen/templates/poly_trait_property_match.rs.jinja +10 -0
- linkml/generators/rustgen/templates/property.rs.jinja +19 -0
- linkml/generators/rustgen/templates/pyproject.toml.jinja +10 -0
- linkml/generators/rustgen/templates/serde_utils.rs.jinja +310 -0
- linkml/generators/rustgen/templates/slot_range_as_union.rs.jinja +61 -0
- linkml/generators/rustgen/templates/struct.rs.jinja +75 -0
- linkml/generators/rustgen/templates/struct_or_subtype_enum.rs.jinja +108 -0
- linkml/generators/rustgen/templates/typealias.rs.jinja +13 -0
- linkml/generators/sqltablegen.py +18 -16
- linkml/generators/yarrrmlgen.py +157 -0
- linkml/linter/config/datamodel/config.py +160 -293
- linkml/linter/config/datamodel/config.yaml +34 -26
- linkml/linter/config/default.yaml +4 -0
- linkml/linter/config/recommended.yaml +4 -0
- linkml/linter/linter.py +1 -2
- linkml/linter/rules.py +37 -0
- linkml/utils/schemaloader.py +55 -3
- {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/METADATA +2 -2
- {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/RECORD +76 -38
- {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/entry_points.txt +1 -0
- linkml/generators/panderagen/panderagen_class_based/mixins.jinja2 +0 -26
- {linkml-1.9.4rc1.dist-info → linkml-1.9.5rc1.dist-info}/WHEEL +0 -0
- {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()
|
linkml/generators/plantumlgen.py
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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,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'] }}
|
|
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 %}
|