dataframely 1.5.0__tar.gz → 1.6.0__tar.gz

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 (177) hide show
  1. {dataframely-1.5.0 → dataframely-1.6.0}/PKG-INFO +2 -2
  2. {dataframely-1.5.0 → dataframely-1.6.0}/README.md +1 -1
  3. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/__init__.py +2 -1
  4. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_base_collection.py +27 -0
  5. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_base_schema.py +23 -22
  6. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_deprecation.py +11 -0
  7. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_filter.py +6 -0
  8. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_rule.py +92 -5
  9. dataframely-1.6.0/dataframely/_serialization.py +88 -0
  10. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/collection.py +60 -0
  11. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/__init__.py +2 -0
  12. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/_base.py +185 -13
  13. dataframely-1.6.0/dataframely/columns/_registry.py +32 -0
  14. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/any.py +4 -9
  15. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/array.py +23 -9
  16. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/bool.py +2 -2
  17. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/datetime.py +22 -26
  18. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/decimal.py +4 -8
  19. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/enum.py +5 -8
  20. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/float.py +6 -8
  21. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/integer.py +13 -8
  22. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/list.py +22 -9
  23. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/object.py +4 -8
  24. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/string.py +4 -10
  25. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/struct.py +32 -9
  26. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/exc.py +4 -25
  27. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/schema.py +119 -1
  28. {dataframely-1.5.0 → dataframely-1.6.0}/pyproject.toml +1 -1
  29. dataframely-1.6.0/tests/collection/test_collection_future_annotations.py +24 -0
  30. dataframely-1.6.0/tests/collection/test_matches.py +119 -0
  31. dataframely-1.6.0/tests/collection/test_repr.py +38 -0
  32. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_alias.py +14 -0
  33. dataframely-1.6.0/tests/columns/test_matches.py +64 -0
  34. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_base.py +5 -8
  35. dataframely-1.6.0/tests/schema/test_matches.py +140 -0
  36. dataframely-1.6.0/tests/schema/test_repr.py +54 -0
  37. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_rule_implementation.py +1 -27
  38. dataframely-1.6.0/tests/schema/test_serialization.py +130 -0
  39. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_validate.py +25 -3
  40. dataframely-1.6.0/tests/test_deprecation.py +63 -0
  41. dataframely-1.5.0/tests/test_deprecation.py +0 -28
  42. {dataframely-1.5.0 → dataframely-1.6.0}/.copier-answers.yml +0 -0
  43. {dataframely-1.5.0 → dataframely-1.6.0}/.envrc +0 -0
  44. {dataframely-1.5.0 → dataframely-1.6.0}/.gitattributes +0 -0
  45. {dataframely-1.5.0 → dataframely-1.6.0}/.github/CODEOWNERS +0 -0
  46. {dataframely-1.5.0 → dataframely-1.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  47. {dataframely-1.5.0 → dataframely-1.6.0}/.github/dependabot.yml +0 -0
  48. {dataframely-1.5.0 → dataframely-1.6.0}/.github/release-drafter.yml +0 -0
  49. {dataframely-1.5.0 → dataframely-1.6.0}/.github/workflows/build.yml +0 -0
  50. {dataframely-1.5.0 → dataframely-1.6.0}/.github/workflows/chore.yml +0 -0
  51. {dataframely-1.5.0 → dataframely-1.6.0}/.github/workflows/ci.yml +0 -0
  52. {dataframely-1.5.0 → dataframely-1.6.0}/.github/workflows/scorecard.yml +0 -0
  53. {dataframely-1.5.0 → dataframely-1.6.0}/.gitignore +0 -0
  54. {dataframely-1.5.0 → dataframely-1.6.0}/.pre-commit-config.yaml +0 -0
  55. {dataframely-1.5.0 → dataframely-1.6.0}/.prettierignore +0 -0
  56. {dataframely-1.5.0 → dataframely-1.6.0}/.prettierrc +0 -0
  57. {dataframely-1.5.0 → dataframely-1.6.0}/.readthedocs.yml +0 -0
  58. {dataframely-1.5.0 → dataframely-1.6.0}/Cargo.lock +0 -0
  59. {dataframely-1.5.0 → dataframely-1.6.0}/Cargo.toml +0 -0
  60. {dataframely-1.5.0 → dataframely-1.6.0}/LICENSE +0 -0
  61. {dataframely-1.5.0 → dataframely-1.6.0}/SECURITY.md +0 -0
  62. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_compat.py +0 -0
  63. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_extre.pyi +0 -0
  64. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_polars.py +0 -0
  65. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_typing.py +0 -0
  66. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/_validation.py +0 -0
  67. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/_mixins.py +0 -0
  68. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/columns/_utils.py +0 -0
  69. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/config.py +0 -0
  70. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/failure.py +0 -0
  71. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/functional.py +0 -0
  72. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/mypy.py +0 -0
  73. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/py.typed +0 -0
  74. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/random.py +0 -0
  75. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/testing/__init__.py +0 -0
  76. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/testing/const.py +0 -0
  77. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/testing/factory.py +0 -0
  78. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/testing/mask.py +0 -0
  79. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/testing/rules.py +0 -0
  80. {dataframely-1.5.0 → dataframely-1.6.0}/dataframely/testing/typing.py +0 -0
  81. {dataframely-1.5.0 → dataframely-1.6.0}/docker-compose.yml +0 -0
  82. {dataframely-1.5.0 → dataframely-1.6.0}/docs/Makefile +0 -0
  83. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.collection.rst +0 -0
  84. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.any.rst +0 -0
  85. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.bool.rst +0 -0
  86. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.datetime.rst +0 -0
  87. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.decimal.rst +0 -0
  88. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.enum.rst +0 -0
  89. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.float.rst +0 -0
  90. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.integer.rst +0 -0
  91. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.list.rst +0 -0
  92. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.rst +0 -0
  93. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.string.rst +0 -0
  94. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.columns.struct.rst +0 -0
  95. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.config.rst +0 -0
  96. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.exc.rst +0 -0
  97. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.failure.rst +0 -0
  98. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.functional.rst +0 -0
  99. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.mypy.rst +0 -0
  100. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.random.rst +0 -0
  101. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.rst +0 -0
  102. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.schema.rst +0 -0
  103. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.testing.const.rst +0 -0
  104. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.testing.factory.rst +0 -0
  105. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.testing.mask.rst +0 -0
  106. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.testing.rst +0 -0
  107. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.testing.rules.rst +0 -0
  108. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/dataframely.testing.typing.rst +0 -0
  109. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_api/modules.rst +0 -0
  110. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_static/custom.css +0 -0
  111. {dataframely-1.5.0 → dataframely-1.6.0}/docs/_static/favicon.ico +0 -0
  112. {dataframely-1.5.0 → dataframely-1.6.0}/docs/conf.py +0 -0
  113. {dataframely-1.5.0 → dataframely-1.6.0}/docs/index.rst +0 -0
  114. {dataframely-1.5.0 → dataframely-1.6.0}/docs/make.bat +0 -0
  115. {dataframely-1.5.0 → dataframely-1.6.0}/docs/sites/development.rst +0 -0
  116. {dataframely-1.5.0 → dataframely-1.6.0}/docs/sites/examples/real-world.ipynb +0 -0
  117. {dataframely-1.5.0 → dataframely-1.6.0}/docs/sites/faq.rst +0 -0
  118. {dataframely-1.5.0 → dataframely-1.6.0}/docs/sites/installation.rst +0 -0
  119. {dataframely-1.5.0 → dataframely-1.6.0}/docs/sites/quickstart.rst +0 -0
  120. {dataframely-1.5.0 → dataframely-1.6.0}/docs/sites/versioning.rst +0 -0
  121. {dataframely-1.5.0 → dataframely-1.6.0}/pixi.lock +0 -0
  122. {dataframely-1.5.0 → dataframely-1.6.0}/pixi.toml +0 -0
  123. {dataframely-1.5.0 → dataframely-1.6.0}/src/errdefs.rs +0 -0
  124. {dataframely-1.5.0 → dataframely-1.6.0}/src/lib.rs +0 -0
  125. {dataframely-1.5.0 → dataframely-1.6.0}/src/regex_repr.rs +0 -0
  126. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_base.py +0 -0
  127. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_cast.py +0 -0
  128. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_create_empty.py +0 -0
  129. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_filter_one_to_n.py +0 -0
  130. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_filter_validate.py +0 -0
  131. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_ignore_in_filter.py +0 -0
  132. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_implementation.py +0 -0
  133. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_optional_members.py +0 -0
  134. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_sample.py +0 -0
  135. {dataframely-1.5.0 → dataframely-1.6.0}/tests/collection/test_validate_input.py +0 -0
  136. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/__init__.py +0 -0
  137. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_any.py +0 -0
  138. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_array.py +0 -0
  139. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_datetime.py +0 -0
  140. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_decimal.py +0 -0
  141. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_enum.py +0 -0
  142. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_float.py +0 -0
  143. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_integer.py +0 -0
  144. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_list.py +0 -0
  145. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_object.py +0 -0
  146. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_string.py +0 -0
  147. {dataframely-1.5.0 → dataframely-1.6.0}/tests/column_types/test_struct.py +0 -0
  148. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/__init__.py +0 -0
  149. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_check.py +0 -0
  150. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_default_dtypes.py +0 -0
  151. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_metadata.py +0 -0
  152. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_polars_schema.py +0 -0
  153. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_pyarrow.py +0 -0
  154. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_rules.py +0 -0
  155. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_sample.py +0 -0
  156. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_sql_schema.py +0 -0
  157. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_str.py +0 -0
  158. {dataframely-1.5.0 → dataframely-1.6.0}/tests/columns/test_utils.py +0 -0
  159. {dataframely-1.5.0 → dataframely-1.6.0}/tests/core_validation/__init__.py +0 -0
  160. {dataframely-1.5.0 → dataframely-1.6.0}/tests/core_validation/test_column_validation.py +0 -0
  161. {dataframely-1.5.0 → dataframely-1.6.0}/tests/core_validation/test_dtype_validation.py +0 -0
  162. {dataframely-1.5.0 → dataframely-1.6.0}/tests/core_validation/test_rule_evaluation.py +0 -0
  163. {dataframely-1.5.0 → dataframely-1.6.0}/tests/functional/test_concat.py +0 -0
  164. {dataframely-1.5.0 → dataframely-1.6.0}/tests/functional/test_relationships.py +0 -0
  165. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_cast.py +0 -0
  166. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_create_empty.py +0 -0
  167. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_create_empty_if_none.py +0 -0
  168. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_filter.py +0 -0
  169. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_inheritance.py +0 -0
  170. {dataframely-1.5.0 → dataframely-1.6.0}/tests/schema/test_sample.py +0 -0
  171. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_compat.py +0 -0
  172. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_config.py +0 -0
  173. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_exc.py +0 -0
  174. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_extre.py +0 -0
  175. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_failure_info.py +0 -0
  176. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_random.py +0 -0
  177. {dataframely-1.5.0 → dataframely-1.6.0}/tests/test_typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframely
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.11
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -28,7 +28,7 @@ Project-URL: Repository, https://github.com/quantco/dataframely
28
28
  [![conda-forge](https://img.shields.io/conda/vn/conda-forge/dataframely?logoColor=white&logo=conda-forge&style=flat-square)](https://prefix.dev/channels/conda-forge/packages/dataframely)
29
29
  [![pypi-version](https://img.shields.io/pypi/v/dataframely.svg?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/dataframely)
30
30
  [![python-version](https://img.shields.io/pypi/pyversions/dataframely?logoColor=white&logo=python&style=flat-square)](https://pypi.org/project/dataframely)
31
- [![codecov](https://codecov.io/gh/Quantco/dataframely/graph/badge.svg)](https://codecov.io/gh/Quantco/dataframely)
31
+ [![codecov](https://codecov.io/gh/Quantco/dataframely/graph/badge.svg?token=QOvhS7Zri2)](https://codecov.io/gh/Quantco/dataframely)
32
32
 
33
33
  </div>
34
34
 
@@ -11,7 +11,7 @@
11
11
  [![conda-forge](https://img.shields.io/conda/vn/conda-forge/dataframely?logoColor=white&logo=conda-forge&style=flat-square)](https://prefix.dev/channels/conda-forge/packages/dataframely)
12
12
  [![pypi-version](https://img.shields.io/pypi/v/dataframely.svg?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/dataframely)
13
13
  [![python-version](https://img.shields.io/pypi/pyversions/dataframely?logoColor=white&logo=python&style=flat-square)](https://pypi.org/project/dataframely)
14
- [![codecov](https://codecov.io/gh/Quantco/dataframely/graph/badge.svg)](https://codecov.io/gh/Quantco/dataframely)
14
+ [![codecov](https://codecov.io/gh/Quantco/dataframely/graph/badge.svg?token=QOvhS7Zri2)](https://codecov.io/gh/Quantco/dataframely)
15
15
 
16
16
  </div>
17
17
 
@@ -51,7 +51,7 @@ from .functional import (
51
51
  filter_relationship_one_to_at_least_one,
52
52
  filter_relationship_one_to_one,
53
53
  )
54
- from .schema import Schema
54
+ from .schema import Schema, deserialize_schema
55
55
 
56
56
  __all__ = [
57
57
  "random",
@@ -67,6 +67,7 @@ __all__ = [
67
67
  "filter_relationship_one_to_at_least_one",
68
68
  "filter_relationship_one_to_one",
69
69
  "Schema",
70
+ "deserialize_schema",
70
71
  "Any",
71
72
  "Bool",
72
73
  "Column",
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import textwrap
6
7
  import typing
7
8
  from abc import ABCMeta
8
9
  from collections.abc import Iterable
@@ -245,6 +246,32 @@ class CollectionMeta(ABCMeta):
245
246
  # Some other unknown annotation
246
247
  raise AnnotationImplementationError(attr, type_annotation)
247
248
 
249
+ def __repr__(cls) -> str:
250
+ parts = [f'[Collection "{cls.__class__.__name__}"]']
251
+ parts.append(textwrap.indent("Members:", prefix=" " * 2))
252
+ for name, member in cls.members().items(): # type: ignore
253
+ parts.append(
254
+ textwrap.indent(
255
+ f'- "{name}": {member.schema.__name__}'
256
+ f"(optional={member.is_optional}, "
257
+ f"ignored_in_filters={member.ignored_in_filters}, "
258
+ f"inline_for_sampling={member.inline_for_sampling})",
259
+ prefix=" " * 4,
260
+ )
261
+ )
262
+ if filters := cls._filters(): # type: ignore
263
+ parts.append(textwrap.indent("Filters:", prefix=" " * 2))
264
+ for name, member in filters.items():
265
+ parts.append(textwrap.indent(f'- "{name}":', prefix=" " * 4))
266
+ parts.append(
267
+ textwrap.indent(
268
+ f"{member.logic(cls.create_empty()).explain()}", # type: ignore
269
+ prefix=" " * 8,
270
+ )
271
+ )
272
+ parts.append("") # Add line break at the end
273
+ return "\n".join(parts)
274
+
248
275
 
249
276
  class BaseCollection(metaclass=CollectionMeta):
250
277
  """Internal utility abstraction to reference collections without introducing
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import textwrap
6
7
  from abc import ABCMeta
7
8
  from copy import copy
8
9
  from dataclasses import dataclass, field
@@ -10,9 +11,9 @@ from typing import Any, Self
10
11
 
11
12
  import polars as pl
12
13
 
13
- from ._rule import GroupRule, Rule, with_evaluation_rules
14
+ from ._rule import GroupRule, Rule
14
15
  from .columns import Column
15
- from .exc import ImplementationError, RuleImplementationError
16
+ from .exc import ImplementationError
16
17
 
17
18
  _COLUMN_ATTR = "__dataframely_columns__"
18
19
  _RULE_ATTR = "__dataframely_rules__"
@@ -111,25 +112,15 @@ class SchemaMeta(ABCMeta):
111
112
  f"which are not in the schema: {missing_list}."
112
113
  )
113
114
 
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={col_name: col.dtype for col_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 rule_name, rule in result.rules.items():
125
- dtype = schema[rule_name]
126
- if not isinstance(dtype, pl.Boolean):
127
- raise RuleImplementationError(
128
- rule_name, dtype, isinstance(rule, GroupRule)
129
- )
130
-
131
115
  return super().__new__(mcs, name, bases, namespace, *args, **kwargs)
132
116
 
117
+ def __getattribute__(cls, name: str) -> Any:
118
+ val = super().__getattribute__(name)
119
+ # Dynamically set the name of the column if it is a `Column` instance.
120
+ if isinstance(val, Column):
121
+ val._name = val.alias or name
122
+ return val
123
+
133
124
  @staticmethod
134
125
  def _get_metadata_recursively(kls: type[object]) -> Metadata:
135
126
  result = Metadata()
@@ -145,9 +136,7 @@ class SchemaMeta(ABCMeta):
145
136
  k: v for k, v in source.items() if not k.startswith("__")
146
137
  }.items():
147
138
  if isinstance(value, Column):
148
- if not value.alias:
149
- value.alias = attr
150
- result.columns[value.alias] = value
139
+ result.columns[value.alias or attr] = value
151
140
  if isinstance(value, Rule):
152
141
  # We must ensure that custom rules do not clash with internal rules.
153
142
  if attr == "primary_key":
@@ -157,6 +146,18 @@ class SchemaMeta(ABCMeta):
157
146
  result.rules[attr] = value
158
147
  return result
159
148
 
149
+ def __repr__(cls) -> str:
150
+ parts = [f'[Schema "{cls.__name__}"]']
151
+ parts.append(textwrap.indent("Columns:", prefix=" " * 2))
152
+ for name, col in cls.columns().items():
153
+ parts.append(textwrap.indent(f'- "{name}": {col!r}', prefix=" " * 4))
154
+ if validation_rules := cls._schema_validation_rules():
155
+ parts.append(textwrap.indent("Rules:", prefix=" " * 2))
156
+ for name, rule in validation_rules.items():
157
+ parts.append(textwrap.indent(f'- "{name}": {rule!r}', prefix=" " * 4))
158
+ parts.append("") # Add line break at the end
159
+ return "\n".join(parts)
160
+
160
161
 
161
162
  class BaseSchema(metaclass=SchemaMeta):
162
163
  """Internal utility abstraction to reference schemas without introducing cyclical
@@ -37,3 +37,14 @@ def warn_nullable_default_change() -> None:
37
37
  FutureWarning,
38
38
  stacklevel=4,
39
39
  )
40
+
41
+
42
+ @skip_if(env="DATAFRAMELY_NO_FUTURE_WARNINGS")
43
+ def warn_no_nullable_primary_keys() -> None:
44
+ warnings.warn(
45
+ "Nullable primary keys are not supported. "
46
+ "Setting `nullable=True` on a primary key column is ignored "
47
+ "and will raise an error in a future release.",
48
+ FutureWarning,
49
+ stacklevel=4,
50
+ )
@@ -34,6 +34,12 @@ def filter() -> Callable[[Callable[[C], pl.LazyFrame]], Filter[C]]:
34
34
  Attention:
35
35
  Make sure to provide unique combinations of the primary keys or the filters
36
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.
37
43
  """
38
44
 
39
45
  def decorator(validation_fn: Callable[[C], pl.LazyFrame]) -> Filter[C]:
@@ -1,8 +1,11 @@
1
1
  # Copyright (c) QuantCo 2025-2025
2
2
  # SPDX-License-Identifier: BSD-3-Clause
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  from collections import defaultdict
5
7
  from collections.abc import Callable
8
+ from typing import Any, Self
6
9
 
7
10
  import polars as pl
8
11
 
@@ -12,17 +15,68 @@ ValidationFunction = Callable[[], pl.Expr]
12
15
  class Rule:
13
16
  """Internal class representing validation rules."""
14
17
 
15
- def __init__(self, expr: pl.Expr) -> None:
16
- self.expr = expr
18
+ def __init__(self, expr: pl.Expr | ValidationFunction) -> None:
19
+ self._expr = expr
20
+
21
+ @property
22
+ def expr(self) -> pl.Expr:
23
+ """Get the expression of the rule."""
24
+ if callable(self._expr):
25
+ return self._expr()
26
+ return self._expr
27
+
28
+ def matches(self, other: Rule) -> bool:
29
+ """Check whether this rule semantically matches another rule.
30
+
31
+ Args:
32
+ other: The rule to compare with.
33
+
34
+ Returns:
35
+ Whether the rules are semantically equal.
36
+ """
37
+ return self.expr.meta.eq(other.expr)
38
+
39
+ def as_dict(self) -> dict[str, Any]:
40
+ """Turn the rule into a dictionary."""
41
+ return {"rule_type": self.__class__.__name__, "expr": self.expr}
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: dict[str, Any]) -> Self:
45
+ """Read the rule from a dictionary.
46
+
47
+ Args:
48
+ data: The dictionary that was created via :meth:`asdict`.
49
+ """
50
+ return cls(data["expr"])
51
+
52
+ def __repr__(self) -> str:
53
+ return str(self.expr)
17
54
 
18
55
 
19
56
  class GroupRule(Rule):
20
57
  """Rule that is evaluated on a group of columns."""
21
58
 
22
- def __init__(self, expr: pl.Expr, group_columns: list[str]) -> None:
59
+ def __init__(
60
+ self, expr: pl.Expr | ValidationFunction, group_columns: list[str]
61
+ ) -> None:
23
62
  super().__init__(expr)
24
63
  self.group_columns = group_columns
25
64
 
65
+ def matches(self, other: Rule) -> bool:
66
+ if not isinstance(other, GroupRule):
67
+ return False
68
+ return super().matches(other) and self.group_columns == other.group_columns
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ return {**super().as_dict(), "group_columns": self.group_columns}
72
+
73
+ @classmethod
74
+ def from_dict(cls, data: dict[str, Any]) -> Self:
75
+ return cls(data["expr"], group_columns=data["group_columns"])
76
+
77
+ def __repr__(self) -> str:
78
+ return f"{super().__repr__()} grouped by {self.group_columns}"
79
+
26
80
 
27
81
  def rule(*, group_by: list[str] | None = None) -> Callable[[ValidationFunction], Rule]:
28
82
  """Mark a function as a rule to evaluate during validation.
@@ -52,12 +106,18 @@ def rule(*, group_by: list[str] | None = None) -> Callable[[ValidationFunction],
52
106
  rules. By default, any rule that evaluates to ``null`` because one of the
53
107
  columns used in the rule is ``null`` is interpreted as ``true``, i.e. the row
54
108
  is assumed to be valid.
109
+
110
+ Attention:
111
+ The rule logic should return a static result.
112
+ Other implementations using arbitrary python logic works for filtering and
113
+ validation, but may lead to wrong results in Schema comparisons
114
+ and (de-)serialization.
55
115
  """
56
116
 
57
117
  def decorator(validation_fn: ValidationFunction) -> Rule:
58
118
  if group_by is not None:
59
- return GroupRule(expr=validation_fn(), group_columns=group_by)
60
- return Rule(expr=validation_fn())
119
+ return GroupRule(expr=validation_fn, group_columns=group_by)
120
+ return Rule(expr=validation_fn)
61
121
 
62
122
  return decorator
63
123
 
@@ -130,3 +190,30 @@ def _with_group_rules(lf: pl.LazyFrame, rules: dict[str, GroupRule]) -> pl.LazyF
130
190
  frame, on=list(group_columns), how="left", nulls_equal=True
131
191
  )
132
192
  return result
193
+
194
+
195
+ # ------------------------------------------------------------------------------------ #
196
+ # FACTORY #
197
+ # ------------------------------------------------------------------------------------ #
198
+
199
+ _TYPE_MAPPING: dict[str, type[Rule]] = {
200
+ Rule.__name__: Rule,
201
+ GroupRule.__name__: GroupRule,
202
+ }
203
+
204
+
205
+ def rule_from_dict(data: dict[str, Any]) -> Rule:
206
+ """Dynamically read a rule object from a dictionary.
207
+
208
+ Args:
209
+ data: The dictionary obtained by calling :meth:`~Rule.asdict` on a rule object.
210
+ The dictionary must contain a key ``"rule_type"`` that indicates which rule
211
+ type to instantiate.
212
+
213
+ Returns:
214
+ The rule object as read from ``data``.
215
+ """
216
+ name = data["rule_type"]
217
+ if name not in _TYPE_MAPPING:
218
+ raise ValueError(f"Unknown rule type: {name}")
219
+ return _TYPE_MAPPING[name].from_dict(data)
@@ -0,0 +1,88 @@
1
+ # Copyright (c) QuantCo 2025-2025
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ import datetime as dt
5
+ import decimal
6
+ from io import BytesIO
7
+ from json import JSONDecoder, JSONEncoder
8
+ from typing import Any, cast
9
+
10
+ import polars as pl
11
+
12
+
13
+ class SchemaJSONEncoder(JSONEncoder):
14
+ """Custom JSON encoder to properly serialize all types serialized by schemas."""
15
+
16
+ def encode(self, obj: Any) -> str:
17
+ def hint_tuples(item: Any) -> Any:
18
+ if isinstance(item, tuple):
19
+ return {"__type__": "tuple", "value": list(item)}
20
+ if isinstance(item, list):
21
+ return [hint_tuples(i) for i in item]
22
+ if isinstance(item, dict):
23
+ return {k: hint_tuples(v) for k, v in item.items()}
24
+ return item
25
+
26
+ return super().encode(hint_tuples(obj))
27
+
28
+ def default(self, obj: Any) -> Any:
29
+ match obj:
30
+ case pl.Expr():
31
+ return {
32
+ "__type__": "expression",
33
+ "value": obj.meta.serialize(format="json"),
34
+ }
35
+ case decimal.Decimal():
36
+ return {"__type__": "decimal", "value": str(obj)}
37
+ case dt.datetime():
38
+ return {"__type__": "datetime", "value": obj.isoformat()}
39
+ case dt.date():
40
+ return {"__type__": "date", "value": obj.isoformat()}
41
+ case dt.time():
42
+ return {"__type__": "time", "value": obj.isoformat()}
43
+ case dt.timedelta():
44
+ return {"__type__": "timedelta", "value": obj.total_seconds()}
45
+ case dt.tzinfo():
46
+ offset = obj.utcoffset(dt.datetime.now())
47
+ return {
48
+ "__type__": "tzinfo",
49
+ "value": offset.total_seconds() if offset is not None else None,
50
+ }
51
+ case _:
52
+ return super().default(obj)
53
+
54
+
55
+ class SchemaJSONDecoder(JSONDecoder):
56
+ """Custom JSON decoder to properly deserialize all types serialized by schemas."""
57
+
58
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
59
+ super().__init__(object_hook=self.object_hook, *args, **kwargs)
60
+
61
+ def object_hook(self, dct: dict[str, Any]) -> Any:
62
+ if "__type__" not in dct:
63
+ return dct
64
+
65
+ match dct["__type__"]:
66
+ case "tuple":
67
+ return tuple(dct["value"])
68
+ case "expression":
69
+ data = BytesIO(cast(str, dct["value"]).encode("utf-8"))
70
+ return pl.Expr.deserialize(data, format="json")
71
+ case "decimal":
72
+ return decimal.Decimal(dct["value"])
73
+ case "datetime":
74
+ return dt.datetime.fromisoformat(dct["value"])
75
+ case "date":
76
+ return dt.date.fromisoformat(dct["value"])
77
+ case "time":
78
+ return dt.time.fromisoformat(dct["value"])
79
+ case "timedelta":
80
+ return dt.timedelta(seconds=float(dct["value"]))
81
+ case "tzinfo":
82
+ return (
83
+ dt.timezone(dt.timedelta(seconds=float(dct["value"])))
84
+ if dct["value"] is not None
85
+ else dt.timezone(dt.timedelta(0))
86
+ )
87
+ case _:
88
+ raise TypeError(f"Unknown type '{dct['__type__']}' in JSON data.")
@@ -4,6 +4,7 @@
4
4
  import warnings
5
5
  from abc import ABC
6
6
  from collections.abc import Mapping, Sequence
7
+ from dataclasses import asdict
7
8
  from pathlib import Path
8
9
  from typing import Any, Self, cast
9
10
 
@@ -225,6 +226,65 @@ class Collection(BaseCollection, ABC):
225
226
  # 3) Eventually, we initialize the final collection and return
226
227
  return cls.validate(members)
227
228
 
229
+ @classmethod
230
+ def matches(cls, other: type["Collection"]) -> bool:
231
+ """Check whether this collection semantically matches another.
232
+
233
+ Args:
234
+ other: The collection to compare with.
235
+
236
+ Returns:
237
+ Whether the two collections are semantically equal.
238
+
239
+ Attention:
240
+ For custom filters, reliable comparison results are only guaranteed
241
+ if the filter always returns a static polars expression.
242
+ Otherwise, this function may falsely indicate a match.
243
+ """
244
+
245
+ def _members_match() -> bool:
246
+ members_lhs = cls.members()
247
+ members_rhs = other.members()
248
+
249
+ # Member names must match
250
+ if members_lhs.keys() != members_rhs.keys():
251
+ return False
252
+
253
+ # Member attributes must match
254
+ for name in members_lhs:
255
+ lhs = asdict(members_lhs[name])
256
+ rhs = asdict(members_rhs[name])
257
+ for attr in lhs.keys() | rhs.keys():
258
+ if attr == "schema":
259
+ if not lhs[attr].matches(rhs[attr]):
260
+ return False
261
+ else:
262
+ if lhs[attr] != rhs[attr]:
263
+ return False
264
+ return True
265
+
266
+ def _filters_match() -> bool:
267
+ filters_lhs = cls._filters()
268
+ filters_rhs = other._filters()
269
+
270
+ # Filter names must match
271
+ if filters_lhs.keys() != filters_rhs.keys():
272
+ return False
273
+
274
+ # Computational graph of filter logic must match
275
+ # Evaluate on empty dataframes
276
+ empty_left = cls.create_empty()
277
+ empty_right = other.create_empty()
278
+
279
+ for name in filters_lhs:
280
+ lhs = filters_lhs[name].logic(empty_left)
281
+ rhs = filters_rhs[name].logic(empty_right)
282
+ if lhs.serialize(format="json") != rhs.serialize(format="json"):
283
+ return False
284
+ return True
285
+
286
+ return _members_match() and _filters_match()
287
+
228
288
  @classmethod
229
289
  def _preprocess_sample(
230
290
  cls, sample: dict[str, Any], index: int, generator: Generator
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: BSD-3-Clause
3
3
 
4
4
  from ._base import Column
5
+ from ._registry import column_from_dict
5
6
  from .any import Any
6
7
  from .array import Array
7
8
  from .bool import Bool
@@ -17,6 +18,7 @@ from .struct import Struct
17
18
 
18
19
  __all__ = [
19
20
  "Column",
21
+ "column_from_dict",
20
22
  "Any",
21
23
  "Array",
22
24
  "Bool",