dataframely 1.0.0__cp311-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 +92 -0
- dataframely/_base_collection.py +285 -0
- dataframely/_base_schema.py +186 -0
- dataframely/_compat.py +39 -0
- dataframely/_extre.pyd +0 -0
- dataframely/_extre.pyi +54 -0
- dataframely/_filter.py +42 -0
- dataframely/_polars.py +63 -0
- dataframely/_rule.py +130 -0
- dataframely/_typing.py +97 -0
- dataframely/_validation.py +85 -0
- dataframely/collection.py +616 -0
- dataframely/columns/__init__.py +41 -0
- dataframely/columns/_base.py +191 -0
- dataframely/columns/_mixins.py +94 -0
- dataframely/columns/_utils.py +60 -0
- dataframely/columns/any.py +72 -0
- dataframely/columns/bool.py +31 -0
- dataframely/columns/datetime.py +501 -0
- dataframely/columns/decimal.py +162 -0
- dataframely/columns/enum.py +76 -0
- dataframely/columns/float.py +201 -0
- dataframely/columns/integer.py +349 -0
- dataframely/columns/list.py +150 -0
- dataframely/columns/string.py +126 -0
- dataframely/columns/struct.py +105 -0
- dataframely/config.py +54 -0
- dataframely/exc.py +125 -0
- dataframely/failure.py +144 -0
- dataframely/functional.py +85 -0
- dataframely/mypy.py +401 -0
- dataframely/py.typed +0 -0
- dataframely/random.py +409 -0
- dataframely/schema.py +615 -0
- dataframely/testing/__init__.py +27 -0
- dataframely/testing/const.py +49 -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.0.0.dist-info/METADATA +97 -0
- dataframely-1.0.0.dist-info/RECORD +44 -0
- dataframely-1.0.0.dist-info/WHEEL +4 -0
- dataframely-1.0.0.dist-info/licenses/LICENSE +29 -0
dataframely/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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
|
|
18
|
+
from .collection import Collection
|
|
19
|
+
from .columns import (
|
|
20
|
+
Any,
|
|
21
|
+
Bool,
|
|
22
|
+
Column,
|
|
23
|
+
Date,
|
|
24
|
+
Datetime,
|
|
25
|
+
Decimal,
|
|
26
|
+
Duration,
|
|
27
|
+
Enum,
|
|
28
|
+
Float,
|
|
29
|
+
Float32,
|
|
30
|
+
Float64,
|
|
31
|
+
Int8,
|
|
32
|
+
Int16,
|
|
33
|
+
Int32,
|
|
34
|
+
Int64,
|
|
35
|
+
Integer,
|
|
36
|
+
List,
|
|
37
|
+
String,
|
|
38
|
+
Struct,
|
|
39
|
+
Time,
|
|
40
|
+
UInt8,
|
|
41
|
+
UInt16,
|
|
42
|
+
UInt32,
|
|
43
|
+
UInt64,
|
|
44
|
+
)
|
|
45
|
+
from .config import Config
|
|
46
|
+
from .failure import FailureInfo
|
|
47
|
+
from .functional import (
|
|
48
|
+
concat_collection_members,
|
|
49
|
+
filter_relationship_one_to_at_least_one,
|
|
50
|
+
filter_relationship_one_to_one,
|
|
51
|
+
)
|
|
52
|
+
from .schema import Schema
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"random",
|
|
56
|
+
"filter",
|
|
57
|
+
"rule",
|
|
58
|
+
"DataFrame",
|
|
59
|
+
"LazyFrame",
|
|
60
|
+
"Collection",
|
|
61
|
+
"CollectionMember",
|
|
62
|
+
"Config",
|
|
63
|
+
"FailureInfo",
|
|
64
|
+
"concat_collection_members",
|
|
65
|
+
"filter_relationship_one_to_at_least_one",
|
|
66
|
+
"filter_relationship_one_to_one",
|
|
67
|
+
"Schema",
|
|
68
|
+
"Any",
|
|
69
|
+
"Bool",
|
|
70
|
+
"Column",
|
|
71
|
+
"Date",
|
|
72
|
+
"Datetime",
|
|
73
|
+
"Decimal",
|
|
74
|
+
"Duration",
|
|
75
|
+
"Time",
|
|
76
|
+
"Enum",
|
|
77
|
+
"Float",
|
|
78
|
+
"Float32",
|
|
79
|
+
"Float64",
|
|
80
|
+
"Int8",
|
|
81
|
+
"Int16",
|
|
82
|
+
"Int32",
|
|
83
|
+
"Int64",
|
|
84
|
+
"Integer",
|
|
85
|
+
"UInt8",
|
|
86
|
+
"UInt16",
|
|
87
|
+
"UInt32",
|
|
88
|
+
"UInt64",
|
|
89
|
+
"String",
|
|
90
|
+
"Struct",
|
|
91
|
+
"List",
|
|
92
|
+
]
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import typing
|
|
7
|
+
from abc import ABCMeta
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Annotated, Any, Self, get_args, get_origin
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
|
|
14
|
+
from ._filter import Filter
|
|
15
|
+
from ._typing import LazyFrame as TypedLazyFrame
|
|
16
|
+
from .exc import AnnotationImplementationError, ImplementationError
|
|
17
|
+
from .schema import Schema
|
|
18
|
+
|
|
19
|
+
_MEMBER_ATTR = "__dataframely_members__"
|
|
20
|
+
_FILTER_ATTR = "__dataframely_filters__"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(kw_only=True)
|
|
24
|
+
class CollectionMember:
|
|
25
|
+
"""An annotation class that configures different behavior for a collection member.
|
|
26
|
+
|
|
27
|
+
Members:
|
|
28
|
+
ignored_in_filters: Indicates that a member should be ignored in the
|
|
29
|
+
``@dy.filter`` methods of a collection. This also affects the computation
|
|
30
|
+
of the shared primary key in the collection.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
.. code:: python
|
|
34
|
+
|
|
35
|
+
class MyCollection(dy.Collection):
|
|
36
|
+
a: dy.LazyFrame[MySchema1]
|
|
37
|
+
b: dy.LazyFrame[MySchema2]
|
|
38
|
+
|
|
39
|
+
ignored_member: Annotated[
|
|
40
|
+
dy.LazyFrame[MySchema3],
|
|
41
|
+
dy.CollectionMember(ignored_in_filters=True)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
@dy.filter
|
|
45
|
+
def my_filter(self) -> pl.DataFrame:
|
|
46
|
+
return self.a.join(self.b, on="shared_key")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
#: Whether the member should be ignored in the filter method.
|
|
50
|
+
ignored_in_filters: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --------------------------------------- UTILS -------------------------------------- #
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _common_primary_keys(columns: Iterable[type[Schema]]) -> set[str]:
|
|
57
|
+
return set.intersection(*[set(schema.primary_keys()) for schema in columns])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------------------------ #
|
|
61
|
+
# COLLECTION META #
|
|
62
|
+
# ------------------------------------------------------------------------------------ #
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class MemberInfo(CollectionMember):
|
|
67
|
+
"""Information about a member of a collection."""
|
|
68
|
+
|
|
69
|
+
#: The schema of the member.
|
|
70
|
+
schema: type[Schema]
|
|
71
|
+
#: Whether the member is optional.
|
|
72
|
+
is_optional: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Metadata:
|
|
77
|
+
"""Utility class to gather members and filters associated with a collection."""
|
|
78
|
+
|
|
79
|
+
members: dict[str, MemberInfo] = field(default_factory=dict)
|
|
80
|
+
filters: dict[str, Filter] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
def update(self, other: Self):
|
|
83
|
+
self.members.update(other.members)
|
|
84
|
+
self.filters.update(other.filters)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CollectionMeta(ABCMeta):
|
|
88
|
+
def __new__(
|
|
89
|
+
mcs, # noqa: N804
|
|
90
|
+
name: str,
|
|
91
|
+
bases: tuple[type[object], ...],
|
|
92
|
+
namespace: dict[str, Any],
|
|
93
|
+
*args: Any,
|
|
94
|
+
**kwargs: Any,
|
|
95
|
+
):
|
|
96
|
+
result = Metadata()
|
|
97
|
+
for base in bases:
|
|
98
|
+
result.update(mcs._get_metadata_recursively(base))
|
|
99
|
+
result.update(mcs._get_metadata(namespace))
|
|
100
|
+
namespace[_MEMBER_ATTR] = result.members
|
|
101
|
+
namespace[_FILTER_ATTR] = result.filters
|
|
102
|
+
|
|
103
|
+
# We now have all necessary information about filters and members. We want to
|
|
104
|
+
# check some preconditions to not run into issues later...
|
|
105
|
+
|
|
106
|
+
non_ignored_member_schemas = [
|
|
107
|
+
m.schema for m in result.members.values() if not m.ignored_in_filters
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
# 1) Check that there are overlapping primary keys that allow the application
|
|
111
|
+
# of filters.
|
|
112
|
+
if len(non_ignored_member_schemas) > 0 and len(result.filters) > 0:
|
|
113
|
+
if len(_common_primary_keys(non_ignored_member_schemas)) == 0:
|
|
114
|
+
raise ImplementationError(
|
|
115
|
+
"Members of a collection must have an overlapping primary key "
|
|
116
|
+
"but did not find any."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# 2) Check that filter names do not overlap with any column or rule names
|
|
120
|
+
if len(result.members) > 0:
|
|
121
|
+
taken = set.union(
|
|
122
|
+
*(
|
|
123
|
+
set(member.schema.column_names())
|
|
124
|
+
for member in result.members.values()
|
|
125
|
+
),
|
|
126
|
+
*(
|
|
127
|
+
set(member.schema._validation_rules())
|
|
128
|
+
for member in result.members.values()
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
intersection = taken & set(result.filters)
|
|
132
|
+
if len(intersection) > 0:
|
|
133
|
+
raise ImplementationError(
|
|
134
|
+
"Filters defined on the collection must not be named the same as any "
|
|
135
|
+
"column or rule in any of the member frames but found "
|
|
136
|
+
f"{len(intersection)} such filters: {sorted(intersection)}."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return super().__new__(mcs, name, bases, namespace, *args, **kwargs)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _get_metadata_recursively(kls: type[object]) -> Metadata:
|
|
143
|
+
result = Metadata()
|
|
144
|
+
for base in kls.__bases__:
|
|
145
|
+
result.update(CollectionMeta._get_metadata_recursively(base))
|
|
146
|
+
result.update(CollectionMeta._get_metadata(kls.__dict__)) # type: ignore
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _get_metadata(source: dict[str, Any]) -> Metadata:
|
|
151
|
+
result = Metadata()
|
|
152
|
+
|
|
153
|
+
# Get all members via the annotations
|
|
154
|
+
if "__annotations__" in source:
|
|
155
|
+
for attr, kls in source["__annotations__"].items():
|
|
156
|
+
origin = get_origin(kls)
|
|
157
|
+
|
|
158
|
+
# optional annotation
|
|
159
|
+
collection_member = CollectionMember()
|
|
160
|
+
|
|
161
|
+
if origin is Annotated:
|
|
162
|
+
annotation_args = get_args(kls)
|
|
163
|
+
origin_arg0 = get_origin(annotation_args[0])
|
|
164
|
+
if not origin_arg0 or not issubclass(origin_arg0, TypedLazyFrame):
|
|
165
|
+
raise AnnotationImplementationError(attr, kls)
|
|
166
|
+
if len(annotation_args) > 2:
|
|
167
|
+
raise AnnotationImplementationError(attr, kls)
|
|
168
|
+
if not isinstance(annotation_args[1], CollectionMember):
|
|
169
|
+
raise AnnotationImplementationError(attr, kls)
|
|
170
|
+
|
|
171
|
+
# Continue with wrapped FrameType
|
|
172
|
+
collection_member = annotation_args[1]
|
|
173
|
+
kls = annotation_args[0]
|
|
174
|
+
origin = origin_arg0
|
|
175
|
+
|
|
176
|
+
if origin is None:
|
|
177
|
+
# `None` annotation is not allowed
|
|
178
|
+
raise AnnotationImplementationError(attr, kls)
|
|
179
|
+
elif origin == typing.Union:
|
|
180
|
+
# Happy path: optional member
|
|
181
|
+
union_args = get_args(kls)
|
|
182
|
+
if len(union_args) != 2:
|
|
183
|
+
raise AnnotationImplementationError(attr, kls)
|
|
184
|
+
if not any(get_origin(arg) is None for arg in union_args):
|
|
185
|
+
raise AnnotationImplementationError(attr, kls)
|
|
186
|
+
|
|
187
|
+
[not_none_arg] = [
|
|
188
|
+
arg for arg in union_args if get_origin(arg) is not None
|
|
189
|
+
]
|
|
190
|
+
if not issubclass(get_origin(not_none_arg), TypedLazyFrame):
|
|
191
|
+
raise AnnotationImplementationError(attr, kls)
|
|
192
|
+
|
|
193
|
+
result.members[attr] = MemberInfo(
|
|
194
|
+
schema=get_args(not_none_arg)[0],
|
|
195
|
+
is_optional=True,
|
|
196
|
+
ignored_in_filters=collection_member.ignored_in_filters,
|
|
197
|
+
)
|
|
198
|
+
elif issubclass(origin, TypedLazyFrame):
|
|
199
|
+
# Happy path: required member
|
|
200
|
+
result.members[attr] = MemberInfo(
|
|
201
|
+
schema=get_args(kls)[0],
|
|
202
|
+
is_optional=False,
|
|
203
|
+
ignored_in_filters=collection_member.ignored_in_filters,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
# Some other unknown annotation
|
|
207
|
+
raise AnnotationImplementationError(attr, kls)
|
|
208
|
+
|
|
209
|
+
# Get all filters by traversing the source
|
|
210
|
+
for attr, value in {
|
|
211
|
+
k: v for k, v in source.items() if not k.startswith("__")
|
|
212
|
+
}.items():
|
|
213
|
+
if isinstance(value, Filter):
|
|
214
|
+
result.filters[attr] = value
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class BaseCollection(metaclass=CollectionMeta):
|
|
220
|
+
"""Internal utility abstraction to reference collections without introducing
|
|
221
|
+
cyclical dependencies."""
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def members(cls) -> dict[str, MemberInfo]:
|
|
225
|
+
"""Information about the members of the collection."""
|
|
226
|
+
return getattr(cls, _MEMBER_ATTR)
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def member_schemas(cls) -> dict[str, type[Schema]]:
|
|
230
|
+
"""The schemas of all members of the collection."""
|
|
231
|
+
return {name: member.schema for name, member in cls.members().items()}
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def required_members(cls) -> set[str]:
|
|
235
|
+
"""The names of all required members of the collection."""
|
|
236
|
+
return {
|
|
237
|
+
name for name, member in cls.members().items() if not member.is_optional
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def optional_members(cls) -> set[str]:
|
|
242
|
+
"""The names of all optional members of the collection."""
|
|
243
|
+
return {name for name, member in cls.members().items() if member.is_optional}
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def ignored_members(cls) -> set[str]:
|
|
247
|
+
"""The names of all members of the collection that are ignored in filters."""
|
|
248
|
+
return {
|
|
249
|
+
name for name, member in cls.members().items() if member.ignored_in_filters
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def non_ignored_members(cls) -> set[str]:
|
|
254
|
+
"""The names of all members of the collection that are not ignored in filters
|
|
255
|
+
(default)."""
|
|
256
|
+
return {
|
|
257
|
+
name
|
|
258
|
+
for name, member in cls.members().items()
|
|
259
|
+
if not member.ignored_in_filters
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def common_primary_keys(cls) -> list[str]:
|
|
264
|
+
"""The primary keys shared by non ignored members of the collection."""
|
|
265
|
+
return sorted(
|
|
266
|
+
_common_primary_keys(
|
|
267
|
+
[
|
|
268
|
+
member.schema
|
|
269
|
+
for member in cls.members().values()
|
|
270
|
+
if not member.ignored_in_filters
|
|
271
|
+
]
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def _filters(cls) -> dict[str, Filter[Self]]:
|
|
277
|
+
return getattr(cls, _FILTER_ATTR)
|
|
278
|
+
|
|
279
|
+
def to_dict(self) -> dict[str, pl.LazyFrame]:
|
|
280
|
+
"""Return a dictionary representation of this collection."""
|
|
281
|
+
return {
|
|
282
|
+
member: getattr(self, member)
|
|
283
|
+
for member in self.member_schemas()
|
|
284
|
+
if getattr(self, member) is not None
|
|
285
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABCMeta
|
|
7
|
+
from copy import copy
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import polars as pl
|
|
12
|
+
|
|
13
|
+
from ._rule import GroupRule, Rule, with_evaluation_rules
|
|
14
|
+
from .columns import Column
|
|
15
|
+
from .exc import ImplementationError, RuleImplementationError
|
|
16
|
+
|
|
17
|
+
_COLUMN_ATTR = "__dataframely_columns__"
|
|
18
|
+
_RULE_ATTR = "__dataframely_rules__"
|
|
19
|
+
|
|
20
|
+
# --------------------------------------- UTILS -------------------------------------- #
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_rules(
|
|
24
|
+
custom: dict[str, Rule], columns: dict[str, Column]
|
|
25
|
+
) -> dict[str, Rule]:
|
|
26
|
+
# NOTE: Copy here to prevent in-place modification of the custom rules
|
|
27
|
+
rules: dict[str, Rule] = copy(custom)
|
|
28
|
+
|
|
29
|
+
# Add primary key validation to the list of rules if applicable
|
|
30
|
+
primary_keys = _primary_keys(columns)
|
|
31
|
+
if len(primary_keys) > 0:
|
|
32
|
+
rules["primary_key"] = Rule(~pl.struct(primary_keys).is_duplicated())
|
|
33
|
+
|
|
34
|
+
# Add column-specific rules
|
|
35
|
+
column_rules = {
|
|
36
|
+
f"{col_name}|{rule_name}": Rule(expr)
|
|
37
|
+
for col_name, column in columns.items()
|
|
38
|
+
for rule_name, expr in column.validation_rules(pl.col(col_name)).items()
|
|
39
|
+
}
|
|
40
|
+
rules.update(column_rules)
|
|
41
|
+
|
|
42
|
+
return rules
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _primary_keys(columns: dict[str, Column]) -> list[str]:
|
|
46
|
+
return list(k for k, col in columns.items() if col.primary_key)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------------------------ #
|
|
50
|
+
# SCHEMA META #
|
|
51
|
+
# ------------------------------------------------------------------------------------ #
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Metadata:
|
|
56
|
+
"""Utility class to gather columns and rules associated with a schema."""
|
|
57
|
+
|
|
58
|
+
columns: dict[str, Column] = field(default_factory=dict)
|
|
59
|
+
rules: dict[str, Rule] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def update(self, other: Self):
|
|
62
|
+
self.columns.update(other.columns)
|
|
63
|
+
self.rules.update(other.rules)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SchemaMeta(ABCMeta):
|
|
67
|
+
def __new__(
|
|
68
|
+
mcs, # noqa: N804
|
|
69
|
+
name: str,
|
|
70
|
+
bases: tuple[type[object], ...],
|
|
71
|
+
namespace: dict[str, Any],
|
|
72
|
+
*args: Any,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
):
|
|
75
|
+
result = Metadata()
|
|
76
|
+
for base in bases:
|
|
77
|
+
result.update(mcs._get_metadata_recursively(base))
|
|
78
|
+
result.update(mcs._get_metadata(namespace))
|
|
79
|
+
namespace[_COLUMN_ATTR] = result.columns
|
|
80
|
+
namespace[_RULE_ATTR] = result.rules
|
|
81
|
+
|
|
82
|
+
# At this point, we already know all columns and custom rules. We want to run
|
|
83
|
+
# some checks...
|
|
84
|
+
|
|
85
|
+
# 1) Check that the column names clash with none of the rule names. To this end,
|
|
86
|
+
# we assume that users cast dtypes, i.e. additional rules for dtype casting
|
|
87
|
+
# are also checked.
|
|
88
|
+
all_column_names = set(result.columns)
|
|
89
|
+
all_rule_names = set(_build_rules(result.rules, result.columns).keys()) | set(
|
|
90
|
+
f"{col}|dtype" for col in result.columns
|
|
91
|
+
)
|
|
92
|
+
common_names = all_column_names & all_rule_names
|
|
93
|
+
if len(common_names) > 0:
|
|
94
|
+
common_list = ", ".join(sorted(f"'{col}'" for col in common_names))
|
|
95
|
+
raise ImplementationError(
|
|
96
|
+
"Rules and columns must not be named equally but found "
|
|
97
|
+
f"{len(common_names)} overlaps: {common_list}."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 2) Check that the columns referenced in the group rules exist.
|
|
101
|
+
for name, rule in result.rules.items():
|
|
102
|
+
if isinstance(rule, GroupRule):
|
|
103
|
+
missing_columns = set(rule.group_columns) - set(result.columns)
|
|
104
|
+
if len(missing_columns) > 0:
|
|
105
|
+
missing_list = ", ".join(
|
|
106
|
+
sorted(f"'{col}'" for col in missing_columns)
|
|
107
|
+
)
|
|
108
|
+
raise ImplementationError(
|
|
109
|
+
f"Group validation rule '{name}' has been implemented "
|
|
110
|
+
f"incorrectly. It references {len(missing_columns)} columns "
|
|
111
|
+
f"which are not in the schema: {missing_list}."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# 3) Assuming that non-custom rules are implemented correctly, we check that all
|
|
115
|
+
# custom rules are _also_ implemented correctly by evaluating rules on an
|
|
116
|
+
# empty data frame and checking for the evaluated dtypes.
|
|
117
|
+
if len(result.rules) > 0:
|
|
118
|
+
lf_empty = pl.LazyFrame(
|
|
119
|
+
schema={name: col.dtype for name, col in result.columns.items()}
|
|
120
|
+
)
|
|
121
|
+
# NOTE: For some reason, `polars` does not yield correct dtypes when calling
|
|
122
|
+
# `collect_schema()`
|
|
123
|
+
schema = with_evaluation_rules(lf_empty, result.rules).collect().schema
|
|
124
|
+
for name, rule in result.rules.items():
|
|
125
|
+
dtype = schema[name]
|
|
126
|
+
if not isinstance(dtype, pl.Boolean):
|
|
127
|
+
raise RuleImplementationError(
|
|
128
|
+
name, dtype, isinstance(rule, GroupRule)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return super().__new__(mcs, name, bases, namespace, *args, **kwargs)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _get_metadata_recursively(kls: type[object]) -> Metadata:
|
|
135
|
+
result = Metadata()
|
|
136
|
+
for base in kls.__bases__:
|
|
137
|
+
result.update(SchemaMeta._get_metadata_recursively(base))
|
|
138
|
+
result.update(SchemaMeta._get_metadata(kls.__dict__)) # type: ignore
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _get_metadata(source: dict[str, Any]) -> Metadata:
|
|
143
|
+
result = Metadata()
|
|
144
|
+
for attr, value in {
|
|
145
|
+
k: v for k, v in source.items() if not k.startswith("__")
|
|
146
|
+
}.items():
|
|
147
|
+
if isinstance(value, Column):
|
|
148
|
+
if not value.alias:
|
|
149
|
+
value.alias = attr
|
|
150
|
+
result.columns[value.alias] = value
|
|
151
|
+
if isinstance(value, Rule):
|
|
152
|
+
# We must ensure that custom rules do not clash with internal rules.
|
|
153
|
+
if attr == "primary_key":
|
|
154
|
+
raise ImplementationError(
|
|
155
|
+
"Custom validation rule must not be named `primary_key`."
|
|
156
|
+
)
|
|
157
|
+
result.rules[attr] = value
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class BaseSchema(metaclass=SchemaMeta):
|
|
162
|
+
"""Internal utility abstraction to reference schemas without introducing cyclical
|
|
163
|
+
dependencies."""
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def column_names(cls) -> list[str]:
|
|
167
|
+
"""The column names of this schema."""
|
|
168
|
+
return list(getattr(cls, _COLUMN_ATTR).keys())
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def columns(cls) -> dict[str, Column]:
|
|
172
|
+
"""The column definitions of this schema."""
|
|
173
|
+
return getattr(cls, _COLUMN_ATTR)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def primary_keys(cls) -> list[str]:
|
|
177
|
+
"""The primary key columns in this schema (possibly empty)."""
|
|
178
|
+
return _primary_keys(cls.columns())
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def _validation_rules(cls) -> dict[str, Rule]:
|
|
182
|
+
return _build_rules(cls._schema_validation_rules(), cls.columns())
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _schema_validation_rules(cls) -> dict[str, Rule]:
|
|
186
|
+
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):
|
|
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"]
|
dataframely/_extre.pyd
ADDED
|
Binary file
|
dataframely/_extre.pyi
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Literal, overload
|
|
2
|
+
|
|
3
|
+
def matching_string_length(regex: str) -> tuple[int, int | None]:
|
|
4
|
+
"""
|
|
5
|
+
Compute the minimum and maximum length (if available) of strings matching a regular expression.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
regex: The regular expression to analyze. The regular expression must not
|
|
9
|
+
contain any lookaround operators.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
A tuple of the minimum of maximum length of the matching strings. While the minimum
|
|
13
|
+
length is guaranteed to be available, the maximum length may be ``None`` if ``regex``
|
|
14
|
+
matches strings of potentially infinite length (e.g. due to the use of ``+`` or ``*``).
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ValueError: If the regex cannot be parsed or analyzed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@overload
|
|
21
|
+
def sample(
|
|
22
|
+
regex: str, n: int, max_repetitions: int = 16, seed: int | None = None
|
|
23
|
+
) -> list[str]:
|
|
24
|
+
"""
|
|
25
|
+
Sample a random (set of) string(s) matching the provided regular expression.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
regex: The regular expression generated strings must match. The regular
|
|
29
|
+
expression must not contain any lookaround operators.
|
|
30
|
+
n: The number of random strings to generate or ``None`` if a single one should
|
|
31
|
+
be generated.
|
|
32
|
+
max_repetitions: The maximum number of repetitions for ``+`` and ``*``
|
|
33
|
+
quantifiers.
|
|
34
|
+
seed: The seed to use for the random sampling procedure.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A single randomly generated string if ``n is None`` or a list of randomly
|
|
38
|
+
generated strings if ``n`` is an integer.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If the regex cannot be parsed.
|
|
42
|
+
|
|
43
|
+
Attention:
|
|
44
|
+
Using wildcards (i.e. ``.``) really means _any_ valid Unicode character.
|
|
45
|
+
Consider using more precise regular expressions if this is undesired.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@overload
|
|
49
|
+
def sample(
|
|
50
|
+
regex: str,
|
|
51
|
+
n: Literal[None] = None,
|
|
52
|
+
max_repetitions: int = 16,
|
|
53
|
+
seed: int | None = None,
|
|
54
|
+
) -> str: ...
|
dataframely/_filter.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
C = TypeVar("C")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Filter(Generic[C]):
|
|
13
|
+
"""Internal class representing logic for filtering members of a collection."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, logic: Callable[[C], pl.LazyFrame]):
|
|
16
|
+
self.logic = logic
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def filter() -> Callable[[Callable[[C], pl.LazyFrame]], Filter[C]]:
|
|
20
|
+
"""Mark a function as filters for rows in the members of a collection.
|
|
21
|
+
|
|
22
|
+
The name of the function will be used as the name of the filter. The name must not
|
|
23
|
+
clash with the name of any column in the member schemas or rules defined on the
|
|
24
|
+
member schemas.
|
|
25
|
+
|
|
26
|
+
A filter receives a collection as input and must return a data frame like the
|
|
27
|
+
following:
|
|
28
|
+
|
|
29
|
+
- The columns must be a superset of the common primary keys across all members.
|
|
30
|
+
- The rows must provide the primary keys which ought to be *kept* across the
|
|
31
|
+
members. The filter results in the removal of rows which are lost as the result
|
|
32
|
+
of inner-joining members onto the return value of this function.
|
|
33
|
+
|
|
34
|
+
Attention:
|
|
35
|
+
Make sure to provide unique combinations of the primary keys or the filters
|
|
36
|
+
might introduce duplicate rows.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def decorator(validation_fn: Callable[[C], pl.LazyFrame]) -> Filter[C]:
|
|
40
|
+
return Filter(logic=validation_fn)
|
|
41
|
+
|
|
42
|
+
return decorator
|