dataframely 2.2.0__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 +111 -0
- dataframely/_base_schema.py +247 -0
- dataframely/_compat.py +86 -0
- dataframely/_deprecation.py +27 -0
- dataframely/_filter.py +48 -0
- dataframely/_match_to_schema.py +89 -0
- dataframely/_native.pyd +0 -0
- dataframely/_native.pyi +67 -0
- dataframely/_plugin.py +84 -0
- dataframely/_polars.py +49 -0
- dataframely/_pydantic.py +113 -0
- dataframely/_rule.py +283 -0
- dataframely/_serialization.py +118 -0
- dataframely/_storage/__init__.py +8 -0
- dataframely/_storage/_base.py +204 -0
- dataframely/_storage/_exc.py +10 -0
- dataframely/_storage/constants.py +6 -0
- dataframely/_storage/delta.py +203 -0
- dataframely/_storage/parquet.py +257 -0
- dataframely/_typing.py +145 -0
- dataframely/collection/__init__.py +20 -0
- dataframely/collection/_base.py +369 -0
- dataframely/collection/collection.py +1395 -0
- dataframely/collection/filter_result.py +70 -0
- dataframely/columns/__init__.py +51 -0
- dataframely/columns/_base.py +434 -0
- dataframely/columns/_mixins.py +100 -0
- dataframely/columns/_registry.py +32 -0
- dataframely/columns/_utils.py +60 -0
- dataframely/columns/any.py +81 -0
- dataframely/columns/array.py +141 -0
- dataframely/columns/binary.py +38 -0
- dataframely/columns/bool.py +31 -0
- dataframely/columns/categorical.py +78 -0
- dataframely/columns/datetime.py +582 -0
- dataframely/columns/decimal.py +178 -0
- dataframely/columns/enum.py +103 -0
- dataframely/columns/float.py +221 -0
- dataframely/columns/integer.py +371 -0
- dataframely/columns/list.py +202 -0
- dataframely/columns/object.py +73 -0
- dataframely/columns/string.py +137 -0
- dataframely/columns/struct.py +155 -0
- dataframely/config.py +60 -0
- dataframely/exc.py +50 -0
- dataframely/filter_result.py +401 -0
- dataframely/functional.py +117 -0
- dataframely/py.typed +0 -0
- dataframely/random.py +445 -0
- dataframely/schema.py +1422 -0
- dataframely/testing/__init__.py +29 -0
- dataframely/testing/const.py +56 -0
- dataframely/testing/factory.py +89 -0
- dataframely/testing/mask.py +46 -0
- dataframely/testing/rules.py +33 -0
- dataframely/testing/storage.py +400 -0
- dataframely-2.2.0.dist-info/METADATA +113 -0
- dataframely-2.2.0.dist-info/RECORD +60 -0
- dataframely-2.2.0.dist-info/WHEEL +4 -0
- dataframely-2.2.0.dist-info/licenses/LICENSE +29 -0
dataframely/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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 ._filter import filter
|
|
15
|
+
from ._rule import rule
|
|
16
|
+
from ._typing import DataFrame, LazyFrame, Validation
|
|
17
|
+
from .collection import (
|
|
18
|
+
Collection,
|
|
19
|
+
CollectionMember,
|
|
20
|
+
deserialize_collection,
|
|
21
|
+
read_parquet_metadata_collection,
|
|
22
|
+
)
|
|
23
|
+
from .columns import (
|
|
24
|
+
Any,
|
|
25
|
+
Array,
|
|
26
|
+
Binary,
|
|
27
|
+
Bool,
|
|
28
|
+
Categorical,
|
|
29
|
+
Column,
|
|
30
|
+
Date,
|
|
31
|
+
Datetime,
|
|
32
|
+
Decimal,
|
|
33
|
+
Duration,
|
|
34
|
+
Enum,
|
|
35
|
+
Float,
|
|
36
|
+
Float32,
|
|
37
|
+
Float64,
|
|
38
|
+
Int8,
|
|
39
|
+
Int16,
|
|
40
|
+
Int32,
|
|
41
|
+
Int64,
|
|
42
|
+
Integer,
|
|
43
|
+
List,
|
|
44
|
+
Object,
|
|
45
|
+
String,
|
|
46
|
+
Struct,
|
|
47
|
+
Time,
|
|
48
|
+
UInt8,
|
|
49
|
+
UInt16,
|
|
50
|
+
UInt32,
|
|
51
|
+
UInt64,
|
|
52
|
+
)
|
|
53
|
+
from .config import Config
|
|
54
|
+
from .exc import DeserializationError
|
|
55
|
+
from .filter_result import FailureInfo
|
|
56
|
+
from .functional import (
|
|
57
|
+
concat_collection_members,
|
|
58
|
+
require_relationship_one_to_at_least_one,
|
|
59
|
+
require_relationship_one_to_one,
|
|
60
|
+
)
|
|
61
|
+
from .schema import Schema, deserialize_schema, read_parquet_metadata_schema
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"random",
|
|
65
|
+
"filter",
|
|
66
|
+
"rule",
|
|
67
|
+
"DataFrame",
|
|
68
|
+
"LazyFrame",
|
|
69
|
+
"Collection",
|
|
70
|
+
"CollectionMember",
|
|
71
|
+
"deserialize_collection",
|
|
72
|
+
"Config",
|
|
73
|
+
"FailureInfo",
|
|
74
|
+
"concat_collection_members",
|
|
75
|
+
"require_relationship_one_to_at_least_one",
|
|
76
|
+
"require_relationship_one_to_one",
|
|
77
|
+
"Schema",
|
|
78
|
+
"deserialize_schema",
|
|
79
|
+
"read_parquet_metadata_schema",
|
|
80
|
+
"read_parquet_metadata_collection",
|
|
81
|
+
"Any",
|
|
82
|
+
"Binary",
|
|
83
|
+
"Bool",
|
|
84
|
+
"Categorical",
|
|
85
|
+
"Column",
|
|
86
|
+
"Date",
|
|
87
|
+
"Datetime",
|
|
88
|
+
"Decimal",
|
|
89
|
+
"Duration",
|
|
90
|
+
"Time",
|
|
91
|
+
"Enum",
|
|
92
|
+
"Float",
|
|
93
|
+
"Float32",
|
|
94
|
+
"Float64",
|
|
95
|
+
"Int8",
|
|
96
|
+
"Int16",
|
|
97
|
+
"Int32",
|
|
98
|
+
"Int64",
|
|
99
|
+
"Integer",
|
|
100
|
+
"UInt8",
|
|
101
|
+
"UInt16",
|
|
102
|
+
"UInt32",
|
|
103
|
+
"UInt64",
|
|
104
|
+
"String",
|
|
105
|
+
"Struct",
|
|
106
|
+
"List",
|
|
107
|
+
"Array",
|
|
108
|
+
"Object",
|
|
109
|
+
"Validation",
|
|
110
|
+
"DeserializationError",
|
|
111
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
from abc import ABCMeta
|
|
9
|
+
from copy import copy
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import polars as pl
|
|
14
|
+
|
|
15
|
+
from ._rule import DtypeCastRule, GroupRule, Rule, RuleFactory
|
|
16
|
+
from .columns import Column
|
|
17
|
+
from .exc import ImplementationError
|
|
18
|
+
|
|
19
|
+
if sys.version_info >= (3, 11):
|
|
20
|
+
from typing import Self
|
|
21
|
+
else:
|
|
22
|
+
from typing_extensions import Self
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_COLUMN_ATTR = "__dataframely_columns__"
|
|
26
|
+
_RULE_ATTR = "__dataframely_rules__"
|
|
27
|
+
|
|
28
|
+
ORIGINAL_COLUMN_PREFIX = "__DATAFRAMELY_ORIGINAL__"
|
|
29
|
+
|
|
30
|
+
# --------------------------------------- UTILS -------------------------------------- #
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_rules(
|
|
34
|
+
custom: dict[str, Rule], columns: dict[str, Column], *, with_cast: bool
|
|
35
|
+
) -> dict[str, Rule]:
|
|
36
|
+
# NOTE: Copy here to prevent in-place modification of the custom rules
|
|
37
|
+
rules: dict[str, Rule] = copy(custom)
|
|
38
|
+
|
|
39
|
+
# Add primary key validation to the list of rules if applicable
|
|
40
|
+
primary_key = _primary_key(columns)
|
|
41
|
+
if len(primary_key) > 0:
|
|
42
|
+
rules["primary_key"] = Rule(~pl.struct(primary_key).is_duplicated())
|
|
43
|
+
|
|
44
|
+
# Add column-specific rules
|
|
45
|
+
column_rules = {
|
|
46
|
+
f"{col_name}|{rule_name}": Rule(expr)
|
|
47
|
+
for col_name, column in columns.items()
|
|
48
|
+
for rule_name, expr in column.validation_rules(pl.col(col_name)).items()
|
|
49
|
+
}
|
|
50
|
+
rules.update(column_rules)
|
|
51
|
+
|
|
52
|
+
# Add casting rules if requested. Here, we can simply check whether the nullability
|
|
53
|
+
# property of a column changes due to lenient dtype casting. Whenever casting fails,
|
|
54
|
+
# the value is set to `null`, mismatching the previous nullability.
|
|
55
|
+
# NOTE: This check assumes that both the original and cast column are present in the
|
|
56
|
+
# data frame.
|
|
57
|
+
if with_cast:
|
|
58
|
+
casting_rules = {
|
|
59
|
+
f"{col_name}|dtype": DtypeCastRule(
|
|
60
|
+
pl.col(col_name).is_null()
|
|
61
|
+
== pl.col(f"{ORIGINAL_COLUMN_PREFIX}{col_name}").is_null()
|
|
62
|
+
)
|
|
63
|
+
for col_name in columns
|
|
64
|
+
}
|
|
65
|
+
rules.update(casting_rules)
|
|
66
|
+
|
|
67
|
+
return rules
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _primary_key(columns: dict[str, Column]) -> list[str]:
|
|
71
|
+
return list(k for k, col in columns.items() if col.primary_key)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------------------------ #
|
|
75
|
+
# SCHEMA META #
|
|
76
|
+
# ------------------------------------------------------------------------------------ #
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Metadata:
|
|
81
|
+
"""Utility class to gather columns and rules associated with a schema."""
|
|
82
|
+
|
|
83
|
+
columns: dict[str, Column] = field(default_factory=dict)
|
|
84
|
+
rules: dict[str, RuleFactory] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
def update(self, other: Self) -> None:
|
|
87
|
+
self.columns.update(other.columns)
|
|
88
|
+
self.rules.update(other.rules)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SchemaMeta(ABCMeta):
|
|
92
|
+
def __new__(
|
|
93
|
+
mcs, # noqa: N804
|
|
94
|
+
name: str,
|
|
95
|
+
bases: tuple[type[object], ...],
|
|
96
|
+
namespace: dict[str, Any],
|
|
97
|
+
*args: Any,
|
|
98
|
+
**kwargs: Any,
|
|
99
|
+
) -> SchemaMeta:
|
|
100
|
+
result = Metadata()
|
|
101
|
+
for base in bases:
|
|
102
|
+
result.update(mcs._get_metadata_recursively(base))
|
|
103
|
+
result.update(mcs._get_metadata(namespace))
|
|
104
|
+
namespace[_COLUMN_ATTR] = result.columns
|
|
105
|
+
cls = super().__new__(mcs, name, bases, namespace, *args, **kwargs)
|
|
106
|
+
|
|
107
|
+
# Assign rules retroactively as we only encounter rule factories in the result
|
|
108
|
+
rules = {name: factory.make(cls) for name, factory in result.rules.items()}
|
|
109
|
+
setattr(cls, _RULE_ATTR, rules)
|
|
110
|
+
|
|
111
|
+
# At this point, we already know all columns and custom rules. We want to run
|
|
112
|
+
# some checks...
|
|
113
|
+
|
|
114
|
+
# 1) Check that the column names clash with none of the rule names. To this end,
|
|
115
|
+
# we assume that users cast dtypes, i.e. additional rules for dtype casting
|
|
116
|
+
# are also checked.
|
|
117
|
+
all_column_names = set(result.columns)
|
|
118
|
+
all_rule_names = set(_build_rules(rules, result.columns, with_cast=True))
|
|
119
|
+
common_names = all_column_names & all_rule_names
|
|
120
|
+
if len(common_names) > 0:
|
|
121
|
+
common_list = ", ".join(sorted(f"'{col}'" for col in common_names))
|
|
122
|
+
raise ImplementationError(
|
|
123
|
+
"Rules and columns must not be named equally but found "
|
|
124
|
+
f"{len(common_names)} overlaps: {common_list}."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# 2) Check that the columns referenced in the group rules exist.
|
|
128
|
+
for rule_name, rule in rules.items():
|
|
129
|
+
if isinstance(rule, GroupRule):
|
|
130
|
+
missing_columns = set(rule.group_columns) - set(result.columns)
|
|
131
|
+
if len(missing_columns) > 0:
|
|
132
|
+
missing_list = ", ".join(
|
|
133
|
+
sorted(f"'{col}'" for col in missing_columns)
|
|
134
|
+
)
|
|
135
|
+
raise ImplementationError(
|
|
136
|
+
f"Group validation rule '{rule_name}' has been implemented "
|
|
137
|
+
f"incorrectly. It references {len(missing_columns)} columns "
|
|
138
|
+
f"which are not in the schema: {missing_list}."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 3) Check that all members are non-pathological (i.e., user errors).
|
|
142
|
+
for attr, value in namespace.items():
|
|
143
|
+
if attr.startswith("__"):
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Check for tuple of column (commonly caused by trailing comma)
|
|
147
|
+
if (
|
|
148
|
+
isinstance(value, tuple)
|
|
149
|
+
and len(value) > 0
|
|
150
|
+
and isinstance(value[0], Column)
|
|
151
|
+
):
|
|
152
|
+
raise TypeError(
|
|
153
|
+
f"Column '{attr}' is defined as a tuple of dy.Column. "
|
|
154
|
+
f"Did you accidentally add a trailing comma?"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Check for column type instead of instance (e.g., dy.Float64 instead of dy.Float64())
|
|
158
|
+
if isinstance(value, type) and issubclass(value, Column):
|
|
159
|
+
raise TypeError(
|
|
160
|
+
f"Column '{attr}' is a type, not an instance. "
|
|
161
|
+
f"Schema members must be of type Column not type[Column]. "
|
|
162
|
+
f"Did you forget to add parentheses?"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return cls
|
|
166
|
+
|
|
167
|
+
if not TYPE_CHECKING:
|
|
168
|
+
# Only define __getattribute__ at runtime to allow type checkers to properly
|
|
169
|
+
# validate attribute access. When TYPE_CHECKING is True, type checkers will use
|
|
170
|
+
# the default metaclass behavior which correctly identifies non-existent attributes.
|
|
171
|
+
def __getattribute__(cls, name: str) -> Any:
|
|
172
|
+
val = super().__getattribute__(name)
|
|
173
|
+
# Dynamically set the name of the column if it is a `Column` instance.
|
|
174
|
+
if isinstance(val, Column):
|
|
175
|
+
val._name = val.alias or name
|
|
176
|
+
return val
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _get_metadata_recursively(kls: type[object]) -> Metadata:
|
|
180
|
+
result = Metadata()
|
|
181
|
+
for base in kls.__bases__:
|
|
182
|
+
result.update(SchemaMeta._get_metadata_recursively(base))
|
|
183
|
+
result.update(SchemaMeta._get_metadata(kls.__dict__)) # type: ignore
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _get_metadata(source: dict[str, Any]) -> Metadata:
|
|
188
|
+
result = Metadata()
|
|
189
|
+
for attr, value in {
|
|
190
|
+
k: v for k, v in source.items() if not k.startswith("__")
|
|
191
|
+
}.items():
|
|
192
|
+
if isinstance(value, Column):
|
|
193
|
+
result.columns[value.alias or attr] = value
|
|
194
|
+
if isinstance(value, RuleFactory):
|
|
195
|
+
# We must ensure that custom rules do not clash with internal rules.
|
|
196
|
+
if attr == "primary_key":
|
|
197
|
+
raise ImplementationError(
|
|
198
|
+
"Custom validation rule must not be named `primary_key`."
|
|
199
|
+
)
|
|
200
|
+
result.rules[attr] = value
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def __repr__(cls) -> str:
|
|
204
|
+
parts = [f'[Schema "{cls.__name__}"]']
|
|
205
|
+
parts.append(textwrap.indent("Columns:", prefix=" " * 2))
|
|
206
|
+
for name, col in cls.columns().items(): # type: ignore[attr-defined]
|
|
207
|
+
parts.append(textwrap.indent(f'- "{name}": {col!r}', prefix=" " * 4))
|
|
208
|
+
if validation_rules := cls._schema_validation_rules(): # type: ignore[attr-defined]
|
|
209
|
+
parts.append(textwrap.indent("Rules:", prefix=" " * 2))
|
|
210
|
+
for name, rule in validation_rules.items():
|
|
211
|
+
parts.append(textwrap.indent(f'- "{name}": {rule!r}', prefix=" " * 4))
|
|
212
|
+
parts.append("") # Add line break at the end
|
|
213
|
+
return "\n".join(parts)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class BaseSchema(metaclass=SchemaMeta):
|
|
217
|
+
"""Internal utility abstraction to reference schemas without introducing cyclical
|
|
218
|
+
dependencies."""
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def column_names(cls) -> list[str]:
|
|
222
|
+
"""The column names of this schema."""
|
|
223
|
+
return list(getattr(cls, _COLUMN_ATTR).keys())
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def columns(cls) -> dict[str, Column]:
|
|
227
|
+
"""The column definitions of this schema."""
|
|
228
|
+
columns: dict[str, Column] = getattr(cls, _COLUMN_ATTR)
|
|
229
|
+
for name in columns.keys():
|
|
230
|
+
# Dynamically set the name of the columns.
|
|
231
|
+
columns[name]._name = name
|
|
232
|
+
return columns
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def primary_key(cls) -> list[str]:
|
|
236
|
+
"""The primary key columns in this schema (possibly empty)."""
|
|
237
|
+
return _primary_key(cls.columns())
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def _validation_rules(cls, *, with_cast: bool) -> dict[str, Rule]:
|
|
241
|
+
return _build_rules(
|
|
242
|
+
cls._schema_validation_rules(), cls.columns(), with_cast=with_cast
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def _schema_validation_rules(cls) -> dict[str, Rule]:
|
|
247
|
+
return getattr(cls, _RULE_ATTR)
|
dataframely/_compat.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
# ------------------------------------ DELTALAKE ------------------------------------- #
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import deltalake
|
|
20
|
+
from deltalake import DeltaTable
|
|
21
|
+
except ImportError:
|
|
22
|
+
deltalake = _DummyModule("deltalake") # type: ignore
|
|
23
|
+
|
|
24
|
+
class DeltaTable: # type: ignore # noqa: N801
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
# ------------------------------------ SQLALCHEMY ------------------------------------ #
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import sqlalchemy as sa
|
|
31
|
+
import sqlalchemy.dialects.mssql as sa_mssql
|
|
32
|
+
from sqlalchemy import Dialect
|
|
33
|
+
from sqlalchemy.dialects.mssql.pyodbc import MSDialect_pyodbc
|
|
34
|
+
from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2
|
|
35
|
+
from sqlalchemy.sql.type_api import TypeEngine as sa_TypeEngine
|
|
36
|
+
except ImportError:
|
|
37
|
+
sa = _DummyModule("sqlalchemy") # type: ignore
|
|
38
|
+
sa_mssql = _DummyModule("sqlalchemy") # type: ignore
|
|
39
|
+
|
|
40
|
+
class sa_TypeEngine: # type: ignore # noqa: N801
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
class MSDialect_pyodbc: # type: ignore # noqa: N801
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
class PGDialect_psycopg2: # type: ignore # noqa: N801
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
class Dialect: # type: ignore # noqa: N801
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# -------------------------------------- PYARROW ------------------------------------- #
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import pyarrow as pa
|
|
56
|
+
except ImportError:
|
|
57
|
+
pa = _DummyModule("pyarrow")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# -------------------------------------- PYDANTIC ------------------------------------ #
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
import pydantic
|
|
64
|
+
except ImportError:
|
|
65
|
+
pydantic = _DummyModule("pydantic") # type: ignore
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from pydantic_core import core_schema as pydantic_core_schema
|
|
69
|
+
except ImportError:
|
|
70
|
+
pydantic_core_schema = _DummyModule("pydantic_core_schema") # type: ignore
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------------------------ #
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"deltalake",
|
|
76
|
+
"DeltaTable",
|
|
77
|
+
"Dialect",
|
|
78
|
+
"MSDialect_pyodbc",
|
|
79
|
+
"pa",
|
|
80
|
+
"PGDialect_psycopg2",
|
|
81
|
+
"pydantic_core_schema",
|
|
82
|
+
"pydantic",
|
|
83
|
+
"sa_mssql",
|
|
84
|
+
"sa_TypeEngine",
|
|
85
|
+
"sa",
|
|
86
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
TRUTHY_VALUES = ["1", "true"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def skip_if(env: str) -> Callable:
|
|
12
|
+
"""Decorator to skip warnings based on environment variable.
|
|
13
|
+
|
|
14
|
+
If the environment variable is equivalent to any of TRUTHY_VALUES, the wrapped
|
|
15
|
+
function is skipped.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def decorator(fun: Callable) -> Callable:
|
|
19
|
+
@wraps(fun)
|
|
20
|
+
def wrapper() -> None:
|
|
21
|
+
if os.getenv(env, "").lower() in TRUTHY_VALUES:
|
|
22
|
+
return
|
|
23
|
+
fun()
|
|
24
|
+
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
return decorator
|
dataframely/_filter.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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]) -> None:
|
|
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
|
+
Attention:
|
|
39
|
+
The filter logic should return a lazy frame with a static computational graph.
|
|
40
|
+
Other implementations using arbitrary python logic works for filtering and
|
|
41
|
+
validation, but may lead to wrong results in Collection comparisons
|
|
42
|
+
and (de-)serialization.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def decorator(validation_fn: Callable[[C], pl.LazyFrame]) -> Filter[C]:
|
|
46
|
+
return Filter(logic=validation_fn)
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Copyright (c) QuantCo 2025-2025
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
from ._base_schema import ORIGINAL_COLUMN_PREFIX, BaseSchema
|
|
9
|
+
from .columns import Column
|
|
10
|
+
from .exc import SchemaError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def match_to_schema(
|
|
14
|
+
lf: pl.LazyFrame,
|
|
15
|
+
target: type[BaseSchema],
|
|
16
|
+
*,
|
|
17
|
+
casting: Literal["none", "lenient", "strict"],
|
|
18
|
+
) -> pl.LazyFrame:
|
|
19
|
+
"""Ensure that a lazy frame contains the columns of the schema with the dtypes
|
|
20
|
+
specified by the schema."""
|
|
21
|
+
|
|
22
|
+
def cast_none(lf: pl.LazyFrame, schema: pl.Schema) -> pl.LazyFrame:
|
|
23
|
+
_validate_columns_exist(schema, target)
|
|
24
|
+
_validate_dtypes(schema, target)
|
|
25
|
+
return lf.select(target.column_names())
|
|
26
|
+
|
|
27
|
+
def cast_lenient(lf: pl.LazyFrame, schema: pl.Schema) -> pl.LazyFrame:
|
|
28
|
+
_validate_columns_exist(schema, target)
|
|
29
|
+
# NOTE: We keep around the original columns for failure objects and
|
|
30
|
+
# to evaluate whether casting is successful.
|
|
31
|
+
return lf.select(
|
|
32
|
+
pl.col(target.column_names()).name.prefix(ORIGINAL_COLUMN_PREFIX),
|
|
33
|
+
*[
|
|
34
|
+
pl.col(name).pipe(_cast_if_required, schema[name], column, strict=False)
|
|
35
|
+
for name, column in target.columns().items()
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def cast_strict(lf: pl.LazyFrame, schema: pl.Schema) -> pl.LazyFrame:
|
|
40
|
+
_validate_columns_exist(schema, target)
|
|
41
|
+
return lf.select(
|
|
42
|
+
pl.col(name).pipe(_cast_if_required, schema[name], column)
|
|
43
|
+
for name, column in target.columns().items()
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
match casting:
|
|
47
|
+
case "none":
|
|
48
|
+
return lf.pipe_with_schema(cast_none)
|
|
49
|
+
case "lenient":
|
|
50
|
+
return lf.pipe_with_schema(cast_lenient)
|
|
51
|
+
case "strict":
|
|
52
|
+
return lf.pipe_with_schema(cast_strict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------------------------ #
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_columns_exist(actual: pl.Schema, target: type[BaseSchema]) -> None:
|
|
59
|
+
actual_columns = set(actual.keys())
|
|
60
|
+
target_columns = set(target.column_names())
|
|
61
|
+
if missing := target_columns - actual_columns:
|
|
62
|
+
raise SchemaError(
|
|
63
|
+
f"{len(missing)} missing columns for schema '{target.__name__}': "
|
|
64
|
+
+ ", ".join(f"'{c}'" for c in sorted(missing))
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _validate_dtypes(actual: pl.Schema, target: type[BaseSchema]) -> None:
|
|
69
|
+
failures = {
|
|
70
|
+
name: column
|
|
71
|
+
for name, column in target.columns().items()
|
|
72
|
+
if not column.validate_dtype(actual[name])
|
|
73
|
+
}
|
|
74
|
+
if failures:
|
|
75
|
+
raise SchemaError(
|
|
76
|
+
f"{len(failures)} columns with invalid dtype for schema '{target.__name__}': "
|
|
77
|
+
+ "\n".join(
|
|
78
|
+
f" - '{name}', got: {actual[name]}, expected: {column}"
|
|
79
|
+
for name, column in failures.items()
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cast_if_required(
|
|
85
|
+
expr: pl.Expr, current_dtype: pl.DataType, column: Column, *, strict: bool = True
|
|
86
|
+
) -> pl.Expr:
|
|
87
|
+
if column.validate_dtype(current_dtype):
|
|
88
|
+
return expr
|
|
89
|
+
return expr.cast(column.dtype, strict=strict)
|
dataframely/_native.pyd
ADDED
|
Binary file
|
dataframely/_native.pyi
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import overload
|
|
2
|
+
|
|
3
|
+
def format_rule_failures(failures: list[tuple[str, int]]) -> str:
|
|
4
|
+
"""
|
|
5
|
+
Format rule failures with the same logic that produces validation errors from the
|
|
6
|
+
polars plugin.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
failures: The name of the failures and their counts. This should only include
|
|
10
|
+
failures with a count of at least 1.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The formatted rule failures.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def regex_matching_string_length(regex: str) -> tuple[int, int | None]:
|
|
17
|
+
"""
|
|
18
|
+
Compute the minimum and maximum length (if available) of strings matching a regular expression.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
regex: The regular expression to analyze. The regular expression must not
|
|
22
|
+
contain any lookaround operators.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A tuple of the minimum of maximum length of the matching strings. While the minimum
|
|
26
|
+
length is guaranteed to be available, the maximum length may be `None` if `regex`
|
|
27
|
+
matches strings of potentially infinite length (e.g. due to the use of `+` or `*`).
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If the regex cannot be parsed or analyzed.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@overload
|
|
34
|
+
def regex_sample(
|
|
35
|
+
regex: str, n: int, max_repetitions: int = 16, seed: int | None = None
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""
|
|
38
|
+
Sample a random (set of) string(s) matching the provided regular expression.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
regex: The regular expression generated strings must match. The regular
|
|
42
|
+
expression must not contain any lookaround operators.
|
|
43
|
+
n: The number of random strings to generate or `None` if a single one should
|
|
44
|
+
be generated.
|
|
45
|
+
max_repetitions: The maximum number of repetitions for `+` and `*`
|
|
46
|
+
quantifiers.
|
|
47
|
+
seed: The seed to use for the random sampling procedure.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A single randomly generated string if `n is None` or a list of randomly
|
|
51
|
+
generated strings if `n` is an integer.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If the regex cannot be parsed.
|
|
55
|
+
|
|
56
|
+
Attention:
|
|
57
|
+
Using wildcards (i.e. `.`) really means _any_ valid Unicode character.
|
|
58
|
+
Consider using more precise regular expressions if this is undesired.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
@overload
|
|
62
|
+
def regex_sample(
|
|
63
|
+
regex: str,
|
|
64
|
+
n: None = None,
|
|
65
|
+
max_repetitions: int = 16,
|
|
66
|
+
seed: int | None = None,
|
|
67
|
+
) -> str: ...
|