dataframely 1.8.2__tar.gz → 1.9.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 (188) hide show
  1. {dataframely-1.8.2 → dataframely-1.9.0}/.gitignore +3 -0
  2. {dataframely-1.8.2 → dataframely-1.9.0}/PKG-INFO +1 -1
  3. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_base_collection.py +3 -3
  4. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_base_schema.py +24 -7
  5. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_polars.py +0 -22
  6. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_rule.py +35 -9
  7. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/collection.py +124 -48
  8. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/failure.py +6 -2
  9. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/schema.py +48 -54
  10. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/testing/factory.py +23 -3
  11. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/testing/rules.py +1 -1
  12. {dataframely-1.8.2 → dataframely-1.9.0}/pyproject.toml +1 -1
  13. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_filter_validate.py +23 -0
  14. dataframely-1.9.0/tests/collection/test_join.py +92 -0
  15. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_array.py +1 -7
  16. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_filter.py +36 -0
  17. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_validate.py +20 -0
  18. dataframely-1.9.0/tests/test_factory.py +89 -0
  19. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_failure_info.py +29 -1
  20. {dataframely-1.8.2 → dataframely-1.9.0}/.copier-answers.yml +0 -0
  21. {dataframely-1.8.2 → dataframely-1.9.0}/.envrc +0 -0
  22. {dataframely-1.8.2 → dataframely-1.9.0}/.gitattributes +0 -0
  23. {dataframely-1.8.2 → dataframely-1.9.0}/.github/CODEOWNERS +0 -0
  24. {dataframely-1.8.2 → dataframely-1.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {dataframely-1.8.2 → dataframely-1.9.0}/.github/dependabot.yml +0 -0
  26. {dataframely-1.8.2 → dataframely-1.9.0}/.github/release-drafter.yml +0 -0
  27. {dataframely-1.8.2 → dataframely-1.9.0}/.github/workflows/build.yml +0 -0
  28. {dataframely-1.8.2 → dataframely-1.9.0}/.github/workflows/chore.yml +0 -0
  29. {dataframely-1.8.2 → dataframely-1.9.0}/.github/workflows/ci.yml +0 -0
  30. {dataframely-1.8.2 → dataframely-1.9.0}/.github/workflows/nightly.yml +0 -0
  31. {dataframely-1.8.2 → dataframely-1.9.0}/.github/workflows/scorecard.yml +0 -0
  32. {dataframely-1.8.2 → dataframely-1.9.0}/.pre-commit-config.yaml +0 -0
  33. {dataframely-1.8.2 → dataframely-1.9.0}/.prettierignore +0 -0
  34. {dataframely-1.8.2 → dataframely-1.9.0}/.prettierrc +0 -0
  35. {dataframely-1.8.2 → dataframely-1.9.0}/.readthedocs.yml +0 -0
  36. {dataframely-1.8.2 → dataframely-1.9.0}/Cargo.lock +0 -0
  37. {dataframely-1.8.2 → dataframely-1.9.0}/Cargo.toml +0 -0
  38. {dataframely-1.8.2 → dataframely-1.9.0}/LICENSE +0 -0
  39. {dataframely-1.8.2 → dataframely-1.9.0}/README.md +0 -0
  40. {dataframely-1.8.2 → dataframely-1.9.0}/SECURITY.md +0 -0
  41. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/__init__.py +0 -0
  42. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_compat.py +0 -0
  43. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_deprecation.py +0 -0
  44. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_extre.pyi +0 -0
  45. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_filter.py +0 -0
  46. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_serialization.py +0 -0
  47. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_typing.py +0 -0
  48. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/_validation.py +0 -0
  49. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/__init__.py +0 -0
  50. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/_base.py +0 -0
  51. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/_mixins.py +0 -0
  52. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/_registry.py +0 -0
  53. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/_utils.py +0 -0
  54. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/any.py +0 -0
  55. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/array.py +0 -0
  56. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/bool.py +0 -0
  57. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/categorical.py +0 -0
  58. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/datetime.py +0 -0
  59. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/decimal.py +0 -0
  60. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/enum.py +0 -0
  61. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/float.py +0 -0
  62. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/integer.py +0 -0
  63. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/list.py +0 -0
  64. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/object.py +0 -0
  65. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/string.py +0 -0
  66. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/columns/struct.py +0 -0
  67. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/config.py +0 -0
  68. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/exc.py +0 -0
  69. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/functional.py +0 -0
  70. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/mypy.py +0 -0
  71. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/py.typed +0 -0
  72. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/random.py +0 -0
  73. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/testing/__init__.py +0 -0
  74. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/testing/const.py +0 -0
  75. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/testing/mask.py +0 -0
  76. {dataframely-1.8.2 → dataframely-1.9.0}/dataframely/testing/typing.py +0 -0
  77. {dataframely-1.8.2 → dataframely-1.9.0}/docker-compose.yml +0 -0
  78. {dataframely-1.8.2 → dataframely-1.9.0}/docs/Makefile +0 -0
  79. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.collection.rst +0 -0
  80. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.any.rst +0 -0
  81. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.bool.rst +0 -0
  82. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.datetime.rst +0 -0
  83. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.decimal.rst +0 -0
  84. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.enum.rst +0 -0
  85. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.float.rst +0 -0
  86. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.integer.rst +0 -0
  87. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.list.rst +0 -0
  88. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.rst +0 -0
  89. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.string.rst +0 -0
  90. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.columns.struct.rst +0 -0
  91. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.config.rst +0 -0
  92. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.exc.rst +0 -0
  93. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.failure.rst +0 -0
  94. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.functional.rst +0 -0
  95. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.mypy.rst +0 -0
  96. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.random.rst +0 -0
  97. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.rst +0 -0
  98. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.schema.rst +0 -0
  99. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.testing.const.rst +0 -0
  100. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.testing.factory.rst +0 -0
  101. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.testing.mask.rst +0 -0
  102. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.testing.rst +0 -0
  103. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.testing.rules.rst +0 -0
  104. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/dataframely.testing.typing.rst +0 -0
  105. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_api/modules.rst +0 -0
  106. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_static/custom.css +0 -0
  107. {dataframely-1.8.2 → dataframely-1.9.0}/docs/_static/favicon.ico +0 -0
  108. {dataframely-1.8.2 → dataframely-1.9.0}/docs/conf.py +0 -0
  109. {dataframely-1.8.2 → dataframely-1.9.0}/docs/index.rst +0 -0
  110. {dataframely-1.8.2 → dataframely-1.9.0}/docs/make.bat +0 -0
  111. {dataframely-1.8.2 → dataframely-1.9.0}/docs/sites/development.rst +0 -0
  112. {dataframely-1.8.2 → dataframely-1.9.0}/docs/sites/examples/real-world.ipynb +0 -0
  113. {dataframely-1.8.2 → dataframely-1.9.0}/docs/sites/faq.rst +0 -0
  114. {dataframely-1.8.2 → dataframely-1.9.0}/docs/sites/installation.rst +0 -0
  115. {dataframely-1.8.2 → dataframely-1.9.0}/docs/sites/quickstart.rst +0 -0
  116. {dataframely-1.8.2 → dataframely-1.9.0}/docs/sites/versioning.rst +0 -0
  117. {dataframely-1.8.2 → dataframely-1.9.0}/pixi.lock +0 -0
  118. {dataframely-1.8.2 → dataframely-1.9.0}/pixi.toml +0 -0
  119. {dataframely-1.8.2 → dataframely-1.9.0}/src/errdefs.rs +0 -0
  120. {dataframely-1.8.2 → dataframely-1.9.0}/src/lib.rs +0 -0
  121. {dataframely-1.8.2 → dataframely-1.9.0}/src/regex_repr.rs +0 -0
  122. {dataframely-1.8.2 → dataframely-1.9.0}/tests/benches/conftest.py +0 -0
  123. {dataframely-1.8.2 → dataframely-1.9.0}/tests/benches/test_collection.py +0 -0
  124. {dataframely-1.8.2 → dataframely-1.9.0}/tests/benches/test_failure.py +0 -0
  125. {dataframely-1.8.2 → dataframely-1.9.0}/tests/benches/test_schema.py +0 -0
  126. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_base.py +0 -0
  127. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_cast.py +0 -0
  128. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_collection_future_annotations.py +0 -0
  129. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_create_empty.py +0 -0
  130. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_filter_one_to_n.py +0 -0
  131. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_ignore_in_filter.py +0 -0
  132. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_implementation.py +0 -0
  133. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_matches.py +0 -0
  134. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_optional_members.py +0 -0
  135. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_read_write_parquet.py +0 -0
  136. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_repr.py +0 -0
  137. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_sample.py +0 -0
  138. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_serialization.py +0 -0
  139. {dataframely-1.8.2 → dataframely-1.9.0}/tests/collection/test_validate_input.py +0 -0
  140. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/__init__.py +0 -0
  141. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_any.py +0 -0
  142. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_datetime.py +0 -0
  143. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_decimal.py +0 -0
  144. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_enum.py +0 -0
  145. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_float.py +0 -0
  146. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_integer.py +0 -0
  147. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_list.py +0 -0
  148. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_object.py +0 -0
  149. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_string.py +0 -0
  150. {dataframely-1.8.2 → dataframely-1.9.0}/tests/column_types/test_struct.py +0 -0
  151. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/__init__.py +0 -0
  152. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_alias.py +0 -0
  153. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_check.py +0 -0
  154. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_default_dtypes.py +0 -0
  155. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_matches.py +0 -0
  156. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_metadata.py +0 -0
  157. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_polars_schema.py +0 -0
  158. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_pyarrow.py +0 -0
  159. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_rules.py +0 -0
  160. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_sample.py +0 -0
  161. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_sql_schema.py +0 -0
  162. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_str.py +0 -0
  163. {dataframely-1.8.2 → dataframely-1.9.0}/tests/columns/test_utils.py +0 -0
  164. {dataframely-1.8.2 → dataframely-1.9.0}/tests/core_validation/__init__.py +0 -0
  165. {dataframely-1.8.2 → dataframely-1.9.0}/tests/core_validation/test_column_validation.py +0 -0
  166. {dataframely-1.8.2 → dataframely-1.9.0}/tests/core_validation/test_dtype_validation.py +0 -0
  167. {dataframely-1.8.2 → dataframely-1.9.0}/tests/core_validation/test_rule_evaluation.py +0 -0
  168. {dataframely-1.8.2 → dataframely-1.9.0}/tests/functional/test_concat.py +0 -0
  169. {dataframely-1.8.2 → dataframely-1.9.0}/tests/functional/test_relationships.py +0 -0
  170. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_base.py +0 -0
  171. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_cast.py +0 -0
  172. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_create_empty.py +0 -0
  173. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_create_empty_if_none.py +0 -0
  174. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_inheritance.py +0 -0
  175. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_matches.py +0 -0
  176. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_read_write_parquet.py +0 -0
  177. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_repr.py +0 -0
  178. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_rule_implementation.py +0 -0
  179. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_sample.py +0 -0
  180. {dataframely-1.8.2 → dataframely-1.9.0}/tests/schema/test_serialization.py +0 -0
  181. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_compat.py +0 -0
  182. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_config.py +0 -0
  183. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_deprecation.py +0 -0
  184. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_exc.py +0 -0
  185. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_extre.py +0 -0
  186. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_random.py +0 -0
  187. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_serialization.py +0 -0
  188. {dataframely-1.8.2 → dataframely-1.9.0}/tests/test_typing.py +0 -0
@@ -388,3 +388,6 @@ $RECYCLE.BIN/
388
388
 
389
389
  # interactive testing
390
390
  /*.ipynb
391
+
392
+ # pytest-benchmark
393
+ .benchmarks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframely
3
- Version: 1.8.2
3
+ Version: 1.9.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.10
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -66,8 +66,8 @@ class CollectionMember:
66
66
  # --------------------------------------- UTILS -------------------------------------- #
67
67
 
68
68
 
69
- def _common_primary_keys(columns: Iterable[type[Schema]]) -> set[str]:
70
- return set.intersection(*[set(schema.primary_keys()) for schema in columns])
69
+ def _common_primary_keys(schemas: Iterable[type[Schema]]) -> set[str]:
70
+ return set.intersection(*[set(schema.primary_keys()) for schema in schemas])
71
71
 
72
72
 
73
73
  # ------------------------------------------------------------------------------------ #
@@ -137,7 +137,7 @@ class CollectionMeta(ABCMeta):
137
137
  for member in result.members.values()
138
138
  ),
139
139
  *(
140
- set(member.schema._validation_rules())
140
+ set(member.schema._validation_rules(with_cast=True))
141
141
  for member in result.members.values()
142
142
  ),
143
143
  )
@@ -12,7 +12,7 @@ from typing import Any
12
12
 
13
13
  import polars as pl
14
14
 
15
- from ._rule import GroupRule, Rule
15
+ from ._rule import DtypeCastRule, GroupRule, Rule
16
16
  from .columns import Column
17
17
  from .exc import ImplementationError
18
18
 
@@ -24,11 +24,13 @@ else:
24
24
  _COLUMN_ATTR = "__dataframely_columns__"
25
25
  _RULE_ATTR = "__dataframely_rules__"
26
26
 
27
+ ORIGINAL_COLUMN_PREFIX = "__DATAFRAMELY_ORIGINAL__"
28
+
27
29
  # --------------------------------------- UTILS -------------------------------------- #
28
30
 
29
31
 
30
32
  def _build_rules(
31
- custom: dict[str, Rule], columns: dict[str, Column]
33
+ custom: dict[str, Rule], columns: dict[str, Column], *, with_cast: bool
32
34
  ) -> dict[str, Rule]:
33
35
  # NOTE: Copy here to prevent in-place modification of the custom rules
34
36
  rules: dict[str, Rule] = copy(custom)
@@ -46,6 +48,21 @@ def _build_rules(
46
48
  }
47
49
  rules.update(column_rules)
48
50
 
51
+ # Add casting rules if requested. Here, we can simply check whether the nullability
52
+ # property of a column changes due to lenient dtype casting. Whenever casting fails,
53
+ # the value is set to `null`, mismatching the previous nullability.
54
+ # NOTE: This check assumes that both the original and cast column are present in the
55
+ # data frame.
56
+ if with_cast:
57
+ casting_rules = {
58
+ f"{col_name}|dtype": DtypeCastRule(
59
+ pl.col(col_name).is_null()
60
+ == pl.col(f"{ORIGINAL_COLUMN_PREFIX}{col_name}").is_null()
61
+ )
62
+ for col_name in columns
63
+ }
64
+ rules.update(casting_rules)
65
+
49
66
  return rules
50
67
 
51
68
 
@@ -93,9 +110,7 @@ class SchemaMeta(ABCMeta):
93
110
  # we assume that users cast dtypes, i.e. additional rules for dtype casting
94
111
  # are also checked.
95
112
  all_column_names = set(result.columns)
96
- all_rule_names = set(_build_rules(result.rules, result.columns).keys()) | set(
97
- f"{col}|dtype" for col in result.columns
98
- )
113
+ all_rule_names = set(_build_rules(result.rules, result.columns, with_cast=True))
99
114
  common_names = all_column_names & all_rule_names
100
115
  if len(common_names) > 0:
101
116
  common_list = ", ".join(sorted(f"'{col}'" for col in common_names))
@@ -189,8 +204,10 @@ class BaseSchema(metaclass=SchemaMeta):
189
204
  return _primary_keys(cls.columns())
190
205
 
191
206
  @classmethod
192
- def _validation_rules(cls) -> dict[str, Rule]:
193
- return _build_rules(cls._schema_validation_rules(), cls.columns())
207
+ def _validation_rules(cls, *, with_cast: bool) -> dict[str, Rule]:
208
+ return _build_rules(
209
+ cls._schema_validation_rules(), cls.columns(), with_cast=with_cast
210
+ )
194
211
 
195
212
  @classmethod
196
213
  def _schema_validation_rules(cls) -> dict[str, Rule]:
@@ -2,7 +2,6 @@
2
2
  # SPDX-License-Identifier: BSD-3-Clause
3
3
 
4
4
  import datetime as dt
5
- from collections.abc import Iterable
6
5
  from typing import TypeVar
7
6
 
8
7
  import polars as pl
@@ -14,27 +13,6 @@ FrameType = TypeVar("FrameType", pl.DataFrame, pl.LazyFrame)
14
13
  EPOCH_DATETIME = dt.datetime(1970, 1, 1)
15
14
  SECONDS_PER_DAY = 86400
16
15
 
17
- # --------------------------------------- JOINS -------------------------------------- #
18
-
19
-
20
- def join_all_inner(dfs: Iterable[FrameType], on: str | list[str]) -> FrameType:
21
- it = iter(dfs)
22
- result = next(it)
23
- while (df := next(it, None)) is not None:
24
- result = result.join(df, on=on)
25
- return result
26
-
27
-
28
- def join_all_outer(dfs: Iterable[FrameType], on: str | list[str]) -> FrameType:
29
- it = iter(dfs)
30
- result = next(it)
31
- while (df := next(it, None)) is not None:
32
- result = result.join(df, on=on, how="full", coalesce=True)
33
- return result
34
-
35
-
36
- # ------------------------------------- DATETIMES ------------------------------------ #
37
-
38
16
 
39
17
  def date_matches_resolution(t: dt.date, resolution: str) -> bool:
40
18
  return pl.Series([t], dtype=pl.Date).dt.truncate(resolution).item() == t
@@ -59,6 +59,14 @@ class Rule:
59
59
  return str(self.expr)
60
60
 
61
61
 
62
+ class DtypeCastRule(Rule):
63
+ """Rule that evaluates whether casting a column to another dtype is successful.
64
+
65
+ The only purpose of this rule is to provide a runtime type to distinguish it from
66
+ other rules.
67
+ """
68
+
69
+
62
70
  class GroupRule(Rule):
63
71
  """Rule that is evaluated on a group of columns."""
64
72
 
@@ -147,7 +155,8 @@ def with_evaluation_rules(lf: pl.LazyFrame, rules: dict[str, Rule]) -> pl.LazyFr
147
155
  while ``False`` indicates an issue.
148
156
  """
149
157
  # Rules must be distinguished into two types of rules:
150
- # 1. Simple rules can simply be selected on the data frame
158
+ # 1. Simple rules can simply be selected on the data frame (this includes rules
159
+ # that check whether dtype casts succeeded)
151
160
  # 2. "Group" rules require a `group_by` and a subsequent join
152
161
  simple_exprs = {
153
162
  name: rule.expr
@@ -160,7 +169,7 @@ def with_evaluation_rules(lf: pl.LazyFrame, rules: dict[str, Rule]) -> pl.LazyFr
160
169
 
161
170
  # Before we can select all of the simple expressions, we need to turn the
162
171
  # group rules into something to use in a `select` statement as well.
163
- return (
172
+ result = (
164
173
  # NOTE: A value of `null` always validates successfully as nullability should
165
174
  # already be checked via dedicated rules.
166
175
  _with_group_rules(lf, group_rules).with_columns(
@@ -168,6 +177,27 @@ def with_evaluation_rules(lf: pl.LazyFrame, rules: dict[str, Rule]) -> pl.LazyFr
168
177
  )
169
178
  )
170
179
 
180
+ # If there is at least one rule that checks for successful dtype casting, we need
181
+ # to take an extra step: rules other than the "dtype rules" might not be reliable
182
+ # if casting failed, i.e. if any of the "dtype rules" evaluated to `False`. For
183
+ # this reason, we set all other rule evaluations to `null` in the case of dtype
184
+ # casting failure.
185
+ dtype_rule_names = [
186
+ name for name, rule in rules.items() if isinstance(rule, DtypeCastRule)
187
+ ]
188
+ if len(dtype_rule_names) > 0:
189
+ non_dtype_rule_names = [
190
+ name for name, rule in rules.items() if not isinstance(rule, DtypeCastRule)
191
+ ]
192
+ all_dtype_casts_valid = pl.all_horizontal(dtype_rule_names)
193
+ return result.with_columns(
194
+ pl.when(all_dtype_casts_valid)
195
+ .then(pl.col(non_dtype_rule_names))
196
+ .otherwise(pl.lit(None, dtype=pl.Boolean))
197
+ )
198
+
199
+ return result
200
+
171
201
 
172
202
  def _with_group_rules(lf: pl.LazyFrame, rules: dict[str, GroupRule]) -> pl.LazyFrame:
173
203
  # First, we partition the rules by group columns. This will minimize the number
@@ -184,16 +214,12 @@ def _with_group_rules(lf: pl.LazyFrame, rules: dict[str, GroupRule]) -> pl.LazyF
184
214
  # We group by the group columns and apply all expressions
185
215
  group_evaluations[group_columns] = lf.group_by(group_columns).agg(**group_rules)
186
216
 
187
- # Eventually, we apply the rule evaluations onto the input data frame. For this,
188
- # we're using left-joins. This has two effects:
189
- # - We're essentially "broadcasting" the results within each group across rows
190
- # in the same group.
191
- # - While an inner-join would be semantically more accurate, the left-join
192
- # preserves the order of the left data frame.
217
+ # Eventually, we apply the rule evaluations onto the input data frame. For this, we
218
+ # "broadcast" the results within each group across rows in the same group.
193
219
  result = lf
194
220
  for group_columns, frame in group_evaluations.items():
195
221
  result = result.join(
196
- frame, on=list(group_columns), how="left", nulls_equal=True
222
+ frame, on=list(group_columns), nulls_equal=True, maintain_order="left"
197
223
  )
198
224
  return result
199
225
 
@@ -10,14 +10,14 @@ from collections.abc import Iterable, Mapping, Sequence
10
10
  from dataclasses import asdict
11
11
  from json import JSONDecodeError
12
12
  from pathlib import Path
13
- from typing import IO, Annotated, Any, cast
13
+ from typing import IO, Annotated, Any, Literal, cast
14
14
 
15
15
  import polars as pl
16
16
  import polars.exceptions as plexc
17
17
 
18
18
  from ._base_collection import BaseCollection, CollectionMember
19
19
  from ._filter import Filter
20
- from ._polars import FrameType, join_all_inner, join_all_outer
20
+ from ._polars import FrameType
21
21
  from ._serialization import (
22
22
  COLLECTION_METADATA_KEY,
23
23
  SERIALIZATION_FORMAT_VERSION,
@@ -41,6 +41,8 @@ if sys.version_info >= (3, 11):
41
41
  else:
42
42
  from typing_extensions import Self
43
43
 
44
+ _FILTER_COLUMN_PREFIX = "__DATAFRAMELY_FILTER_COLUMN__"
45
+
44
46
 
45
47
  class Collection(BaseCollection, ABC):
46
48
  """Base class for all collections of data frames with a predefined schema.
@@ -376,7 +378,8 @@ class Collection(BaseCollection, ABC):
376
378
  Returns:
377
379
  An instance of the collection. All members of the collection are guaranteed
378
380
  to be valid with respect to their respective schemas and the filters on this
379
- collection did not remove rows from any member.
381
+ collection did not remove rows from any member. The input order of each
382
+ member is maintained.
380
383
  """
381
384
  out, failure = cls.filter(data, cast=cast)
382
385
  if any(len(fail) > 0 for fail in failure.values()):
@@ -439,7 +442,9 @@ class Collection(BaseCollection, ABC):
439
442
  filtered out by any of the collection's filters. While collection members
440
443
  are always instances of :class:`~polars.LazyFrame`, the members of the
441
444
  returned collection are essentially eager as they are constructed by
442
- calling ``.lazy()`` on eager data frames.
445
+ calling ``.lazy()`` on eager data frames. Just like in polars' native
446
+ :meth:`~polars.DataFrame.filter`, the order of rows is maintained in all
447
+ returned data frames.
443
448
  - A mapping from member name to a :class:`FailureInfo` object which provides
444
449
  details on why individual rows had been removed. Optional members are only
445
450
  included in this dictionary if they had been provided in the input.
@@ -465,7 +470,7 @@ class Collection(BaseCollection, ABC):
465
470
  data[member_name], cast=cast
466
471
  )
467
472
 
468
- # Once we're done that, we can apply the filters on this collection. To this end,
473
+ # Once we've done that, we can apply the filters on this collection. To this end,
469
474
  # we iterate over all filters and store the filter results.
470
475
  filters = cls._filters()
471
476
  if len(filters) > 0:
@@ -476,41 +481,35 @@ class Collection(BaseCollection, ABC):
476
481
  for name, filter in filters.items():
477
482
  keep[name] = filter.logic(result_cls).select(primary_keys).collect()
478
483
 
479
- # Using the filter results, we can define a joint data frame that we use to filter
480
- # the input.
481
- all_keep = join_all_inner(
482
- [df.lazy() for df in keep.values()], on=primary_keys
483
- ).collect()
484
-
485
- # Now we can iterate over the results where we do the following:
486
- # - Join the current result onto `all_keep` to get rid of the rows we do not
487
- # want to keep.
488
- # - Anti-join onto each individual filter to extend the failure reasons
484
+ # Now we can iterate over the results and left-join onto each individual
485
+ # filter to obtain independent boolean indicators of whether to keep the row
489
486
  for member_name, filtered in results.items():
490
- if cls.members()[member_name].ignored_in_filters:
487
+ member_info = cls.members()[member_name]
488
+ if member_info.ignored_in_filters:
491
489
  continue
492
- results[member_name] = filtered.join(all_keep, on=primary_keys)
493
-
494
- new_failure_names = list(filters.keys())
495
- new_failure_pks = [
496
- filtered.select(primary_keys)
497
- .lazy()
498
- .unique()
499
- .join(filter_keep.lazy(), on=primary_keys, how="anti")
500
- .with_columns(pl.lit(False).alias(name))
501
- for name, filter_keep in keep.items()
502
- ]
503
- # NOTE: The outer join might generate NULL values if a primary key is not
504
- # filtered out by all filters. In this case, we want to assign a validation
505
- # value of `True`.
506
- all_new_failure_pks = join_all_outer(
507
- new_failure_pks, on=primary_keys
508
- ).with_columns(pl.col(new_failure_names).fill_null(True))
509
-
510
- # At this point, we have a data frame with the primary keys of the *excluded*
511
- # rows of the member result along with the reasons. We join on the result again
512
- # which will give us the same shape as the current failure info lf. Thus, we can
513
- # simply concatenate diagonally, resulting in a failure info object as follows:
490
+
491
+ lf_with_eval = filtered.lazy()
492
+ for name, filter_keep in keep.items():
493
+ lf_with_eval = lf_with_eval.join(
494
+ filter_keep.lazy().with_columns(pl.lit(True).alias(name)),
495
+ on=primary_keys,
496
+ how="left",
497
+ maintain_order="left",
498
+ ).with_columns(pl.col(name).fill_null(False))
499
+
500
+ result_with_eval = lf_with_eval.collect()
501
+
502
+ # Filtering `result_with_eval` by the rows for which all joins
503
+ # "succeeded", we can identify the rows that pass all the filters. We
504
+ # keep these rows for the result.
505
+ results[member_name] = result_with_eval.filter(
506
+ pl.all_horizontal(keep.keys())
507
+ ).drop(keep.keys())
508
+
509
+ # Filtering `result_with_eval` with the inverse condition, we find all
510
+ # the problematic rows. We can build a single failure info object by
511
+ # simply concatenating diagonally with the already existing failure. The
512
+ # resulting failure info looks as follows:
514
513
  #
515
514
  # | Source Data | Rule Columns (schema) | Filter Name Columns (collection) |
516
515
  # | ----------- | --------------------- | -------------------------------- |
@@ -518,21 +517,98 @@ class Collection(BaseCollection, ABC):
518
517
  # | ... | NULL | <filled> |
519
518
  #
520
519
  failure = failures[member_name]
521
- new_failure = FailureInfo(
522
- lf=pl.concat(
523
- [
524
- failure._lf,
525
- filtered.lazy().join(all_new_failure_pks, on=primary_keys),
526
- ],
527
- how="diagonal",
528
- ),
529
- rule_columns=failure._rule_columns + new_failure_names,
520
+ filtered_failure = result_with_eval.filter(
521
+ ~pl.all_horizontal(keep.keys())
522
+ ).lazy()
523
+
524
+ # If we cast previously, `failure` and `filtered_failure` have different
525
+ # dtypes for the source data: `failure` keeps the original dtypes while
526
+ # `filtered_failure` has the target dtypes. Hence, we need to cast
527
+ # `filtered_failure` to the original dtypes. This is safe because any
528
+ # row in `filtered_failure` must have already been successfully cast and
529
+ # a "roundtrip cast" is always possible.
530
+ # Doing this in a fully lazy way is not trivial: we do a diagonal
531
+ # concatenation where we duplicate each column of the source data. We
532
+ # then coalesce the two versions into the original column dtype.
533
+ if cast:
534
+ filtered_failure = filtered_failure.rename(
535
+ {
536
+ name: f"{_FILTER_COLUMN_PREFIX}{name}"
537
+ for name in member_info.schema.column_names()
538
+ }
539
+ )
540
+
541
+ failure_lf = pl.concat([failure._lf, filtered_failure], how="diagonal")
542
+ if cast:
543
+ failure_lf = failure_lf.with_columns(
544
+ pl.coalesce(
545
+ name,
546
+ pl.col(f"{_FILTER_COLUMN_PREFIX}{name}").cast(
547
+ pl.dtype_of(name)
548
+ ),
549
+ )
550
+ for name in member_info.schema.column_names()
551
+ ).drop(
552
+ f"{_FILTER_COLUMN_PREFIX}{name}"
553
+ for name in member_info.schema.column_names()
554
+ )
555
+
556
+ failures[member_name] = FailureInfo(
557
+ lf=failure_lf,
558
+ rule_columns=failure._rule_columns + list(keep.keys()),
530
559
  schema=failure.schema,
531
560
  )
532
- failures[member_name] = new_failure
533
561
 
534
562
  return cls._init(results), failures
535
563
 
564
+ def join(
565
+ self,
566
+ primary_keys: pl.LazyFrame,
567
+ how: Literal["semi", "anti"] = "semi",
568
+ maintain_order: Literal["none", "left"] = "none",
569
+ ) -> Self:
570
+ """Filter the collection by joining onto a data frame containing entries for the
571
+ common primary key columns whose respective rows should be kept or removed in
572
+ the collection members.
573
+
574
+ Args:
575
+ primary_keys: The data frame to join on. Must contain the common primary key
576
+ columns of the collection.
577
+ how: The join strategy to use. Like in polars, `semi` will keep all rows
578
+ that can be found in `primary_keys`, `anti` will remove them.
579
+ maintain_order: The `maintain_order` option to use for the polars join.
580
+
581
+ Returns:
582
+ The collection, with members potentially reduced in length.
583
+
584
+ Raises:
585
+ ValueError: If the collection contains any member that is annotated with
586
+ `ignored_in_filters=True`.
587
+
588
+ Attention:
589
+ This method does not validate the resulting collection. Ensure to only use
590
+ this if the resulting collection still satisfies the filters of the
591
+ collection. The joins are not evaluated eagerly. Therefore, a downstream
592
+ call to :meth:`collect` might fail, especially if `primary_keys` does not
593
+ contain all columns for all common primary keys.
594
+ """
595
+ if any(member.ignored_in_filters for member in self.members().values()):
596
+ raise ValueError(
597
+ "The join operation is not supported for collections with members that are ignored in filters."
598
+ )
599
+
600
+ return self.cast(
601
+ {
602
+ key: lf.join(
603
+ primary_keys,
604
+ on=self.common_primary_keys(),
605
+ how=how,
606
+ maintain_order=maintain_order,
607
+ )
608
+ for key, lf in self.to_dict().items()
609
+ }
610
+ )
611
+
536
612
  # ------------------------------------ CASTING ----------------------------------- #
537
613
 
538
614
  @classmethod
@@ -19,6 +19,7 @@ if TYPE_CHECKING: # pragma: no cover
19
19
  from .schema import Schema
20
20
 
21
21
  RULE_METADATA_KEY = "dataframely_rule_columns"
22
+ UNKNOWN_SCHEMA_NAME = "__DATAFRAMELY_UNKNOWN__"
22
23
 
23
24
  S = TypeVar("S", bound=BaseSchema)
24
25
 
@@ -176,7 +177,7 @@ class FailureInfo(Generic[S]):
176
177
  def _from_parquet(
177
178
  cls, source: str | Path | IO[bytes], scan: bool, **kwargs: Any
178
179
  ) -> FailureInfo[Schema]:
179
- from .schema import deserialize_schema
180
+ from .schema import Schema, deserialize_schema
180
181
 
181
182
  metadata = pl.read_parquet_metadata(source)
182
183
  schema_metadata = metadata.get(SCHEMA_METADATA_KEY)
@@ -189,10 +190,13 @@ class FailureInfo(Generic[S]):
189
190
  if scan
190
191
  else pl.read_parquet(source, **kwargs).lazy()
191
192
  )
193
+ failure_schema = deserialize_schema(schema_metadata, strict=False) or type(
194
+ UNKNOWN_SCHEMA_NAME, (Schema,), {}
195
+ )
192
196
  return FailureInfo(
193
197
  lf,
194
198
  json.loads(rule_metadata),
195
- schema=deserialize_schema(schema_metadata),
199
+ schema=failure_schema,
196
200
  )
197
201
 
198
202