django-data-shape 0.16.0__tar.gz → 0.17.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 (173) hide show
  1. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/CHANGELOG.md +67 -1
  2. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/PKG-INFO +1 -1
  3. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/__init__.py +6 -0
  4. django_data_shape-0.17.0/django_data_shape/derivations/copied.py +46 -0
  5. django_data_shape-0.17.0/django_data_shape/derivations/offset.py +65 -0
  6. django_data_shape-0.17.0/django_data_shape/derivations/product.py +67 -0
  7. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/scaled_world.py +56 -2
  8. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/table.py +43 -0
  9. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/utils.py +42 -1
  10. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/version.py +1 -1
  11. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/derivations.md +32 -0
  12. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/reference.md +6 -0
  13. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/pyproject.toml +1 -1
  14. django_data_shape-0.17.0/tests/derivations/test_after_units.py +77 -0
  15. django_data_shape-0.17.0/tests/derivations/test_canonical_arithmetic.py +165 -0
  16. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_fixtures.py +21 -12
  17. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_scaled_world.py +12 -5
  18. django_data_shape-0.17.0/tests/test_scaled_world_over_a_session_world.py +95 -0
  19. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_utils.py +46 -0
  20. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/testapp/models.py +22 -0
  21. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/CODEOWNERS +0 -0
  22. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/SECURITY.md +0 -0
  23. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/dependabot.yml +0 -0
  24. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/workflows/release.yml +0 -0
  25. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/workflows/tests.yml +0 -0
  26. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/workflows/upstream-drift.yml +0 -0
  27. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.gitignore +0 -0
  28. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.pre-commit-config.yaml +0 -0
  29. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/CLAUDE.md +0 -0
  30. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/LICENSE +0 -0
  31. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/Makefile +0 -0
  32. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/README.md +0 -0
  33. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/apply_statistics_targets.py +0 -0
  34. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/build.py +0 -0
  35. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/build_result.py +0 -0
  36. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/canonical.py +0 -0
  37. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/check_constraints.py +0 -0
  38. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/check_invariants.py +0 -0
  39. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/children_per_parent.py +0 -0
  40. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/clone_database.py +0 -0
  41. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivation_queried_database.py +0 -0
  42. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/__init__.py +0 -0
  43. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/after.py +0 -0
  44. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/aligned.py +0 -0
  45. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/derivation.py +0 -0
  46. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/derived.py +0 -0
  47. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/given.py +0 -0
  48. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/per_parent.py +0 -0
  49. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/scope.py +0 -0
  50. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/__init__.py +0 -0
  51. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/ascending.py +0 -0
  52. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/bounded.py +0 -0
  53. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/categorical.py +0 -0
  54. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/constant.py +0 -0
  55. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/distinct.py +0 -0
  56. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/distribution.py +0 -0
  57. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/sequential.py +0 -0
  58. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/skew.py +0 -0
  59. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/uniform.py +0 -0
  60. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/zipf.py +0 -0
  61. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/drop_database.py +0 -0
  62. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fan_out.py +0 -0
  63. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fan_out_plan.py +0 -0
  64. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fan_out_sizes.py +0 -0
  65. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/__init__.py +0 -0
  66. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
  67. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
  68. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
  69. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/generate_rows.py +0 -0
  70. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/infer_key_strategy.py +0 -0
  71. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/invalid_shape.py +0 -0
  72. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/invariant.py +0 -0
  73. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/invariant_violated.py +0 -0
  74. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/__init__.py +0 -0
  75. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/disjoint.py +0 -0
  76. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/key_function.py +0 -0
  77. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/key_strategy.py +0 -0
  78. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/md5_keys.py +0 -0
  79. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/sequential_keys.py +0 -0
  80. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/sql_keys.py +0 -0
  81. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/uuid_keys.py +0 -0
  82. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/order_derivations.py +0 -0
  83. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/order_tables.py +0 -0
  84. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/paired.py +0 -0
  85. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/paired_plan.py +0 -0
  86. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/projection.py +0 -0
  87. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/py.typed +0 -0
  88. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/refuse_queries.py +0 -0
  89. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/require_clone_strategy.py +0 -0
  90. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/require_postgres.py +0 -0
  91. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/resolve_fan_out.py +0 -0
  92. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/resolve_paired.py +0 -0
  93. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/scale_protocol.py +0 -0
  94. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/scaled_shape.py +0 -0
  95. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape.py +0 -0
  96. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape_digest.py +0 -0
  97. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape_from_factory.py +0 -0
  98. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape_not_empty.py +0 -0
  99. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/table_result.py +0 -0
  100. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/template_database.py +0 -0
  101. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/unhashable_shape.py +0 -0
  102. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/unsupported_backend.py +0 -0
  103. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/world_changed.py +0 -0
  104. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/index.md +0 -0
  105. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/invariants.md +0 -0
  106. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/keys.md +0 -0
  107. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/projections.md +0 -0
  108. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/pytest.md +0 -0
  109. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/relations.md +0 -0
  110. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/statistics.md +0 -0
  111. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/mkdocs.yml +0 -0
  112. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/scripts/check-changelog +0 -0
  113. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/scripts/release-publish.sh +0 -0
  114. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/__init__.py +0 -0
  115. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/conftest.py +0 -0
  116. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/conftest_settings.py +0 -0
  117. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/__init__.py +0 -0
  118. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_after.py +0 -0
  119. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_aligned.py +0 -0
  120. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_derived.py +0 -0
  121. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_given.py +0 -0
  122. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_per_parent.py +0 -0
  123. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/__init__.py +0 -0
  124. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_constant.py +0 -0
  125. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_sequential.py +0 -0
  126. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_skew.py +0 -0
  127. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_uniform.py +0 -0
  128. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_zipf.py +0 -0
  129. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/__init__.py +0 -0
  130. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_key_function.py +0 -0
  131. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_md5_keys.py +0 -0
  132. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_sequential_keys.py +0 -0
  133. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_uuid_keys.py +0 -0
  134. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/scale_protocol_consumers.py +0 -0
  135. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/scale_protocol_impostors.py +0 -0
  136. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_apply_statistics_targets.py +0 -0
  137. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build.py +0 -0
  138. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_derivations.py +0 -0
  139. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_graph.py +0 -0
  140. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_invariants.py +0 -0
  141. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_keys.py +0 -0
  142. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_m2m.py +0 -0
  143. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_portable.py +0 -0
  144. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_projection.py +0 -0
  145. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_check_constraints.py +0 -0
  146. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_children_per_parent.py +0 -0
  147. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_documentation.py +0 -0
  148. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_fan_out.py +0 -0
  149. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_fan_out_sizes.py +0 -0
  150. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_generate_rows.py +0 -0
  151. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_infer_key_strategy.py +0 -0
  152. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_invariant.py +0 -0
  153. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_order_derivations.py +0 -0
  154. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_order_tables.py +0 -0
  155. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_paired.py +0 -0
  156. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_paired_plan.py +0 -0
  157. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_projection.py +0 -0
  158. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_reference_documents_the_public_surface.py +0 -0
  159. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_refuse_queries.py +0 -0
  160. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_require_clone_strategy.py +0 -0
  161. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_require_postgres.py +0 -0
  162. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_resolve_fan_out.py +0 -0
  163. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_resolve_paired.py +0 -0
  164. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_scale_protocol.py +0 -0
  165. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_scaled_shape.py +0 -0
  166. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_shape.py +0 -0
  167. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_shape_digest.py +0 -0
  168. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_shape_from_factory.py +0 -0
  169. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_table.py +0 -0
  170. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_template_database.py +0 -0
  171. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_version.py +0 -0
  172. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/testapp/__init__.py +0 -0
  173. {django_data_shape-0.16.0 → django_data_shape-0.17.0}/uv.lock +0 -0
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0] — 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **`Product`, `Offset` and `Copied`: the commonest arithmetic, said as data.**
15
+ They compute nothing `Derived` could not. They exist because of what `Derived`
16
+ costs: it takes a callable, a callable cannot be honestly digested, and a
17
+ shape holding one is refused by `template_database`. So a column as ordinary
18
+ as `total = quantity * unit_price` took a whole declaration out of the reuse
19
+ that turns a forty-second build into a hundred-millisecond clone -- and
20
+ `build()` kept working, so nothing said what it had cost.
21
+
22
+ The refusal stays; it is right. These three are pure data, implement
23
+ `Canonical`, and hash. `Derived` remains the answer for computation that
24
+ really is code.
25
+
26
+ `Offset` is also the same-row half of `After`, which is parent-scoped only.
27
+ Its gap is fixed rather than spread across a window, because a due date thirty
28
+ days after an issue date is a term and not a distribution.
29
+
30
+ ### Changed
31
+
32
+ - **A scaled world can now be built over a session world.** The two pytest
33
+ surfaces this package ships wanted the same tables in any application with one
34
+ model graph, and were mutually exclusive: the session world holds its rows for
35
+ the whole run, so the scaled build met a table that was not empty and refused.
36
+ The documented answer -- give the two different models -- is not available to a
37
+ project whose plan assertions and growth assertions are about the same flow,
38
+ because that is what the application is.
39
+
40
+ It was also order-dependent, which is what made it worth fixing rather than
41
+ documenting better: a suite whose growth tests happened to be collected first
42
+ passed, and the same tests named in the other order failed.
43
+
44
+ `scaled_world` now empties the tables its shape declares before building.
45
+ **Nothing is snapshotted, and nothing needs to be**: this happens inside the
46
+ atomic block it already rolls back, so whatever was there comes back when the
47
+ block ends. A table whose keys are `Disjoint` is left alone, mirroring the
48
+ exemption `build` already makes, so the documented hybrid -- parents made by
49
+ your code, children made here -- keeps working.
50
+
51
+ `build()` keeps its refusal. It has no transaction of its own to undo and
52
+ would be destroying rows for good.
53
+
54
+ The two statement-count constants moved: one TRUNCATE on PostgreSQL, one
55
+ DELETE per declared table elsewhere. Both are properties of the declaration
56
+ rather than the factor, so what those tests pin -- fixed on PostgreSQL at
57
+ every factor, a curve off it -- is unchanged.
58
+
59
+ ### Fixed
60
+
61
+ - **`After` across a parent column of a different kind is refused.**
62
+ `date + timedelta` is a `date`, so a `DateTimeField` child whose parent column
63
+ was a `DateField` was filled with dates: `COPY` accepted them and they landed
64
+ as naive midnights. A world whose orders went on sale in 2024 had shows
65
+ starting in 1900, and the only signal was Django's own per-row
66
+ `RuntimeWarning` in the middle of a build that prints thousands of lines.
67
+
68
+ Both field types are on `_meta` and both are known before a row is generated,
69
+ so this belonged in the class of things already refused at declaration time.
70
+ Only `After` is checked, deliberately: `After` *is* `parent + offset` written
71
+ into this column, so the two describe the same quantity by construction, while
72
+ a `Derived` reading a parent column may legitimately convert -- turning a
73
+ timestamp into a count of days is an ordinary thing to declare.
74
+
10
75
  ## [0.16.0] — 2026-09-04
11
76
 
12
77
  ### Fixed
@@ -1050,7 +1115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1050
1115
  into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
1051
1116
  first.
1052
1117
 
1053
- [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.16.0...HEAD
1118
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.17.0...HEAD
1119
+ [0.17.0]: https://github.com/Artui/django-data-shape/compare/v0.16.0...v0.17.0
1054
1120
  [0.16.0]: https://github.com/Artui/django-data-shape/compare/v0.15.0...v0.16.0
1055
1121
  [0.15.0]: https://github.com/Artui/django-data-shape/compare/v0.14.0...v0.15.0
1056
1122
  [0.14.0]: https://github.com/Artui/django-data-shape/compare/v0.13.0...v0.14.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-data-shape
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: A realistically shaped test database from Django models: declare cardinality, skew and fan-out, load by COPY, and make the query planner believe it.
5
5
  Project-URL: Homepage, https://github.com/Artui/django-data-shape
6
6
  Project-URL: Repository, https://github.com/Artui/django-data-shape
@@ -11,10 +11,13 @@ from django_data_shape.clone_database import clone_database
11
11
  from django_data_shape.derivation_queried_database import DerivationQueriedDatabase
12
12
  from django_data_shape.derivations.after import After
13
13
  from django_data_shape.derivations.aligned import Aligned
14
+ from django_data_shape.derivations.copied import Copied
14
15
  from django_data_shape.derivations.derivation import Derivation
15
16
  from django_data_shape.derivations.derived import Derived
16
17
  from django_data_shape.derivations.given import Given
18
+ from django_data_shape.derivations.offset import Offset
17
19
  from django_data_shape.derivations.per_parent import PerParent
20
+ from django_data_shape.derivations.product import Product
18
21
  from django_data_shape.derivations.scope import Scope
19
22
  from django_data_shape.distributions.ascending import Ascending
20
23
  from django_data_shape.distributions.bounded import Bounded
@@ -67,6 +70,7 @@ __all__ = [
67
70
  "Categorical",
68
71
  "ChildrenPerParent",
69
72
  "Constant",
73
+ "Copied",
70
74
  "Derivation",
71
75
  "DerivationQueriedDatabase",
72
76
  "Derived",
@@ -81,8 +85,10 @@ __all__ = [
81
85
  "KeyFunction",
82
86
  "Md5Keys",
83
87
  "KeyStrategy",
88
+ "Offset",
84
89
  "Paired",
85
90
  "PerParent",
91
+ "Product",
86
92
  "Projection",
87
93
  "ScaleProtocol",
88
94
  "Scope",
@@ -0,0 +1,46 @@
1
+ """A column taken verbatim from the parent's."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django_data_shape.derivations.scope import Scope
6
+
7
+
8
+ class Copied:
9
+ """``relation.field``, unchanged.
10
+
11
+ A ticket's face value is the unit price of the order it belongs to; a line's
12
+ currency is its invoice's. There is no arithmetic at all, which is what made
13
+ needing a lambda for it -- and losing the template-database cache with it --
14
+ annoying rather than merely unfortunate. See
15
+ :class:`~django_data_shape.derivations.product.Product`.
16
+
17
+ Worth saying what this is *not*, because the two are easy to confuse. A
18
+ denormalised copy is a column with its own statistics: the planner sees a
19
+ distribution over the child table rather than a join, which is the whole
20
+ reason schemas carry such columns and the reason a shaped database has to
21
+ reproduce them. Reading the parent's column through the join is a different
22
+ query with a different plan.
23
+ """
24
+
25
+ def __init__(self, source: str) -> None:
26
+ self._source = source
27
+
28
+ @property
29
+ def scope(self) -> Scope:
30
+ return Scope.PARENT
31
+
32
+ @property
33
+ def sources(self) -> tuple[str, ...]:
34
+ return (self._source,)
35
+
36
+ def value(self, row: int, draw: float, sources: tuple[object, ...]) -> object:
37
+ return sources[0]
38
+
39
+ def canonical(self) -> object:
40
+ return ("Copied", self._source)
41
+
42
+ def __repr__(self) -> str:
43
+ return f"Copied({self._source!r})"
44
+
45
+
46
+ __all__ = ["Copied"]
@@ -0,0 +1,65 @@
1
+ """A fixed distance past another column of the same row."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from django_data_shape.derivations.scope import Scope
8
+ from django_data_shape.invalid_shape import InvalidShape
9
+
10
+
11
+ class Offset:
12
+ """``source + by``, where ``source`` is another column of this row.
13
+
14
+ The same-row half of :class:`~django_data_shape.derivations.after.After`,
15
+ which is parent-scoped only. A show goes on sale and then happens; an
16
+ invoice is issued and then falls due. Both columns are on the same row, and
17
+ until this existed the only way to say so was a lambda -- which cost the
18
+ whole shape its template-database cache. See
19
+ :class:`~django_data_shape.derivations.product.Product` for why that
20
+ mattered.
21
+
22
+ The gap is **fixed**, which is the difference from ``After``: that one
23
+ spreads a gap across ``within`` using the column's own draw, because a
24
+ child's distance from its parent is a real distribution. A due date thirty
25
+ days after an issue date is not a distribution, it is a term. Where the
26
+ spread is wanted on the same row, ``Derived`` still takes a callable.
27
+
28
+ ``by`` is in the column's own units -- a ``timedelta`` for a datetime
29
+ column, a number for a numeric one -- and anything supporting
30
+ ``source + by`` works.
31
+ """
32
+
33
+ def __init__(self, source: str, *, by: Any) -> None:
34
+ zero = by * 0
35
+ if by < zero:
36
+ raise InvalidShape(
37
+ f"Offset needs a non-negative by, got {by!r}. A column that lands "
38
+ "*before* another is that other column's Offset, declared the other "
39
+ "way round -- which keeps the two readable in the order they happen."
40
+ )
41
+ self._source = source
42
+ self._by = by
43
+
44
+ @property
45
+ def scope(self) -> Scope:
46
+ return Scope.ROW
47
+
48
+ @property
49
+ def sources(self) -> tuple[str, ...]:
50
+ return (self._source,)
51
+
52
+ def value(self, row: int, draw: float, sources: tuple[object, ...]) -> object:
53
+ # cast for the same reason After does: the column's type is the model's
54
+ # business, and a guard here is a branch no declaration reaches.
55
+ source = cast("Any", sources[0])
56
+ return source + self._by
57
+
58
+ def canonical(self) -> object:
59
+ return ("Offset", self._source, self._by)
60
+
61
+ def __repr__(self) -> str:
62
+ return f"Offset({self._source!r}, by={self._by!r})"
63
+
64
+
65
+ __all__ = ["Offset"]
@@ -0,0 +1,67 @@
1
+ """The product of two columns of the same row."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from django_data_shape.derivations.scope import Scope
8
+
9
+
10
+ class Product:
11
+ """``left * right``, read from two other columns of this row.
12
+
13
+ One of three derivations that exist because
14
+ :class:`~django_data_shape.derivations.derived.Derived` -- the only shipped
15
+ face that can read another column of the same row -- takes a callable, and
16
+ a callable cannot be digested. A shape holding one is refused by
17
+ :func:`~django_data_shape.template_database.template_database`, so a column
18
+ as ordinary as ``total = quantity * unit_price`` excluded the whole
19
+ declaration from the reuse that turns a forty-second build into a
20
+ hundred-millisecond clone.
21
+
22
+ That refusal is right and stays: two lambdas share a name, and identical
23
+ bytecode returns something else when a constant it reads is edited in
24
+ another module. What was wrong is what it excluded. These three say the
25
+ commonest arithmetic as **data**, so they implement
26
+ :class:`~django_data_shape.canonical.Canonical` and the shape hashes::
27
+
28
+ Table(
29
+ Order,
30
+ rows=2_000_000,
31
+ quantity=Aligned("basket", Uniform(1, 8, places=0)),
32
+ unit_price=Aligned("basket", Uniform(1500, 25000, places=0)),
33
+ total=Product("quantity", "unit_price"),
34
+ )
35
+
36
+ ``Derived`` is unchanged and remains the answer for computation that really
37
+ is code. The line between them is whether the declaration can be written
38
+ down: a product of two named columns can, a lambda cannot.
39
+ """
40
+
41
+ def __init__(self, left: str, right: str) -> None:
42
+ self._left = left
43
+ self._right = right
44
+
45
+ @property
46
+ def scope(self) -> Scope:
47
+ return Scope.ROW
48
+
49
+ @property
50
+ def sources(self) -> tuple[str, ...]:
51
+ return (self._left, self._right)
52
+
53
+ def value(self, row: int, draw: float, sources: tuple[object, ...]) -> object:
54
+ # cast rather than a narrowing check, as the other derivations do: what
55
+ # a column holds is decided by the model, not by this package, and a
56
+ # runtime guard would be a branch no declaration can reach.
57
+ left, right = (cast("Any", source) for source in sources)
58
+ return left * right
59
+
60
+ def canonical(self) -> object:
61
+ return ("Product", self._left, self._right)
62
+
63
+ def __repr__(self) -> str:
64
+ return f"Product({self._left!r}, {self._right!r})"
65
+
66
+
67
+ __all__ = ["Product"]
@@ -5,9 +5,10 @@ from __future__ import annotations
5
5
  from collections.abc import Iterator
6
6
  from contextlib import contextmanager
7
7
 
8
- from django.db import DEFAULT_DB_ALIAS, transaction
8
+ from django.db import DEFAULT_DB_ALIAS, connections, transaction
9
9
 
10
10
  from django_data_shape.build import build
11
+ from django_data_shape.keys.disjoint import Disjoint
11
12
  from django_data_shape.scaled_shape import scaled_shape
12
13
  from django_data_shape.shape import Shape
13
14
 
@@ -81,8 +82,10 @@ def scaled_world(shape: Shape, factor: int, *, using: str = DEFAULT_DB_ALIAS) ->
81
82
  not drawn -- so the only visible effect is that a row created by the ORM
82
83
  after a world is torn down gets a larger id than it otherwise would.
83
84
  """
85
+ scaled = scaled_shape(shape, factor)
84
86
  with transaction.atomic(using=using):
85
- result = build(scaled_shape(shape, factor), using=using, require_statistics=False)
87
+ _empty_declared_tables(scaled, using)
88
+ result = build(scaled, using=using, require_statistics=False)
86
89
  yield result.rows
87
90
  # Rolling back on the way out rather than raising and swallowing an
88
91
  # exception to get there: set_rollback is the supported way to leave an
@@ -90,3 +93,54 @@ def scaled_world(shape: Shape, factor: int, *, using: str = DEFAULT_DB_ALIAS) ->
90
93
  # never reaches this line and does not need to -- atomic rolls back for
91
94
  # exactly that case already.
92
95
  transaction.set_rollback(True, using=using)
96
+
97
+
98
+ def _empty_declared_tables(shape: Shape, using: str) -> None:
99
+ """Clear the tables ``shape`` declares, inside the caller's transaction.
100
+
101
+ A world at a factor is the declared shape at that size and nothing else, so
102
+ rows already in those tables are not this world's -- and emptying them is
103
+ what lets a scaled world be built over a session world, which is the one
104
+ composition of this package's two pytest surfaces that an application with a
105
+ single model graph needs and could not have.
106
+
107
+ **Nothing is snapshotted, and nothing needs to be.** This runs inside the
108
+ atomic block ``scaled_world`` already rolls back, so whatever was there
109
+ comes back when the block ends -- the session world included. That is the
110
+ whole reason the fix is four lines rather than a save-and-restore: the
111
+ database was already going to undo this.
112
+
113
+ Emptying rather than refusing is safe *only* here, and that is why it lives
114
+ in this function and not in ``build``. A bare ``build()`` keeps its refusal,
115
+ because it has no transaction of its own to undo and would be destroying
116
+ rows for good.
117
+
118
+ A table whose keys are :class:`~django_data_shape.keys.disjoint.Disjoint`
119
+ is left alone, mirroring the exemption ``build`` makes for the same reason:
120
+ those keys cannot collide with a caller's rows, so the hybrid this package
121
+ documents -- parents made by your code, children made here -- must keep
122
+ working. Deleting the parents would break it in a new way.
123
+ """
124
+ connection = connections[using]
125
+ tables = [
126
+ table.db_table
127
+ for table in shape.tables
128
+ if not isinstance(getattr(table, "keys", None), Disjoint)
129
+ ]
130
+ if not tables:
131
+ return
132
+ quoted = ", ".join(connection.ops.quote_name(name) for name in tables)
133
+ with connection.cursor() as cursor:
134
+ if connection.vendor == "postgresql":
135
+ # TRUNCATE is transactional on PostgreSQL, so it rolls back with the
136
+ # rest of the block. CASCADE covers a foreign key from a table this
137
+ # shape does not declare; RESTART IDENTITY is deliberately omitted,
138
+ # because a sequence reset is not transactional and would leave the
139
+ # counter behind the rows that came back.
140
+ cursor.execute(f"TRUNCATE {quoted} CASCADE")
141
+ else:
142
+ # DELETE rather than TRUNCATE off PostgreSQL: SQLite has no
143
+ # TRUNCATE, and the loads here are small enough that the difference
144
+ # does not matter.
145
+ for name in reversed(tables):
146
+ cursor.execute(f"DELETE FROM {connection.ops.quote_name(name)}")
@@ -8,6 +8,7 @@ from typing import Any, cast
8
8
 
9
9
  from django.db.models import Field, Model
10
10
 
11
+ from django_data_shape.derivations.after import After
11
12
  from django_data_shape.derivations.derivation import Derivation
12
13
  from django_data_shape.derivations.per_parent import PerParent
13
14
  from django_data_shape.derivations.scope import Scope
@@ -26,6 +27,7 @@ from django_data_shape.utils import (
26
27
  check_not_inherited,
27
28
  check_statistics_target,
28
29
  has_db_default,
30
+ offsettable_kind,
29
31
  primary_key_field,
30
32
  )
31
33
 
@@ -370,6 +372,47 @@ class Table:
370
372
  f"field named {field_name}. Its concrete fields are: "
371
373
  f"{', '.join(sorted(available))}."
372
374
  )
375
+ if isinstance(declared, After):
376
+ self._check_offset_units(name, source, parent, field_name, known[name])
377
+
378
+ def _check_offset_units(
379
+ self,
380
+ name: str,
381
+ source: str,
382
+ parent: type[Model],
383
+ field_name: str,
384
+ column: Field[Any, Any],
385
+ ) -> None:
386
+ """Refuse an ``After`` whose parent column is a different kind of thing.
387
+
388
+ Only ``After`` is checked, and deliberately not every parent-scoped
389
+ derivation: ``After`` *is* ``parent + offset`` written into this column,
390
+ so the two columns describe the same quantity by construction. A
391
+ ``Derived`` reading a parent column may legitimately convert -- turning
392
+ a timestamp into a count of days is an ordinary thing to declare -- and
393
+ a check that could not tell the two apart would refuse it.
394
+
395
+ The failure this prevents is silent rather than loud, which is why it
396
+ is worth a refusal at declaration time. ``date + timedelta`` is a
397
+ ``date``; the load accepts it, the rows arrive as naive midnights, and
398
+ the only complaint is Django's own per-row warning in the middle of a
399
+ build that prints thousands of lines.
400
+ """
401
+ parent_field = cast("Field[Any, Any]", parent._meta.get_field(field_name))
402
+ parent_kind = offsettable_kind(parent_field)
403
+ column_kind = offsettable_kind(column)
404
+ if parent_kind is None or column_kind is None or parent_kind == column_kind:
405
+ return
406
+ raise InvalidShape(
407
+ f"{self.model.__name__}.{name} is After({source!r}), which writes that parent "
408
+ f"column plus an offset into this one -- but {parent.__name__}.{field_name} is a "
409
+ f"{type(parent_field).__name__} and {self.model.__name__}.{name} is a "
410
+ f"{type(column).__name__}. Adding an offset to a "
411
+ f"{type(parent_field).__name__} produces a {parent_kind}, so this column would be "
412
+ f"filled with the wrong kind of value and the load would accept it. Read a "
413
+ f"{type(column).__name__} on the parent, or fill this column with a distribution "
414
+ "of its own."
415
+ )
373
416
 
374
417
  def _check_group_sources(self, name: str, declared: Derivation) -> None:
375
418
  """Refuse a group-scoped column whose groups are not there to be grouped by.
@@ -5,7 +5,17 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  from typing import Any
7
7
 
8
- from django.db.models import Field, Model
8
+ from django.db.models import (
9
+ DateField,
10
+ DateTimeField,
11
+ DecimalField,
12
+ DurationField,
13
+ Field,
14
+ FloatField,
15
+ IntegerField,
16
+ Model,
17
+ TimeField,
18
+ )
9
19
  from django.db.models.fields import NOT_PROVIDED
10
20
 
11
21
  from django_data_shape.invalid_shape import InvalidShape
@@ -191,3 +201,34 @@ def check_not_inherited(model: type[Model]) -> None:
191
201
  f"{parents} on its own for the columns that live in its table, and make the rows of "
192
202
  f"{model.__name__} another way."
193
203
  )
204
+
205
+
206
+ def offsettable_kind(field: Field[Any, Any]) -> str | None:
207
+ """What kind of thing this column holds, for the purpose of adding to it.
208
+
209
+ ``After`` writes ``parent + offset`` into a column, so the parent's column
210
+ and the child's have to be the same kind of thing. They are not
211
+ interchangeable even when Python will add them: ``date + timedelta`` is a
212
+ ``date``, so a ``DateTimeField`` filled from a ``DateField`` parent gets
213
+ dates, ``COPY`` accepts them, and they land as naive midnights.
214
+
215
+ ``DateTimeField`` is checked before ``DateField`` because it *subclasses*
216
+ it -- the one ordering in this function that is load-bearing, and the reason
217
+ an ``isinstance`` chain is used rather than a mapping keyed on the class.
218
+
219
+ Returns ``None`` for a column this package has no opinion about, and the
220
+ caller then declines to judge rather than inventing a refusal: a custom
221
+ field that adds cleanly to an offset is a legitimate thing to declare, and
222
+ refusing it would cost a caller a working shape to buy nothing.
223
+ """
224
+ if isinstance(field, DateTimeField):
225
+ return "datetime"
226
+ if isinstance(field, DateField):
227
+ return "date"
228
+ if isinstance(field, TimeField):
229
+ return "time"
230
+ if isinstance(field, DurationField):
231
+ return "duration"
232
+ if isinstance(field, (IntegerField, FloatField, DecimalField)):
233
+ return "number"
234
+ return None
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.16.0"
5
+ __version__: str = "0.17.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -35,6 +35,9 @@ different places, and the place is the only thing that varies:
35
35
  | `After` | `parent` | `"relation.field"` on the parent row | an order opened after its account signed up |
36
36
  | `Given` | `parent` | `"relation.field"` on the parent row | severity skewed by the account's plan |
37
37
  | `Aligned` | `rank` | a shared rank the declaration names | the whales are the same whales |
38
+ | `Product` | `row` | two other columns of this row | `total = quantity * unit_price` |
39
+ | `Offset` | `row` | one other column of this row | a show starts 75 days after it goes on sale |
40
+ | `Copied` | `parent` | `"relation.field"`, unchanged | a ticket's face value is its order's unit price |
38
41
 
39
42
  `Derived` is the mechanism and the other three are shorthand over it. The scope
40
43
  is a parameter, so a correlation nobody shipped a face for is still declarable:
@@ -43,6 +46,35 @@ is a parameter, so a correlation nobody shipped a face for is still declarable:
43
46
  region = Derived("account.country", compute=region_of, scope="parent")
44
47
  ```
45
48
 
49
+ ## Why three of them say arithmetic as data
50
+
51
+ `Product`, `Offset` and `Copied` compute nothing `Derived` could not. They exist
52
+ because of what `Derived` costs, and the cost is not obvious from the
53
+ declaration.
54
+
55
+ `Derived` takes a **callable**, and a callable cannot be honestly digested: two
56
+ lambdas share a name, and identical bytecode returns something else when a
57
+ constant it reads is edited in another module. So a shape holding one is refused
58
+ by [`template_database`](statistics.md) -- and that refusal is right, because a
59
+ cache key that agreed while the data changed would serve a stale database to a
60
+ suite that then passes.
61
+
62
+ What was wrong is what it excluded. `total = quantity * unit_price` is about as
63
+ ordinary as a column gets, and declaring it with a lambda took the whole shape
64
+ out of the reuse that turns a forty-second build into a hundred-millisecond
65
+ clone. `build()` kept working, so nothing said what it had cost.
66
+
67
+ These three are pure data -- a product of two named columns, a fixed offset, a
68
+ parent column unchanged -- so they implement `Canonical` and the shape hashes.
69
+ Reach for `Derived` when the computation really is code; reach for these when it
70
+ can be written down.
71
+
72
+ `Offset` is also the same-row half of `After`, which is parent-scoped only. The
73
+ difference beyond scope is that `After` spreads its gap across `within` using
74
+ the column's own draw, because a child's distance from its parent is a real
75
+ distribution; `Offset`'s gap is fixed, because a due date thirty days after an
76
+ issue date is a term rather than a distribution.
77
+
46
78
  That is why they are one thing. Built separately, "custom creation logic" and
47
79
  "correlate across a relation" become two vocabularies overlapping on the
48
80
  interesting half -- and the half a consumer asks for first is the within-row one,
@@ -76,6 +76,12 @@
76
76
 
77
77
  ::: django_data_shape.derivations.aligned.Aligned
78
78
 
79
+ ::: django_data_shape.derivations.product.Product
80
+
81
+ ::: django_data_shape.derivations.offset.Offset
82
+
83
+ ::: django_data_shape.derivations.copied.Copied
84
+
79
85
  ## Keys
80
86
 
81
87
  ::: django_data_shape.keys.key_strategy.KeyStrategy
@@ -170,7 +170,7 @@ skip-magic-trailing-comma = false
170
170
  root = ["./django_data_shape"]
171
171
 
172
172
  [tool.bumpversion]
173
- current_version = "0.16.0"
173
+ current_version = "0.17.0"
174
174
  allow_dirty = false
175
175
  commit = false
176
176
  tag = false
@@ -0,0 +1,77 @@
1
+ """``After`` writes the parent's value plus an offset, so the two columns have
2
+ to be the same kind of thing.
3
+
4
+ ``date + timedelta`` is a ``date``. A ``DateTimeField`` child filled across an
5
+ edge whose parent column is a ``DateField`` was therefore filled with dates:
6
+ ``COPY`` accepted them, they landed as naive midnights, and the only signal was
7
+ Django's own per-row ``RuntimeWarning`` in the middle of a build.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+
14
+ import pytest
15
+
16
+ from django_data_shape import After, Constant, FanOut, InvalidShape, Sequential, Table, Zipf
17
+ from tests.testapp.models import Performance, Ticket
18
+
19
+
20
+ def test_a_date_parent_under_a_datetime_child_is_refused() -> None:
21
+ with pytest.raises(InvalidShape) as excinfo:
22
+ Table(
23
+ Performance,
24
+ rows=10,
25
+ playhouse=FanOut(Zipf()),
26
+ starts_at=After("playhouse.opened_on", within=dt.timedelta(days=1)),
27
+ doors_on=Constant(dt.date(2026, 1, 1)),
28
+ )
29
+
30
+ message = str(excinfo.value)
31
+ assert "Performance.starts_at" in message
32
+ assert "playhouse.opened_on" in message
33
+ # Both kinds named, because a reader who has to look them up has been given
34
+ # an error that knows more than it says.
35
+ assert "DateField" in message
36
+ assert "DateTimeField" in message
37
+
38
+
39
+ def test_a_datetime_parent_under_a_date_child_is_refused() -> None:
40
+ """The other direction truncates rather than misdating, and is still wrong."""
41
+ with pytest.raises(InvalidShape, match="doors_on"):
42
+ Table(
43
+ Performance,
44
+ rows=10,
45
+ playhouse=FanOut(Zipf()),
46
+ starts_at=Sequential(
47
+ dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), dt.timedelta(hours=1)
48
+ ),
49
+ doors_on=After("playhouse.announced_at", within=dt.timedelta(days=1)),
50
+ )
51
+
52
+
53
+ def test_matching_kinds_are_accepted() -> None:
54
+ """A `DateField` after a `DateField` is exactly what the edge is for."""
55
+ Table(
56
+ Performance,
57
+ rows=10,
58
+ playhouse=FanOut(Zipf()),
59
+ starts_at=Sequential(
60
+ dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), dt.timedelta(hours=1)
61
+ ),
62
+ doors_on=After("playhouse.opened_on", within=dt.timedelta(days=1)),
63
+ )
64
+
65
+
66
+ def test_the_documented_datetime_case_still_builds() -> None:
67
+ """The example in `After`'s own docstring, unchanged."""
68
+ Table(
69
+ Ticket,
70
+ rows=10,
71
+ account=FanOut(Zipf()),
72
+ opened_at=After("account.signed_up_at", within=dt.timedelta(days=365)),
73
+ severity=Constant("low"),
74
+ quantity=Constant(1),
75
+ unit_price=Constant("1.00"),
76
+ total=Constant("1.00"),
77
+ )