dataframely 1.7.3__cp310-abi3-win_amd64.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.
- dataframely/__init__.py +100 -0
- dataframely/_base_collection.py +343 -0
- dataframely/_base_schema.py +192 -0
- dataframely/_compat.py +39 -0
- dataframely/_deprecation.py +50 -0
- dataframely/_extre.pyd +0 -0
- dataframely/_extre.pyi +54 -0
- dataframely/_filter.py +48 -0
- dataframely/_polars.py +63 -0
- dataframely/_rule.py +220 -0
- dataframely/_serialization.py +113 -0
- dataframely/_typing.py +115 -0
- dataframely/_validation.py +85 -0
- dataframely/collection.py +972 -0
- dataframely/columns/__init__.py +47 -0
- dataframely/columns/_base.py +436 -0
- dataframely/columns/_mixins.py +95 -0
- dataframely/columns/_registry.py +32 -0
- dataframely/columns/_utils.py +60 -0
- dataframely/columns/any.py +81 -0
- dataframely/columns/array.py +134 -0
- dataframely/columns/bool.py +31 -0
- dataframely/columns/datetime.py +582 -0
- dataframely/columns/decimal.py +177 -0
- dataframely/columns/enum.py +90 -0
- dataframely/columns/float.py +216 -0
- dataframely/columns/integer.py +371 -0
- dataframely/columns/list.py +184 -0
- dataframely/columns/object.py +73 -0
- dataframely/columns/string.py +137 -0
- dataframely/columns/struct.py +150 -0
- dataframely/config.py +56 -0
- dataframely/exc.py +112 -0
- dataframely/failure.py +229 -0
- dataframely/functional.py +89 -0
- dataframely/mypy.py +424 -0
- dataframely/py.typed +0 -0
- dataframely/random.py +419 -0
- dataframely/schema.py +1018 -0
- dataframely/testing/__init__.py +29 -0
- dataframely/testing/const.py +54 -0
- dataframely/testing/factory.py +63 -0
- dataframely/testing/mask.py +46 -0
- dataframely/testing/rules.py +33 -0
- dataframely/testing/typing.py +30 -0
- dataframely-1.7.3.dist-info/METADATA +101 -0
- dataframely-1.7.3.dist-info/RECORD +49 -0
- dataframely-1.7.3.dist-info/WHEEL +4 -0
- dataframely-1.7.3.dist-info/licenses/LICENSE +29 -0
dataframely/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = importlib.metadata.version(__name__)
|
|
9
|
+
except importlib.metadata.PackageNotFoundError as e: # pragma: no cover
|
|
10
|
+
warnings.warn(f"Could not determine version of {__name__}\n{e!s}", stacklevel=2)
|
|
11
|
+
__version__ = "unknown"
|
|
12
|
+
|
|
13
|
+
from . import random
|
|
14
|
+
from ._base_collection import CollectionMember
|
|
15
|
+
from ._filter import filter
|
|
16
|
+
from ._rule import rule
|
|
17
|
+
from ._typing import DataFrame, LazyFrame, Validation
|
|
18
|
+
from .collection import Collection, deserialize_collection
|
|
19
|
+
from .columns import (
|
|
20
|
+
Any,
|
|
21
|
+
Array,
|
|
22
|
+
Bool,
|
|
23
|
+
Column,
|
|
24
|
+
Date,
|
|
25
|
+
Datetime,
|
|
26
|
+
Decimal,
|
|
27
|
+
Duration,
|
|
28
|
+
Enum,
|
|
29
|
+
Float,
|
|
30
|
+
Float32,
|
|
31
|
+
Float64,
|
|
32
|
+
Int8,
|
|
33
|
+
Int16,
|
|
34
|
+
Int32,
|
|
35
|
+
Int64,
|
|
36
|
+
Integer,
|
|
37
|
+
List,
|
|
38
|
+
Object,
|
|
39
|
+
String,
|
|
40
|
+
Struct,
|
|
41
|
+
Time,
|
|
42
|
+
UInt8,
|
|
43
|
+
UInt16,
|
|
44
|
+
UInt32,
|
|
45
|
+
UInt64,
|
|
46
|
+
)
|
|
47
|
+
from .config import Config
|
|
48
|
+
from .failure import FailureInfo
|
|
49
|
+
from .functional import (
|
|
50
|
+
concat_collection_members,
|
|
51
|
+
filter_relationship_one_to_at_least_one,
|
|
52
|
+
filter_relationship_one_to_one,
|
|
53
|
+
)
|
|
54
|
+
from .schema import Schema, deserialize_schema, read_parquet_metadata_schema
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"random",
|
|
58
|
+
"filter",
|
|
59
|
+
"rule",
|
|
60
|
+
"DataFrame",
|
|
61
|
+
"LazyFrame",
|
|
62
|
+
"Collection",
|
|
63
|
+
"CollectionMember",
|
|
64
|
+
"deserialize_collection",
|
|
65
|
+
"Config",
|
|
66
|
+
"FailureInfo",
|
|
67
|
+
"concat_collection_members",
|
|
68
|
+
"filter_relationship_one_to_at_least_one",
|
|
69
|
+
"filter_relationship_one_to_one",
|
|
70
|
+
"Schema",
|
|
71
|
+
"deserialize_schema",
|
|
72
|
+
"read_parquet_metadata_schema",
|
|
73
|
+
"Any",
|
|
74
|
+
"Bool",
|
|
75
|
+
"Column",
|
|
76
|
+
"Date",
|
|
77
|
+
"Datetime",
|
|
78
|
+
"Decimal",
|
|
79
|
+
"Duration",
|
|
80
|
+
"Time",
|
|
81
|
+
"Enum",
|
|
82
|
+
"Float",
|
|
83
|
+
"Float32",
|
|
84
|
+
"Float64",
|
|
85
|
+
"Int8",
|
|
86
|
+
"Int16",
|
|
87
|
+
"Int32",
|
|
88
|
+
"Int64",
|
|
89
|
+
"Integer",
|
|
90
|
+
"UInt8",
|
|
91
|
+
"UInt16",
|
|
92
|
+
"UInt32",
|
|
93
|
+
"UInt64",
|
|
94
|
+
"String",
|
|
95
|
+
"Struct",
|
|
96
|
+
"List",
|
|
97
|
+
"Array",
|
|
98
|
+
"Object",
|
|
99
|
+
"Validation",
|
|
100
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import textwrap
|
|
7
|
+
import typing
|
|
8
|
+
from abc import ABCMeta
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Annotated, Any, cast, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
import polars as pl
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
from ._filter import Filter
|
|
17
|
+
from ._typing import LazyFrame as TypedLazyFrame
|
|
18
|
+
from .exc import AnnotationImplementationError, ImplementationError
|
|
19
|
+
from .schema import Schema
|
|
20
|
+
|
|
21
|
+
_MEMBER_ATTR = "__dataframely_members__"
|
|
22
|
+
_FILTER_ATTR = "__dataframely_filters__"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(kw_only=True)
|
|
26
|
+
class CollectionMember:
|
|
27
|
+
"""An annotation class that configures different behavior for a collection member.
|
|
28
|
+
|
|
29
|
+
Members:
|
|
30
|
+
ignored_in_filters: Indicates that a member should be ignored in the
|
|
31
|
+
``@dy.filter`` methods of a collection. This also affects the computation
|
|
32
|
+
of the shared primary key in the collection.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
.. code:: python
|
|
36
|
+
|
|
37
|
+
class MyCollection(dy.Collection):
|
|
38
|
+
a: dy.LazyFrame[MySchema1]
|
|
39
|
+
b: dy.LazyFrame[MySchema2]
|
|
40
|
+
|
|
41
|
+
ignored_member: Annotated[
|
|
42
|
+
dy.LazyFrame[MySchema3],
|
|
43
|
+
dy.CollectionMember(ignored_in_filters=True)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
@dy.filter
|
|
47
|
+
def my_filter(self) -> pl.DataFrame:
|
|
48
|
+
return self.a.join(self.b, on="shared_key")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
#: Whether the member should be ignored in the filter method.
|
|
52
|
+
ignored_in_filters: bool = False
|
|
53
|
+
#: Whether the member's non-primary key columns should be inlined for sampling.
|
|
54
|
+
#: This means that value overrides are supplied on the top-level rather than in
|
|
55
|
+
#: a subkey with the member's name. Only valid if the member's primary key matches
|
|
56
|
+
#: the collection's common primary key. Two members that share common column names
|
|
57
|
+
#: may not both be inlined for sampling.
|
|
58
|
+
inline_for_sampling: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------- UTILS -------------------------------------- #
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _common_primary_keys(columns: Iterable[type[Schema]]) -> set[str]:
|
|
65
|
+
return set.intersection(*[set(schema.primary_keys()) for schema in columns])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------------------------ #
|
|
69
|
+
# COLLECTION META #
|
|
70
|
+
# ------------------------------------------------------------------------------------ #
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class MemberInfo(CollectionMember):
|
|
75
|
+
"""Information about a member of a collection."""
|
|
76
|
+
|
|
77
|
+
#: The schema of the member.
|
|
78
|
+
schema: type[Schema]
|
|
79
|
+
#: Whether the member is optional.
|
|
80
|
+
is_optional: bool
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Metadata:
|
|
85
|
+
"""Utility class to gather members and filters associated with a collection."""
|
|
86
|
+
|
|
87
|
+
members: dict[str, MemberInfo] = field(default_factory=dict)
|
|
88
|
+
filters: dict[str, Filter] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
def update(self, other: Self) -> None:
|
|
91
|
+
self.members.update(other.members)
|
|
92
|
+
self.filters.update(other.filters)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CollectionMeta(ABCMeta):
|
|
96
|
+
def __new__(
|
|
97
|
+
mcs, # noqa: N804
|
|
98
|
+
name: str,
|
|
99
|
+
bases: tuple[type[object], ...],
|
|
100
|
+
namespace: dict[str, Any],
|
|
101
|
+
*args: Any,
|
|
102
|
+
**kwargs: Any,
|
|
103
|
+
) -> CollectionMeta:
|
|
104
|
+
result = Metadata()
|
|
105
|
+
for base in bases:
|
|
106
|
+
result.update(mcs._get_metadata_recursively(base))
|
|
107
|
+
result.update(mcs._get_metadata(namespace))
|
|
108
|
+
namespace[_MEMBER_ATTR] = result.members
|
|
109
|
+
namespace[_FILTER_ATTR] = result.filters
|
|
110
|
+
|
|
111
|
+
# We now have all necessary information about filters and members. We want to
|
|
112
|
+
# check some preconditions to not run into issues later...
|
|
113
|
+
|
|
114
|
+
non_ignored_member_schemas = [
|
|
115
|
+
m.schema for m in result.members.values() if not m.ignored_in_filters
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
# 1) Check that there are overlapping primary keys that allow the application
|
|
119
|
+
# of filters.
|
|
120
|
+
if len(non_ignored_member_schemas) > 0 and len(result.filters) > 0:
|
|
121
|
+
if len(_common_primary_keys(non_ignored_member_schemas)) == 0:
|
|
122
|
+
raise ImplementationError(
|
|
123
|
+
"Members of a collection must have an overlapping primary key "
|
|
124
|
+
"but did not find any."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# 2) Check that filter names do not overlap with any column or rule names
|
|
128
|
+
if len(result.members) > 0:
|
|
129
|
+
taken = set.union(
|
|
130
|
+
*(
|
|
131
|
+
set(member.schema.column_names())
|
|
132
|
+
for member in result.members.values()
|
|
133
|
+
),
|
|
134
|
+
*(
|
|
135
|
+
set(member.schema._validation_rules())
|
|
136
|
+
for member in result.members.values()
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
intersection = taken & set(result.filters)
|
|
140
|
+
if len(intersection) > 0:
|
|
141
|
+
raise ImplementationError(
|
|
142
|
+
"Filters defined on the collection must not be named the same as any "
|
|
143
|
+
"column or rule in any of the member frames but found "
|
|
144
|
+
f"{len(intersection)} such filters: {sorted(intersection)}."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 3) Check that inlining for sampling is configured correctly.
|
|
148
|
+
if len(non_ignored_member_schemas) > 0:
|
|
149
|
+
common_primary_keys = _common_primary_keys(non_ignored_member_schemas)
|
|
150
|
+
inlined_columns: set[str] = set()
|
|
151
|
+
for member, info in result.members.items():
|
|
152
|
+
if info.inline_for_sampling:
|
|
153
|
+
if set(info.schema.primary_keys()) != common_primary_keys:
|
|
154
|
+
raise ImplementationError(
|
|
155
|
+
f"Member '{member}' is inlined for sampling but its primary "
|
|
156
|
+
"key is a superset of the common primary key. Such a member "
|
|
157
|
+
"must not be inlined to be able to provide multiple values "
|
|
158
|
+
"for a single combination of the common primary key."
|
|
159
|
+
)
|
|
160
|
+
non_primary_key_columns = (
|
|
161
|
+
set(info.schema.column_names()) - common_primary_keys
|
|
162
|
+
)
|
|
163
|
+
if len(inlined_columns & non_primary_key_columns):
|
|
164
|
+
raise ImplementationError(
|
|
165
|
+
f"At least one column name of member '{member}' clashes "
|
|
166
|
+
"with a column name of another member that is inlined for "
|
|
167
|
+
"sampling."
|
|
168
|
+
)
|
|
169
|
+
inlined_columns.update(non_primary_key_columns)
|
|
170
|
+
|
|
171
|
+
return super().__new__(mcs, name, bases, namespace, *args, **kwargs)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _get_metadata_recursively(kls: type[object]) -> Metadata:
|
|
175
|
+
result = Metadata()
|
|
176
|
+
for base in kls.__bases__:
|
|
177
|
+
result.update(CollectionMeta._get_metadata_recursively(base))
|
|
178
|
+
result.update(CollectionMeta._get_metadata(kls.__dict__)) # type: ignore
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _get_metadata(source: dict[str, Any]) -> Metadata:
|
|
183
|
+
result = Metadata()
|
|
184
|
+
|
|
185
|
+
# Get all members via the annotations
|
|
186
|
+
if "__annotations__" in source:
|
|
187
|
+
for attr, kls in source["__annotations__"].items():
|
|
188
|
+
result.members[attr] = CollectionMeta._derive_member_info(
|
|
189
|
+
attr, kls, CollectionMember()
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Get all filters by traversing the source
|
|
193
|
+
for attr, value in {
|
|
194
|
+
k: v for k, v in source.items() if not k.startswith("__")
|
|
195
|
+
}.items():
|
|
196
|
+
if isinstance(value, Filter):
|
|
197
|
+
result.filters[attr] = value
|
|
198
|
+
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _derive_member_info(
|
|
203
|
+
attr: str, type_annotation: Any, collection_member: CollectionMember
|
|
204
|
+
) -> MemberInfo:
|
|
205
|
+
origin = get_origin(type_annotation)
|
|
206
|
+
|
|
207
|
+
if origin is None:
|
|
208
|
+
# `None` annotation is not allowed
|
|
209
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
210
|
+
elif origin == Annotated:
|
|
211
|
+
# Maybe happy path: annotated member, dispatch recursively
|
|
212
|
+
annotation_args = cast(list[Any], get_args(type_annotation))
|
|
213
|
+
if len(annotation_args) > 2:
|
|
214
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
215
|
+
if not isinstance(annotation_args[1], CollectionMember):
|
|
216
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
217
|
+
return CollectionMeta._derive_member_info(
|
|
218
|
+
attr, annotation_args[0], annotation_args[1]
|
|
219
|
+
)
|
|
220
|
+
elif origin == typing.Union:
|
|
221
|
+
# Happy path: optional member
|
|
222
|
+
union_args = get_args(type_annotation)
|
|
223
|
+
if len(union_args) != 2:
|
|
224
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
225
|
+
if not any(get_origin(arg) is None for arg in union_args):
|
|
226
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
227
|
+
|
|
228
|
+
[not_none_arg] = [arg for arg in union_args if get_origin(arg) is not None]
|
|
229
|
+
if not issubclass(get_origin(not_none_arg), TypedLazyFrame):
|
|
230
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
231
|
+
|
|
232
|
+
return MemberInfo(
|
|
233
|
+
schema=get_args(not_none_arg)[0],
|
|
234
|
+
is_optional=True,
|
|
235
|
+
ignored_in_filters=collection_member.ignored_in_filters,
|
|
236
|
+
inline_for_sampling=collection_member.inline_for_sampling,
|
|
237
|
+
)
|
|
238
|
+
elif issubclass(origin, TypedLazyFrame):
|
|
239
|
+
# Happy path: required member
|
|
240
|
+
return MemberInfo(
|
|
241
|
+
schema=get_args(type_annotation)[0],
|
|
242
|
+
is_optional=False,
|
|
243
|
+
ignored_in_filters=collection_member.ignored_in_filters,
|
|
244
|
+
inline_for_sampling=collection_member.inline_for_sampling,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
# Some other unknown annotation
|
|
248
|
+
raise AnnotationImplementationError(attr, type_annotation)
|
|
249
|
+
|
|
250
|
+
def __repr__(cls) -> str:
|
|
251
|
+
parts = [f'[Collection "{cls.__class__.__name__}"]']
|
|
252
|
+
parts.append(textwrap.indent("Members:", prefix=" " * 2))
|
|
253
|
+
for name, member in cls.members().items(): # type: ignore
|
|
254
|
+
parts.append(
|
|
255
|
+
textwrap.indent(
|
|
256
|
+
f'- "{name}": {member.schema.__name__}'
|
|
257
|
+
f"(optional={member.is_optional}, "
|
|
258
|
+
f"ignored_in_filters={member.ignored_in_filters}, "
|
|
259
|
+
f"inline_for_sampling={member.inline_for_sampling})",
|
|
260
|
+
prefix=" " * 4,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
if filters := cls._filters(): # type: ignore
|
|
264
|
+
parts.append(textwrap.indent("Filters:", prefix=" " * 2))
|
|
265
|
+
for name, member in filters.items():
|
|
266
|
+
parts.append(textwrap.indent(f'- "{name}":', prefix=" " * 4))
|
|
267
|
+
parts.append(
|
|
268
|
+
textwrap.indent(
|
|
269
|
+
f"{member.logic(cls.create_empty()).explain()}", # type: ignore
|
|
270
|
+
prefix=" " * 8,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
parts.append("") # Add line break at the end
|
|
274
|
+
return "\n".join(parts)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class BaseCollection(metaclass=CollectionMeta):
|
|
278
|
+
"""Internal utility abstraction to reference collections without introducing
|
|
279
|
+
cyclical dependencies."""
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def members(cls) -> dict[str, MemberInfo]:
|
|
283
|
+
"""Information about the members of the collection."""
|
|
284
|
+
return getattr(cls, _MEMBER_ATTR)
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def member_schemas(cls) -> dict[str, type[Schema]]:
|
|
288
|
+
"""The schemas of all members of the collection."""
|
|
289
|
+
return {name: member.schema for name, member in cls.members().items()}
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def required_members(cls) -> set[str]:
|
|
293
|
+
"""The names of all required members of the collection."""
|
|
294
|
+
return {
|
|
295
|
+
name for name, member in cls.members().items() if not member.is_optional
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def optional_members(cls) -> set[str]:
|
|
300
|
+
"""The names of all optional members of the collection."""
|
|
301
|
+
return {name for name, member in cls.members().items() if member.is_optional}
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def ignored_members(cls) -> set[str]:
|
|
305
|
+
"""The names of all members of the collection that are ignored in filters."""
|
|
306
|
+
return {
|
|
307
|
+
name for name, member in cls.members().items() if member.ignored_in_filters
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def non_ignored_members(cls) -> set[str]:
|
|
312
|
+
"""The names of all members of the collection that are not ignored in filters
|
|
313
|
+
(default)."""
|
|
314
|
+
return {
|
|
315
|
+
name
|
|
316
|
+
for name, member in cls.members().items()
|
|
317
|
+
if not member.ignored_in_filters
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def common_primary_keys(cls) -> list[str]:
|
|
322
|
+
"""The primary keys shared by non ignored members of the collection."""
|
|
323
|
+
return sorted(
|
|
324
|
+
_common_primary_keys(
|
|
325
|
+
[
|
|
326
|
+
member.schema
|
|
327
|
+
for member in cls.members().values()
|
|
328
|
+
if not member.ignored_in_filters
|
|
329
|
+
]
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
def _filters(cls) -> dict[str, Filter[Self]]:
|
|
335
|
+
return getattr(cls, _FILTER_ATTR)
|
|
336
|
+
|
|
337
|
+
def to_dict(self) -> dict[str, pl.LazyFrame]:
|
|
338
|
+
"""Return a dictionary representation of this collection."""
|
|
339
|
+
return {
|
|
340
|
+
member: getattr(self, member)
|
|
341
|
+
for member in self.member_schemas()
|
|
342
|
+
if getattr(self, member) is not None
|
|
343
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import textwrap
|
|
7
|
+
from abc import ABCMeta
|
|
8
|
+
from copy import copy
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
from typing_extensions import Self
|
|
14
|
+
|
|
15
|
+
from ._rule import GroupRule, Rule
|
|
16
|
+
from .columns import Column
|
|
17
|
+
from .exc import ImplementationError
|
|
18
|
+
|
|
19
|
+
_COLUMN_ATTR = "__dataframely_columns__"
|
|
20
|
+
_RULE_ATTR = "__dataframely_rules__"
|
|
21
|
+
|
|
22
|
+
# --------------------------------------- UTILS -------------------------------------- #
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_rules(
|
|
26
|
+
custom: dict[str, Rule], columns: dict[str, Column]
|
|
27
|
+
) -> dict[str, Rule]:
|
|
28
|
+
# NOTE: Copy here to prevent in-place modification of the custom rules
|
|
29
|
+
rules: dict[str, Rule] = copy(custom)
|
|
30
|
+
|
|
31
|
+
# Add primary key validation to the list of rules if applicable
|
|
32
|
+
primary_keys = _primary_keys(columns)
|
|
33
|
+
if len(primary_keys) > 0:
|
|
34
|
+
rules["primary_key"] = Rule(~pl.struct(primary_keys).is_duplicated())
|
|
35
|
+
|
|
36
|
+
# Add column-specific rules
|
|
37
|
+
column_rules = {
|
|
38
|
+
f"{col_name}|{rule_name}": Rule(expr)
|
|
39
|
+
for col_name, column in columns.items()
|
|
40
|
+
for rule_name, expr in column.validation_rules(pl.col(col_name)).items()
|
|
41
|
+
}
|
|
42
|
+
rules.update(column_rules)
|
|
43
|
+
|
|
44
|
+
return rules
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _primary_keys(columns: dict[str, Column]) -> list[str]:
|
|
48
|
+
return list(k for k, col in columns.items() if col.primary_key)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------------------------ #
|
|
52
|
+
# SCHEMA META #
|
|
53
|
+
# ------------------------------------------------------------------------------------ #
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Metadata:
|
|
58
|
+
"""Utility class to gather columns and rules associated with a schema."""
|
|
59
|
+
|
|
60
|
+
columns: dict[str, Column] = field(default_factory=dict)
|
|
61
|
+
rules: dict[str, Rule] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
def update(self, other: Self) -> None:
|
|
64
|
+
self.columns.update(other.columns)
|
|
65
|
+
self.rules.update(other.rules)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SchemaMeta(ABCMeta):
|
|
69
|
+
def __new__(
|
|
70
|
+
mcs, # noqa: N804
|
|
71
|
+
name: str,
|
|
72
|
+
bases: tuple[type[object], ...],
|
|
73
|
+
namespace: dict[str, Any],
|
|
74
|
+
*args: Any,
|
|
75
|
+
**kwargs: Any,
|
|
76
|
+
) -> SchemaMeta:
|
|
77
|
+
result = Metadata()
|
|
78
|
+
for base in bases:
|
|
79
|
+
result.update(mcs._get_metadata_recursively(base))
|
|
80
|
+
result.update(mcs._get_metadata(namespace))
|
|
81
|
+
namespace[_COLUMN_ATTR] = result.columns
|
|
82
|
+
namespace[_RULE_ATTR] = result.rules
|
|
83
|
+
|
|
84
|
+
# At this point, we already know all columns and custom rules. We want to run
|
|
85
|
+
# some checks...
|
|
86
|
+
|
|
87
|
+
# 1) Check that the column names clash with none of the rule names. To this end,
|
|
88
|
+
# we assume that users cast dtypes, i.e. additional rules for dtype casting
|
|
89
|
+
# are also checked.
|
|
90
|
+
all_column_names = set(result.columns)
|
|
91
|
+
all_rule_names = set(_build_rules(result.rules, result.columns).keys()) | set(
|
|
92
|
+
f"{col}|dtype" for col in result.columns
|
|
93
|
+
)
|
|
94
|
+
common_names = all_column_names & all_rule_names
|
|
95
|
+
if len(common_names) > 0:
|
|
96
|
+
common_list = ", ".join(sorted(f"'{col}'" for col in common_names))
|
|
97
|
+
raise ImplementationError(
|
|
98
|
+
"Rules and columns must not be named equally but found "
|
|
99
|
+
f"{len(common_names)} overlaps: {common_list}."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 2) Check that the columns referenced in the group rules exist.
|
|
103
|
+
for rule_name, rule in result.rules.items():
|
|
104
|
+
if isinstance(rule, GroupRule):
|
|
105
|
+
missing_columns = set(rule.group_columns) - set(result.columns)
|
|
106
|
+
if len(missing_columns) > 0:
|
|
107
|
+
missing_list = ", ".join(
|
|
108
|
+
sorted(f"'{col}'" for col in missing_columns)
|
|
109
|
+
)
|
|
110
|
+
raise ImplementationError(
|
|
111
|
+
f"Group validation rule '{rule_name}' has been implemented "
|
|
112
|
+
f"incorrectly. It references {len(missing_columns)} columns "
|
|
113
|
+
f"which are not in the schema: {missing_list}."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return super().__new__(mcs, name, bases, namespace, *args, **kwargs)
|
|
117
|
+
|
|
118
|
+
def __getattribute__(cls, name: str) -> Any:
|
|
119
|
+
val = super().__getattribute__(name)
|
|
120
|
+
# Dynamically set the name of the column if it is a `Column` instance.
|
|
121
|
+
if isinstance(val, Column):
|
|
122
|
+
val._name = val.alias or name
|
|
123
|
+
return val
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _get_metadata_recursively(kls: type[object]) -> Metadata:
|
|
127
|
+
result = Metadata()
|
|
128
|
+
for base in kls.__bases__:
|
|
129
|
+
result.update(SchemaMeta._get_metadata_recursively(base))
|
|
130
|
+
result.update(SchemaMeta._get_metadata(kls.__dict__)) # type: ignore
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _get_metadata(source: dict[str, Any]) -> Metadata:
|
|
135
|
+
result = Metadata()
|
|
136
|
+
for attr, value in {
|
|
137
|
+
k: v for k, v in source.items() if not k.startswith("__")
|
|
138
|
+
}.items():
|
|
139
|
+
if isinstance(value, Column):
|
|
140
|
+
result.columns[value.alias or attr] = value
|
|
141
|
+
if isinstance(value, Rule):
|
|
142
|
+
# We must ensure that custom rules do not clash with internal rules.
|
|
143
|
+
if attr == "primary_key":
|
|
144
|
+
raise ImplementationError(
|
|
145
|
+
"Custom validation rule must not be named `primary_key`."
|
|
146
|
+
)
|
|
147
|
+
result.rules[attr] = value
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def __repr__(cls) -> str:
|
|
151
|
+
parts = [f'[Schema "{cls.__name__}"]']
|
|
152
|
+
parts.append(textwrap.indent("Columns:", prefix=" " * 2))
|
|
153
|
+
for name, col in cls.columns().items():
|
|
154
|
+
parts.append(textwrap.indent(f'- "{name}": {col!r}', prefix=" " * 4))
|
|
155
|
+
if validation_rules := cls._schema_validation_rules():
|
|
156
|
+
parts.append(textwrap.indent("Rules:", prefix=" " * 2))
|
|
157
|
+
for name, rule in validation_rules.items():
|
|
158
|
+
parts.append(textwrap.indent(f'- "{name}": {rule!r}', prefix=" " * 4))
|
|
159
|
+
parts.append("") # Add line break at the end
|
|
160
|
+
return "\n".join(parts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class BaseSchema(metaclass=SchemaMeta):
|
|
164
|
+
"""Internal utility abstraction to reference schemas without introducing cyclical
|
|
165
|
+
dependencies."""
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def column_names(cls) -> list[str]:
|
|
169
|
+
"""The column names of this schema."""
|
|
170
|
+
return list(getattr(cls, _COLUMN_ATTR).keys())
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def columns(cls) -> dict[str, Column]:
|
|
174
|
+
"""The column definitions of this schema."""
|
|
175
|
+
columns: dict[str, Column] = getattr(cls, _COLUMN_ATTR)
|
|
176
|
+
for name in columns.keys():
|
|
177
|
+
# Dynamically set the name of the columns.
|
|
178
|
+
columns[name]._name = name
|
|
179
|
+
return columns
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def primary_keys(cls) -> list[str]:
|
|
183
|
+
"""The primary key columns in this schema (possibly empty)."""
|
|
184
|
+
return _primary_keys(cls.columns())
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def _validation_rules(cls) -> dict[str, Rule]:
|
|
188
|
+
return _build_rules(cls._schema_validation_rules(), cls.columns())
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def _schema_validation_rules(cls) -> dict[str, Rule]:
|
|
192
|
+
return getattr(cls, _RULE_ATTR)
|
dataframely/_compat.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _DummyModule: # pragma: no cover
|
|
9
|
+
def __init__(self, module: str) -> None:
|
|
10
|
+
self.module = module
|
|
11
|
+
|
|
12
|
+
def __getattr__(self, name: str) -> Any:
|
|
13
|
+
raise ValueError(f"Module '{self.module}' is not installed.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ------------------------------------ SQLALCHEMY ------------------------------------ #
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import sqlalchemy as sa
|
|
20
|
+
import sqlalchemy.dialects.mssql as sa_mssql
|
|
21
|
+
from sqlalchemy.sql.type_api import TypeEngine as sa_TypeEngine
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
sa = _DummyModule("sqlalchemy") # type: ignore
|
|
24
|
+
sa_mssql = _DummyModule("sqlalchemy") # type: ignore
|
|
25
|
+
|
|
26
|
+
class sa_TypeEngine: # type: ignore # noqa: N801
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# -------------------------------------- PYARROW ------------------------------------- #
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import pyarrow as pa
|
|
34
|
+
except ImportError: # pragma: no cover
|
|
35
|
+
pa = _DummyModule("pyarrow")
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------------------------ #
|
|
38
|
+
|
|
39
|
+
__all__ = ["sa", "sa_mssql", "sa_TypeEngine", "pa"]
|