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.
Files changed (49) hide show
  1. dataframely/__init__.py +100 -0
  2. dataframely/_base_collection.py +343 -0
  3. dataframely/_base_schema.py +192 -0
  4. dataframely/_compat.py +39 -0
  5. dataframely/_deprecation.py +50 -0
  6. dataframely/_extre.pyd +0 -0
  7. dataframely/_extre.pyi +54 -0
  8. dataframely/_filter.py +48 -0
  9. dataframely/_polars.py +63 -0
  10. dataframely/_rule.py +220 -0
  11. dataframely/_serialization.py +113 -0
  12. dataframely/_typing.py +115 -0
  13. dataframely/_validation.py +85 -0
  14. dataframely/collection.py +972 -0
  15. dataframely/columns/__init__.py +47 -0
  16. dataframely/columns/_base.py +436 -0
  17. dataframely/columns/_mixins.py +95 -0
  18. dataframely/columns/_registry.py +32 -0
  19. dataframely/columns/_utils.py +60 -0
  20. dataframely/columns/any.py +81 -0
  21. dataframely/columns/array.py +134 -0
  22. dataframely/columns/bool.py +31 -0
  23. dataframely/columns/datetime.py +582 -0
  24. dataframely/columns/decimal.py +177 -0
  25. dataframely/columns/enum.py +90 -0
  26. dataframely/columns/float.py +216 -0
  27. dataframely/columns/integer.py +371 -0
  28. dataframely/columns/list.py +184 -0
  29. dataframely/columns/object.py +73 -0
  30. dataframely/columns/string.py +137 -0
  31. dataframely/columns/struct.py +150 -0
  32. dataframely/config.py +56 -0
  33. dataframely/exc.py +112 -0
  34. dataframely/failure.py +229 -0
  35. dataframely/functional.py +89 -0
  36. dataframely/mypy.py +424 -0
  37. dataframely/py.typed +0 -0
  38. dataframely/random.py +419 -0
  39. dataframely/schema.py +1018 -0
  40. dataframely/testing/__init__.py +29 -0
  41. dataframely/testing/const.py +54 -0
  42. dataframely/testing/factory.py +63 -0
  43. dataframely/testing/mask.py +46 -0
  44. dataframely/testing/rules.py +33 -0
  45. dataframely/testing/typing.py +30 -0
  46. dataframely-1.7.3.dist-info/METADATA +101 -0
  47. dataframely-1.7.3.dist-info/RECORD +49 -0
  48. dataframely-1.7.3.dist-info/WHEEL +4 -0
  49. dataframely-1.7.3.dist-info/licenses/LICENSE +29 -0
@@ -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"]