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.
Files changed (60) hide show
  1. dataframely/__init__.py +111 -0
  2. dataframely/_base_schema.py +247 -0
  3. dataframely/_compat.py +86 -0
  4. dataframely/_deprecation.py +27 -0
  5. dataframely/_filter.py +48 -0
  6. dataframely/_match_to_schema.py +89 -0
  7. dataframely/_native.pyd +0 -0
  8. dataframely/_native.pyi +67 -0
  9. dataframely/_plugin.py +84 -0
  10. dataframely/_polars.py +49 -0
  11. dataframely/_pydantic.py +113 -0
  12. dataframely/_rule.py +283 -0
  13. dataframely/_serialization.py +118 -0
  14. dataframely/_storage/__init__.py +8 -0
  15. dataframely/_storage/_base.py +204 -0
  16. dataframely/_storage/_exc.py +10 -0
  17. dataframely/_storage/constants.py +6 -0
  18. dataframely/_storage/delta.py +203 -0
  19. dataframely/_storage/parquet.py +257 -0
  20. dataframely/_typing.py +145 -0
  21. dataframely/collection/__init__.py +20 -0
  22. dataframely/collection/_base.py +369 -0
  23. dataframely/collection/collection.py +1395 -0
  24. dataframely/collection/filter_result.py +70 -0
  25. dataframely/columns/__init__.py +51 -0
  26. dataframely/columns/_base.py +434 -0
  27. dataframely/columns/_mixins.py +100 -0
  28. dataframely/columns/_registry.py +32 -0
  29. dataframely/columns/_utils.py +60 -0
  30. dataframely/columns/any.py +81 -0
  31. dataframely/columns/array.py +141 -0
  32. dataframely/columns/binary.py +38 -0
  33. dataframely/columns/bool.py +31 -0
  34. dataframely/columns/categorical.py +78 -0
  35. dataframely/columns/datetime.py +582 -0
  36. dataframely/columns/decimal.py +178 -0
  37. dataframely/columns/enum.py +103 -0
  38. dataframely/columns/float.py +221 -0
  39. dataframely/columns/integer.py +371 -0
  40. dataframely/columns/list.py +202 -0
  41. dataframely/columns/object.py +73 -0
  42. dataframely/columns/string.py +137 -0
  43. dataframely/columns/struct.py +155 -0
  44. dataframely/config.py +60 -0
  45. dataframely/exc.py +50 -0
  46. dataframely/filter_result.py +401 -0
  47. dataframely/functional.py +117 -0
  48. dataframely/py.typed +0 -0
  49. dataframely/random.py +445 -0
  50. dataframely/schema.py +1422 -0
  51. dataframely/testing/__init__.py +29 -0
  52. dataframely/testing/const.py +56 -0
  53. dataframely/testing/factory.py +89 -0
  54. dataframely/testing/mask.py +46 -0
  55. dataframely/testing/rules.py +33 -0
  56. dataframely/testing/storage.py +400 -0
  57. dataframely-2.2.0.dist-info/METADATA +113 -0
  58. dataframely-2.2.0.dist-info/RECORD +60 -0
  59. dataframely-2.2.0.dist-info/WHEEL +4 -0
  60. dataframely-2.2.0.dist-info/licenses/LICENSE +29 -0
@@ -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)
Binary file
@@ -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: ...