graphty 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- graphty-0.2.0/PKG-INFO +31 -0
- graphty-0.2.0/README.md +20 -0
- graphty-0.2.0/pyproject.toml +30 -0
- graphty-0.2.0/src/graphty/.ruff_cache/.gitignore +2 -0
- graphty-0.2.0/src/graphty/.ruff_cache/0.14.6/1516144176499143322 +0 -0
- graphty-0.2.0/src/graphty/.ruff_cache/0.14.6/16552783075482950175 +0 -0
- graphty-0.2.0/src/graphty/.ruff_cache/0.6.9/5647188018562798146 +0 -0
- graphty-0.2.0/src/graphty/.ruff_cache/0.6.9/9121026693023336299 +0 -0
- graphty-0.2.0/src/graphty/.ruff_cache/CACHEDIR.TAG +1 -0
- graphty-0.2.0/src/graphty/planner.py +323 -0
- graphty-0.2.0/src/graphty/py.typed +0 -0
- graphty-0.2.0/src/graphty/utils/.ruff_cache/.gitignore +2 -0
- graphty-0.2.0/src/graphty/utils/.ruff_cache/0.14.6/15861910901900155226 +0 -0
- graphty-0.2.0/src/graphty/utils/.ruff_cache/0.14.6/7883837630368280160 +0 -0
- graphty-0.2.0/src/graphty/utils/.ruff_cache/CACHEDIR.TAG +1 -0
- graphty-0.2.0/src/graphty/utils/alias_map.py +135 -0
- graphty-0.2.0/src/graphty/utils/type_utils.py +64 -0
- graphty-0.1.0/.gitignore +0 -10
- graphty-0.1.0/.python-version +0 -1
- graphty-0.1.0/PKG-INFO +0 -11
- graphty-0.1.0/README.md +0 -3
- graphty-0.1.0/pyproject.toml +0 -14
- graphty-0.1.0/src/graphty/__init__.py +0 -2
- /graphty-0.1.0/src/graphty/py.typed → /graphty-0.2.0/src/graphty/__init__.py +0 -0
graphty-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: graphty
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Lukas Plank
|
|
6
|
+
Author-email: Lukas Plank <lupl@tuta.io>
|
|
7
|
+
Requires-Dist: polars>=1.39.3
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
<img src="graphty.svg" width="50%" height="50%" />
|
|
13
|
+
|
|
14
|
+
#
|
|
15
|
+

|
|
16
|
+
[](https://coveralls.io/github/lu-pl/graphty?branch=lupl/actions)
|
|
17
|
+
[](https://github.com/astral-sh/ruff)
|
|
18
|
+
[](https://github.com/astral-sh/uv)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Typed object graph materializer over relational binding sets.
|
|
22
|
+
|
|
23
|
+
> WARNING: This project is in an early stage of development and should be used with caution.
|
|
24
|
+
|
|
25
|
+
## Introduction
|
|
26
|
+
|
|
27
|
+
[Intro]
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
[Installation]
|
graphty-0.2.0/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<img src="graphty.svg" width="50%" height="50%" />
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+

|
|
5
|
+
[](https://coveralls.io/github/lu-pl/graphty?branch=lupl/actions)
|
|
6
|
+
[](https://github.com/astral-sh/ruff)
|
|
7
|
+
[](https://github.com/astral-sh/uv)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Typed object graph materializer over relational binding sets.
|
|
11
|
+
|
|
12
|
+
> WARNING: This project is in an early stage of development and should be used with caution.
|
|
13
|
+
|
|
14
|
+
## Introduction
|
|
15
|
+
|
|
16
|
+
[Intro]
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
[Installation]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "graphty"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Lukas Plank", email = "lupl@tuta.io" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"polars>=1.39.3",
|
|
12
|
+
"pydantic>=2.12.5",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.9.30,<0.10.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.0.3",
|
|
22
|
+
"pytest-cov>=7.1.0",
|
|
23
|
+
"pytest-randomly>=4.1.0",
|
|
24
|
+
"ruff>=0.15.17",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
addopts = [
|
|
29
|
+
"--import-mode=importlib",
|
|
30
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Signature: 8a477f597d28d172789f06886806bc55
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import cached_property, reduce
|
|
4
|
+
from itertools import chain
|
|
5
|
+
from types import UnionType
|
|
6
|
+
from typing import Annotated, cast, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
from pydantic import BaseModel, Discriminator, Tag
|
|
10
|
+
from pydantic import ConfigDict as PydanticConfigDict
|
|
11
|
+
from pydantic.fields import FieldInfo
|
|
12
|
+
from typing_extensions import TypeForm, get_annotations
|
|
13
|
+
|
|
14
|
+
from graphty.utils.type_utils import (
|
|
15
|
+
de_annotate,
|
|
16
|
+
is_parametrized_list_static_type,
|
|
17
|
+
is_pydantic_model_static_type,
|
|
18
|
+
is_pydantic_model_union_static_type,
|
|
19
|
+
is_structured_field_static_type,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_model_projection(model: type[BaseModel]) -> set[str]:
|
|
24
|
+
return {
|
|
25
|
+
field_name
|
|
26
|
+
for field_name, field_info in model.model_fields.items()
|
|
27
|
+
if not is_structured_field_static_type(field_info.annotation)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigDict(PydanticConfigDict):
|
|
32
|
+
group_by: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Agg:
|
|
37
|
+
"""Configuration class for aggregation fields.
|
|
38
|
+
|
|
39
|
+
The class allows to configure aggregation behavior for
|
|
40
|
+
model fields with aggretation targets:
|
|
41
|
+
|
|
42
|
+
E.g. `field: typing.Annotated[list[int], Agg(unique=False)]`
|
|
43
|
+
will aggregate non-unique values of a given partition into `field`.
|
|
44
|
+
|
|
45
|
+
Note: This is a draft and likely subject of breaking API changes.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
unique: bool = True
|
|
49
|
+
drop_nulls: bool = True
|
|
50
|
+
|
|
51
|
+
def __iter__(self) -> Iterator[Callable[[pl.Expr], pl.Expr]]:
|
|
52
|
+
if self.unique:
|
|
53
|
+
yield lambda expr: expr.unique()
|
|
54
|
+
if self.drop_nulls:
|
|
55
|
+
yield lambda expr: expr.drop_nulls()
|
|
56
|
+
|
|
57
|
+
def apply_to(self, expr: pl.Expr) -> pl.Expr:
|
|
58
|
+
return reduce(lambda x, y: y(x), self, expr)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ModelUnionDispatch:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
type_form: TypeForm,
|
|
65
|
+
base_cols: set[str],
|
|
66
|
+
discriminator: str | Callable | None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.type_form = type_form
|
|
69
|
+
self.base_cols = base_cols
|
|
70
|
+
self.discriminator = discriminator
|
|
71
|
+
|
|
72
|
+
self.model_members: list[type[BaseModel]] = [
|
|
73
|
+
member
|
|
74
|
+
for member in get_args(de_annotate(self.type_form))
|
|
75
|
+
if is_pydantic_model_static_type(member)
|
|
76
|
+
]
|
|
77
|
+
self.model_union_members: list[UnionType] = [
|
|
78
|
+
member
|
|
79
|
+
for member in get_args(de_annotate(self.type_form))
|
|
80
|
+
if is_pydantic_model_union_static_type(member)
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def compute_model_expr(self) -> pl.Expr:
|
|
84
|
+
match self.model_members, self.model_union_members:
|
|
85
|
+
case [model], []:
|
|
86
|
+
# TODO: this duplicates Exprs._struct_expr and should be abstracted
|
|
87
|
+
model_projection: set[str] = get_model_projection(model=model)
|
|
88
|
+
return pl.struct(model_projection).struct.with_fields(
|
|
89
|
+
*Exprs(model=model, base_cols=self.base_cols),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
whens = self._compute_whens()
|
|
93
|
+
when, *rest_whens = whens
|
|
94
|
+
|
|
95
|
+
return reduce(
|
|
96
|
+
lambda x, y: y.otherwise(x),
|
|
97
|
+
rest_whens,
|
|
98
|
+
when.otherwise(None),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _compute_whens(self) -> list["pl.When"]:
|
|
102
|
+
return [
|
|
103
|
+
*self._compute_model_whens(),
|
|
104
|
+
*self._compute_model_union_whens(),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def _compute_model_whens(self) -> list["pl.When"]:
|
|
108
|
+
if not self.model_members:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
union_projection: set[str] = reduce(
|
|
112
|
+
set.union,
|
|
113
|
+
[get_model_projection(member) for member in self.model_members],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
discriminator: Discriminator = self._resolve_discriminator()
|
|
117
|
+
discriminator_value: str | Callable = discriminator.discriminator
|
|
118
|
+
|
|
119
|
+
match discriminator_value:
|
|
120
|
+
case str():
|
|
121
|
+
discriminator_mapping: dict[tuple[str, ...], type[BaseModel]] = {
|
|
122
|
+
get_args(model.model_fields[discriminator_value].annotation): model
|
|
123
|
+
for model in self.model_members
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return [
|
|
127
|
+
pl.when(pl.col(discriminator_value).is_in(list(k))).then(
|
|
128
|
+
pl.struct(union_projection).struct.with_fields(
|
|
129
|
+
*Exprs(model=v, base_cols=self.base_cols),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
for k, v in discriminator_mapping.items()
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
case Callable():
|
|
136
|
+
tag_mapping: dict[str, type[BaseModel]] = self._get_tag_mapping()
|
|
137
|
+
|
|
138
|
+
discriminator_expression = pl.struct(union_projection).map_elements(
|
|
139
|
+
function=discriminator_value,
|
|
140
|
+
return_dtype=pl.String,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return [
|
|
144
|
+
pl.when(discriminator_expression == k).then(
|
|
145
|
+
pl.struct(union_projection).struct.with_fields(
|
|
146
|
+
*Exprs(model=v, base_cols=self.base_cols),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
for k, v in tag_mapping.items()
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
case _:
|
|
153
|
+
assert False, "Expected discriminator to be of type str | Callable."
|
|
154
|
+
|
|
155
|
+
def _compute_model_union_whens(self) -> list["pl.When"]:
|
|
156
|
+
return list(
|
|
157
|
+
chain.from_iterable(
|
|
158
|
+
ModelUnionDispatch(
|
|
159
|
+
type_form=cast(TypeForm, type_form),
|
|
160
|
+
base_cols=self.base_cols,
|
|
161
|
+
discriminator=None,
|
|
162
|
+
)._compute_whens()
|
|
163
|
+
for type_form in self.model_union_members
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _resolve_discriminator(self) -> Discriminator:
|
|
168
|
+
if self.discriminator is not None:
|
|
169
|
+
return Discriminator(discriminator=self.discriminator)
|
|
170
|
+
|
|
171
|
+
args = (
|
|
172
|
+
get_args(self.type_form) if get_origin(self.type_form) is Annotated else []
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
for arg in args:
|
|
176
|
+
match arg:
|
|
177
|
+
case FieldInfo(discriminator=discriminator):
|
|
178
|
+
assert discriminator is not None, (
|
|
179
|
+
"Expected discriminator to be non-None."
|
|
180
|
+
)
|
|
181
|
+
return (
|
|
182
|
+
discriminator
|
|
183
|
+
if isinstance(discriminator, Discriminator)
|
|
184
|
+
else Discriminator(discriminator=discriminator)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
case Discriminator():
|
|
188
|
+
return arg
|
|
189
|
+
|
|
190
|
+
msg = (
|
|
191
|
+
"Multi-Model unions must be discriminated unions. "
|
|
192
|
+
f"Unable to extract discriminator for type form '{self.type_form}'."
|
|
193
|
+
)
|
|
194
|
+
raise ValueError(msg)
|
|
195
|
+
|
|
196
|
+
# TODO: this needs to be more defensive and raise a clear exception in case Tags cannot be retrieved
|
|
197
|
+
def _get_tag_mapping(self):
|
|
198
|
+
"""Prototype; this needs proper abstraction."""
|
|
199
|
+
|
|
200
|
+
def _generate():
|
|
201
|
+
for type_form in get_args(de_annotate(self.type_form)):
|
|
202
|
+
model, *rest = get_args(type_form)
|
|
203
|
+
tag = next(member for member in rest if isinstance(member, Tag))
|
|
204
|
+
|
|
205
|
+
yield tag.tag, model
|
|
206
|
+
|
|
207
|
+
return dict(_generate())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Exprs(Iterable[pl.Expr]):
|
|
211
|
+
def __init__(
|
|
212
|
+
self, model: type[BaseModel], base_cols: set[str], group_context: bool = False
|
|
213
|
+
) -> None:
|
|
214
|
+
self.model = model
|
|
215
|
+
self.base_cols = base_cols
|
|
216
|
+
self.group_context = group_context
|
|
217
|
+
|
|
218
|
+
def __iter__(self) -> Iterator[pl.Expr]:
|
|
219
|
+
for field_name, field_info in self.model.model_fields.items():
|
|
220
|
+
annotation = cast(TypeForm, field_info.annotation)
|
|
221
|
+
|
|
222
|
+
if is_pydantic_model_static_type(annotation):
|
|
223
|
+
expr: pl.Expr = self._struct_expr(model=annotation).alias(field_name)
|
|
224
|
+
yield (expr.first() if self.group_context else expr)
|
|
225
|
+
|
|
226
|
+
elif is_pydantic_model_union_static_type(annotation):
|
|
227
|
+
expr: pl.Expr = (
|
|
228
|
+
ModelUnionDispatch(
|
|
229
|
+
type_form=get_annotations(self.model)[
|
|
230
|
+
field_name
|
|
231
|
+
], # pass full TypeForm for discriminator resolution
|
|
232
|
+
base_cols=self.base_cols,
|
|
233
|
+
discriminator=field_info.discriminator,
|
|
234
|
+
)
|
|
235
|
+
.compute_model_expr()
|
|
236
|
+
.alias(field_name)
|
|
237
|
+
)
|
|
238
|
+
yield (expr.first() if self.group_context else expr)
|
|
239
|
+
|
|
240
|
+
elif is_parametrized_list_static_type(annotation):
|
|
241
|
+
(item_annotation,) = get_args(annotation)
|
|
242
|
+
|
|
243
|
+
partition_value: str = self._get_partition_value(model=self.model)
|
|
244
|
+
agg: Agg = self._get_agg(field_info)
|
|
245
|
+
|
|
246
|
+
if is_pydantic_model_static_type(item_annotation):
|
|
247
|
+
inner: pl.Expr = self._struct_expr(model=item_annotation).alias(
|
|
248
|
+
field_name
|
|
249
|
+
)
|
|
250
|
+
elif is_pydantic_model_union_static_type(item_annotation):
|
|
251
|
+
inner = (
|
|
252
|
+
ModelUnionDispatch(
|
|
253
|
+
type_form=item_annotation, # pyright: ignore
|
|
254
|
+
base_cols=self.base_cols,
|
|
255
|
+
discriminator=field_info.discriminator,
|
|
256
|
+
)
|
|
257
|
+
.compute_model_expr()
|
|
258
|
+
.alias(field_name)
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
inner: pl.Expr = pl.col(field_name)
|
|
262
|
+
|
|
263
|
+
expr: pl.Expr = agg.apply_to(inner)
|
|
264
|
+
yield (
|
|
265
|
+
expr
|
|
266
|
+
if self.group_context
|
|
267
|
+
else expr.implode().over(partition_by=partition_value)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _struct_expr(self, model: type[BaseModel]) -> pl.Expr:
|
|
271
|
+
model_projection: set[str] = get_model_projection(model=model)
|
|
272
|
+
exprs: chain[pl.Expr] = chain(
|
|
273
|
+
[pl.col(member) for member in model_projection],
|
|
274
|
+
Exprs(model=model, base_cols=self.base_cols),
|
|
275
|
+
)
|
|
276
|
+
return pl.struct(*exprs)
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _get_partition_value(model: type[BaseModel]) -> str:
|
|
280
|
+
try:
|
|
281
|
+
partition_value = model.model_config["group_by"] # type: ignore
|
|
282
|
+
except KeyError:
|
|
283
|
+
raise Exception(
|
|
284
|
+
f"Model '{model.__name__}' with aggregation target "
|
|
285
|
+
"does not specify ConfigDict.group_by."
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
return partition_value
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _get_agg(field_info: FieldInfo) -> Agg:
|
|
292
|
+
agg: Agg | None = next(
|
|
293
|
+
(entry for entry in field_info.metadata if isinstance(entry, Agg)), None
|
|
294
|
+
)
|
|
295
|
+
return agg or Agg()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class LazyFramePlanner:
|
|
299
|
+
def __init__(
|
|
300
|
+
self, model: type[BaseModel], data: pl._typing.FrameInitTypes | pl.LazyFrame
|
|
301
|
+
) -> None:
|
|
302
|
+
self.model = model
|
|
303
|
+
self.lazy_frame: pl.LazyFrame = (
|
|
304
|
+
data if isinstance(data, pl.LazyFrame) else pl.LazyFrame(data=data)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def run(self) -> pl.LazyFrame:
|
|
308
|
+
group_by: str | None = self.model.model_config.get("group_by")
|
|
309
|
+
model_projection: set[str] = get_model_projection(self.model)
|
|
310
|
+
|
|
311
|
+
if group_by is None:
|
|
312
|
+
return self.lazy_frame.with_columns(
|
|
313
|
+
*Exprs(model=self.model, base_cols=self._base_cols)
|
|
314
|
+
).drop(self._base_cols.difference(model_projection))
|
|
315
|
+
|
|
316
|
+
return self.lazy_frame.group_by(group_by, maintain_order=True).agg(
|
|
317
|
+
*[pl.col(col).first() for col in model_projection.difference({group_by})],
|
|
318
|
+
*Exprs(model=self.model, base_cols=self._base_cols, group_context=True),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
@cached_property
|
|
322
|
+
def _base_cols(self) -> set[str]:
|
|
323
|
+
return set(self.lazy_frame.collect_schema().names())
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Signature: 8a477f597d28d172789f06886806bc55
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from collections import UserDict
|
|
2
|
+
from collections.abc import Callable, Iterator
|
|
3
|
+
from types import resolve_bases
|
|
4
|
+
|
|
5
|
+
from pydantic import AliasChoices, BaseModel
|
|
6
|
+
from pydantic.fields import FieldInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AliasMap(UserDict):
|
|
10
|
+
"""Custom mapping for resolving Pydantic field validation aliases.
|
|
11
|
+
|
|
12
|
+
E.g. for a given model
|
|
13
|
+
|
|
14
|
+
class Point(BaseModel):
|
|
15
|
+
a: int = Field(alias="x")
|
|
16
|
+
y: int
|
|
17
|
+
|
|
18
|
+
the mapping would compute to {"a": "x", "y": y}.
|
|
19
|
+
|
|
20
|
+
Alias choices, alias generators and alias priority are supported;
|
|
21
|
+
alias choices are resolved against a projection.
|
|
22
|
+
|
|
23
|
+
Note that AliasPath is currently not supported.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, model: type[BaseModel], projection: set[str]) -> None:
|
|
27
|
+
self.model = model
|
|
28
|
+
self.projection = projection
|
|
29
|
+
|
|
30
|
+
self.data = dict(self._generate_alias_map())
|
|
31
|
+
|
|
32
|
+
def _generate_alias_map(self) -> Iterator[tuple[str, str]]:
|
|
33
|
+
"""Generate an alias mapping.
|
|
34
|
+
|
|
35
|
+
For every field in a given model, the generator resolves aliases
|
|
36
|
+
and yields 2-tuples representing key/value pairs.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
alias_resolver: Callable[[str, FieldInfo], list[str]] = (
|
|
40
|
+
self._get_alias_resolver()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for k, v in self.model.model_fields.items():
|
|
44
|
+
aliases: list[str] = alias_resolver(k, v)
|
|
45
|
+
|
|
46
|
+
match aliases:
|
|
47
|
+
case []:
|
|
48
|
+
yield k, k
|
|
49
|
+
case [alias]:
|
|
50
|
+
yield (k, alias)
|
|
51
|
+
case [_alias, *_aliases] as aliases:
|
|
52
|
+
alias: str | None = next(
|
|
53
|
+
(i for i in aliases if i in self.projection), None
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if alias is None:
|
|
57
|
+
msg = (
|
|
58
|
+
f"Unable to resolve AliasChoice for field '{k}' of model '{self.model.__name__}'.\n"
|
|
59
|
+
f"None of computed aliases '{aliases}' in projection '{self.projection}'."
|
|
60
|
+
)
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
|
|
63
|
+
yield (k, alias)
|
|
64
|
+
case _: # pragma: no cover; unreachable
|
|
65
|
+
assert False, "This should never happen."
|
|
66
|
+
|
|
67
|
+
def _get_alias_resolver(self) -> Callable[[str, FieldInfo], list[str]]:
|
|
68
|
+
"""Helper for acquiring an alias resolver.
|
|
69
|
+
|
|
70
|
+
An alias resolver computes alias candidates given a field name and
|
|
71
|
+
FieldInfo object and according to validate_by_name/validate_by_alias flags.
|
|
72
|
+
|
|
73
|
+
Note that the deprecated populate_by_name flag is not supported.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config = self.model.model_config
|
|
77
|
+
|
|
78
|
+
if model_config.get("populate_by_name") is not None:
|
|
79
|
+
msg = (
|
|
80
|
+
"Config option 'populate_by_name' is not supported. "
|
|
81
|
+
"Use Pydantic >=2.11 flags 'validate_by_name'/'validate_by_alias' instead."
|
|
82
|
+
)
|
|
83
|
+
raise ValueError(msg)
|
|
84
|
+
|
|
85
|
+
validate_by_name, validate_by_alias = (
|
|
86
|
+
model_config.get("validate_by_name", False),
|
|
87
|
+
model_config.get("validate_by_alias", True),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
match validate_by_name, validate_by_alias:
|
|
91
|
+
case True, False: # TODO: trigger in tests
|
|
92
|
+
return lambda field_name, _: [field_name]
|
|
93
|
+
case False, True:
|
|
94
|
+
return lambda _, field_info: self._compute_alias_candidates(
|
|
95
|
+
field_info=field_info
|
|
96
|
+
)
|
|
97
|
+
case True, True: # TODO: trigger in tests
|
|
98
|
+
return lambda field_name, field_info: [
|
|
99
|
+
field_name,
|
|
100
|
+
*self._compute_alias_candidates(field_info=field_info),
|
|
101
|
+
]
|
|
102
|
+
case (
|
|
103
|
+
False,
|
|
104
|
+
False,
|
|
105
|
+
): # pragma: no cover; False, False raises error in Pydantic
|
|
106
|
+
msg = "Invalid config: validate_by_name=False, validate_by_alias=False."
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
case _: # pragma: no cover
|
|
109
|
+
assert False, "This should never happen."
|
|
110
|
+
|
|
111
|
+
def _compute_alias_candidates(self, field_info: FieldInfo) -> list[str]:
|
|
112
|
+
if (validation_alias := field_info.validation_alias):
|
|
113
|
+
return self._resolve_alias(validation_alias)
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _resolve_alias(alias: str | AliasChoices) -> list[str]:
|
|
118
|
+
"""Helper for resolving alias/validation_alias values in FieldInfo objects.
|
|
119
|
+
|
|
120
|
+
Note that pydantic.AliasPath is not meaningful in the context of flat relational binding mappings;
|
|
121
|
+
the method therefore raises an Exception for AliasPath and AliasChoices/AliasPath objects.
|
|
122
|
+
"""
|
|
123
|
+
match alias:
|
|
124
|
+
case str():
|
|
125
|
+
return [alias]
|
|
126
|
+
case AliasChoices(choices=choices) if all(
|
|
127
|
+
isinstance(choice, str) for choice in choices
|
|
128
|
+
):
|
|
129
|
+
return choices
|
|
130
|
+
case _:
|
|
131
|
+
msg = (
|
|
132
|
+
"Unable to resolve alias. "
|
|
133
|
+
f"Expected str or AliasChoices of str, got '{alias}'."
|
|
134
|
+
)
|
|
135
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import types
|
|
2
|
+
import typing
|
|
3
|
+
from typing import Annotated, TypeGuard, get_args, get_origin
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from typing_extensions import TypeForm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def de_annotate(type_form: TypeForm) -> TypeForm:
|
|
10
|
+
"""Unwrap potentially nested Annotated wrappers."""
|
|
11
|
+
|
|
12
|
+
if get_origin(type_form) is Annotated:
|
|
13
|
+
type_form, *_ = get_args(type_form)
|
|
14
|
+
return de_annotate(type_form)
|
|
15
|
+
return type_form
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_pydantic_model_static_type(type_form: TypeForm) -> TypeGuard[type[BaseModel]]:
|
|
19
|
+
"""Check if type_form denotes a Pydantic model type."""
|
|
20
|
+
type_form = de_annotate(type_form)
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
isinstance(type_form, type)
|
|
24
|
+
and issubclass(type_form, BaseModel)
|
|
25
|
+
and (type_form is not BaseModel)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_parametrized_list_static_type(type_form: TypeForm) -> TypeGuard[type[list]]:
|
|
30
|
+
"""Check if type_form denotes a parametrized list type."""
|
|
31
|
+
type_form = de_annotate(type_form)
|
|
32
|
+
return get_origin(type_form) is list
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_pydantic_model_union_static_type(
|
|
36
|
+
type_form: TypeForm,
|
|
37
|
+
) -> TypeGuard[types.UnionType]:
|
|
38
|
+
"""Check if type_form denotes a union type of a Pydantic model."""
|
|
39
|
+
|
|
40
|
+
type_form = de_annotate(type_form)
|
|
41
|
+
|
|
42
|
+
is_union_type: bool = get_origin(type_form) in (types.UnionType, typing.Union)
|
|
43
|
+
has_any_model: bool = any(
|
|
44
|
+
is_pydantic_model_static_type(obj) or is_pydantic_model_union_static_type(obj)
|
|
45
|
+
for obj in get_args(type_form)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return is_union_type and has_any_model
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_structured_field_static_type(type_form: TypeForm) -> bool:
|
|
52
|
+
"""Check if type_form denotes a structured field type.
|
|
53
|
+
|
|
54
|
+
A structured field type is a type that triggers a recursion
|
|
55
|
+
and/or aggregation code path in the GraphTy planner.
|
|
56
|
+
"""
|
|
57
|
+
return any(
|
|
58
|
+
predicate(type_form)
|
|
59
|
+
for predicate in [
|
|
60
|
+
is_pydantic_model_static_type,
|
|
61
|
+
is_pydantic_model_union_static_type,
|
|
62
|
+
is_parametrized_list_static_type,
|
|
63
|
+
]
|
|
64
|
+
)
|
graphty-0.1.0/.gitignore
DELETED
graphty-0.1.0/.python-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
3.13
|
graphty-0.1.0/PKG-INFO
DELETED
graphty-0.1.0/README.md
DELETED
graphty-0.1.0/pyproject.toml
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "graphty"
|
|
3
|
-
version = "0.1.0"
|
|
4
|
-
description = "Add your description here"
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
authors = [
|
|
7
|
-
{ name = "Lukas Plank", email = "lupl@tuta.io" }
|
|
8
|
-
]
|
|
9
|
-
requires-python = ">=3.13"
|
|
10
|
-
dependencies = []
|
|
11
|
-
|
|
12
|
-
[build-system]
|
|
13
|
-
requires = ["hatchling"]
|
|
14
|
-
build-backend = "hatchling.build"
|
|
File without changes
|