django-data-shape 0.22.0__tar.gz → 0.24.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 (219) hide show
  1. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/workflows/release.yml +2 -2
  2. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/workflows/tests.yml +35 -10
  3. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/workflows/upstream-drift.yml +2 -2
  4. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/CHANGELOG.md +103 -1
  5. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/PKG-INFO +10 -3
  6. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/README.md +9 -2
  7. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/__init__.py +4 -0
  8. django_data_shape-0.24.0/django_data_shape/databases/derive_template.py +231 -0
  9. django_data_shape-0.24.0/django_data_shape/databases/step_failed.py +24 -0
  10. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/template_database.py +33 -86
  11. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/check_constraints.py +18 -8
  12. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/table.py +2 -2
  13. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/__init__.py +2 -0
  14. django_data_shape-0.24.0/django_data_shape/fixtures/template_fixture.py +157 -0
  15. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out.py +44 -3
  16. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out_sizes.py +41 -21
  17. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/resolve_fan_out.py +126 -11
  18. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/resolve_paired.py +27 -15
  19. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scale_protocol.py +1 -1
  20. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scaled_world.py +2 -2
  21. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/utils.py +160 -0
  22. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/version.py +1 -1
  23. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/pytest.md +92 -7
  24. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/reference.md +6 -0
  25. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/relations.md +88 -8
  26. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/statistics.md +78 -0
  27. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/pyproject.toml +1 -1
  28. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/conftest_settings.py +31 -0
  29. django_data_shape-0.24.0/tests/databases/test_derive_template.py +368 -0
  30. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/test_template_database.py +11 -8
  31. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_check_constraints.py +51 -0
  32. django_data_shape-0.24.0/tests/fixtures/test_template_fixture.py +119 -0
  33. django_data_shape-0.24.0/tests/fixtures/test_template_fixture_leaves_other_databases.py +89 -0
  34. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build.py +78 -1
  35. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_fan_out.py +36 -2
  36. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_fan_out_sizes.py +124 -0
  37. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_paired_parents.py +38 -0
  38. django_data_shape-0.24.0/tests/relations/test_parents_key_spelling.py +177 -0
  39. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_resolve_fan_out.py +222 -2
  40. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_resolve_paired.py +3 -0
  41. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/scale_protocol_consumers.py +3 -3
  42. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/scale_protocol_impostors.py +2 -2
  43. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_shape.py +4 -1
  44. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_utils.py +86 -1
  45. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/testapp/models.py +26 -0
  46. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/uv.lock +64 -87
  47. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/CODEOWNERS +0 -0
  48. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/SECURITY.md +0 -0
  49. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/dependabot.yml +0 -0
  50. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.gitignore +0 -0
  51. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.pre-commit-config.yaml +0 -0
  52. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/CLAUDE.md +0 -0
  53. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/LICENSE +0 -0
  54. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/Makefile +0 -0
  55. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/__init__.py +0 -0
  56. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/require_clone_strategy.py +0 -0
  57. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/require_postgres.py +0 -0
  58. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/unsupported_backend.py +0 -0
  59. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/__init__.py +0 -0
  60. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/clone_database.py +0 -0
  61. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/drop_database.py +0 -0
  62. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/require_quotable_name.py +0 -0
  63. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/shape_digest.py +0 -0
  64. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/unhashable_shape.py +0 -0
  65. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/unusable_base.py +0 -0
  66. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/__init__.py +0 -0
  67. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/invalid_shape.py +0 -0
  68. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/order_derivations.py +0 -0
  69. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/order_tables.py +0 -0
  70. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/projection.py +0 -0
  71. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/shape.py +0 -0
  72. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/shape_from_factory.py +0 -0
  73. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/sql_value.py +0 -0
  74. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/__init__.py +0 -0
  75. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/after.py +0 -0
  76. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/aligned.py +0 -0
  77. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/copied.py +0 -0
  78. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/derivation.py +0 -0
  79. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/derived.py +0 -0
  80. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/given.py +0 -0
  81. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/offset.py +0 -0
  82. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/per_parent.py +0 -0
  83. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/product.py +0 -0
  84. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/scope.py +0 -0
  85. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/__init__.py +0 -0
  86. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/ascending.py +0 -0
  87. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/bounded.py +0 -0
  88. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/categorical.py +0 -0
  89. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/constant.py +0 -0
  90. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/distinct.py +0 -0
  91. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/distribution.py +0 -0
  92. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/sequential.py +0 -0
  93. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/skew.py +0 -0
  94. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/uniform.py +0 -0
  95. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/zipf.py +0 -0
  96. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
  97. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
  98. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
  99. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/__init__.py +0 -0
  100. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/derivation_queried_database.py +0 -0
  101. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/generate_rows.py +0 -0
  102. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/refuse_queries.py +0 -0
  103. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/__init__.py +0 -0
  104. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/check_invariants.py +0 -0
  105. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/invariant.py +0 -0
  106. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/invariant_violated.py +0 -0
  107. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/__init__.py +0 -0
  108. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/disjoint.py +0 -0
  109. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/infer_key_strategy.py +0 -0
  110. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/is_disjoint.py +0 -0
  111. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/key_function.py +0 -0
  112. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/key_strategy.py +0 -0
  113. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/md5_keys.py +0 -0
  114. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/sequential_keys.py +0 -0
  115. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/sql_keys.py +0 -0
  116. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/uuid_keys.py +0 -0
  117. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/__init__.py +0 -0
  118. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/apply_statistics_targets.py +0 -0
  119. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/build.py +0 -0
  120. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/shape_not_empty.py +0 -0
  121. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/py.typed +0 -0
  122. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/__init__.py +0 -0
  123. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out_plan.py +0 -0
  124. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/paired.py +0 -0
  125. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/paired_plan.py +0 -0
  126. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/world_changed.py +0 -0
  127. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/__init__.py +0 -0
  128. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scaled_shape.py +0 -0
  129. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/shape_referenced.py +0 -0
  130. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/__init__.py +0 -0
  131. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/build_result.py +0 -0
  132. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/canonical.py +0 -0
  133. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/children_per_parent.py +0 -0
  134. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/table_result.py +0 -0
  135. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/derivations.md +0 -0
  136. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/index.md +0 -0
  137. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/invariants.md +0 -0
  138. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/keys.md +0 -0
  139. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/projections.md +0 -0
  140. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/mkdocs.yml +0 -0
  141. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/scripts/check-changelog +0 -0
  142. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/scripts/release-publish.sh +0 -0
  143. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/__init__.py +0 -0
  144. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/backends/__init__.py +0 -0
  145. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/backends/test_require_clone_strategy.py +0 -0
  146. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/backends/test_require_postgres.py +0 -0
  147. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/conftest.py +0 -0
  148. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/__init__.py +0 -0
  149. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/test_require_quotable_name.py +0 -0
  150. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/test_shape_digest.py +0 -0
  151. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/__init__.py +0 -0
  152. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_order_derivations.py +0 -0
  153. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_order_tables.py +0 -0
  154. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection.py +0 -0
  155. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_ceiling.py +0 -0
  156. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_sql_percent.py +0 -0
  157. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values.py +0 -0
  158. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values_references.py +0 -0
  159. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values_uuid.py +0 -0
  160. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_shape.py +0 -0
  161. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_shape_from_factory.py +0 -0
  162. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_table.py +0 -0
  163. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/__init__.py +0 -0
  164. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_after.py +0 -0
  165. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_after_units.py +0 -0
  166. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_aligned.py +0 -0
  167. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
  168. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_derived.py +0 -0
  169. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_given.py +0 -0
  170. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_per_parent.py +0 -0
  171. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/__init__.py +0 -0
  172. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_constant.py +0 -0
  173. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_sequential.py +0 -0
  174. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_skew.py +0 -0
  175. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_uniform.py +0 -0
  176. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_zipf.py +0 -0
  177. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/fixtures/__init__.py +0 -0
  178. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/fixtures/test_fixtures.py +0 -0
  179. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/generation/__init__.py +0 -0
  180. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/generation/test_generate_rows.py +0 -0
  181. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/generation/test_refuse_queries.py +0 -0
  182. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/invariants/__init__.py +0 -0
  183. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/invariants/test_invariant.py +0 -0
  184. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/__init__.py +0 -0
  185. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_infer_key_strategy.py +0 -0
  186. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_is_disjoint.py +0 -0
  187. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_key_function.py +0 -0
  188. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_md5_keys.py +0 -0
  189. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_sequential_keys.py +0 -0
  190. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_uuid_keys.py +0 -0
  191. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/__init__.py +0 -0
  192. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_apply_statistics_targets.py +0 -0
  193. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_derivations.py +0 -0
  194. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_graph.py +0 -0
  195. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_invariants.py +0 -0
  196. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_keys.py +0 -0
  197. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_m2m.py +0 -0
  198. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_portable.py +0 -0
  199. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_projection.py +0 -0
  200. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/__init__.py +0 -0
  201. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_paired.py +0 -0
  202. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_paired_plan.py +0 -0
  203. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/__init__.py +0 -0
  204. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scale_protocol.py +0 -0
  205. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_projection_ceiling.py +0 -0
  206. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world.py +0 -0
  207. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_disjoint_advice.py +0 -0
  208. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_leaves_undeclared_tables_alone.py +0 -0
  209. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_over_a_session_world.py +0 -0
  210. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_over_disjoint_tables.py +0 -0
  211. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_pending_trigger_events.py +0 -0
  212. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_restores_sequences.py +0 -0
  213. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_documentation.py +0 -0
  214. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_package_layout.py +0 -0
  215. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_reference_documents_the_public_surface.py +0 -0
  216. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_version.py +0 -0
  217. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/testapp/__init__.py +0 -0
  218. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/types/__init__.py +0 -0
  219. {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/types/test_children_per_parent.py +0 -0
@@ -56,7 +56,7 @@ jobs:
56
56
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57
57
  with:
58
58
  fetch-depth: 0
59
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
59
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
60
60
  with:
61
61
  enable-cache: true
62
62
  - name: Install
@@ -68,7 +68,7 @@ jobs:
68
68
 
69
69
  - name: Publish to PyPI
70
70
  if: steps.prepare.outputs.released == 'true'
71
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
71
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
72
72
  with:
73
73
  # Idempotent on purpose. What gates a release is the git tag, not the
74
74
  # index (see `prepare`), so re-running this job after a failure in a
@@ -20,7 +20,7 @@ jobs:
20
20
  runs-on: ubuntu-latest
21
21
  steps:
22
22
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
23
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
24
24
  with:
25
25
  enable-cache: true
26
26
  - name: Install
@@ -37,7 +37,7 @@ jobs:
37
37
  runs-on: ubuntu-latest
38
38
  steps:
39
39
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
40
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
41
41
  with:
42
42
  enable-cache: true
43
43
  - name: Install with docs group
@@ -79,7 +79,7 @@ jobs:
79
79
  PGDATABASE: django_data_shape
80
80
  steps:
81
81
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
82
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
82
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
83
83
  with:
84
84
  enable-cache: true
85
85
  # A floor is a claim about the *oldest* version that works, and a lockfile
@@ -199,7 +199,7 @@ jobs:
199
199
 
200
200
  steps:
201
201
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
202
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
202
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
203
203
  with:
204
204
  enable-cache: true
205
205
  - name: Install Python ${{ matrix.python-version }}
@@ -257,7 +257,7 @@ jobs:
257
257
  PGDATABASE: django_data_shape
258
258
  steps:
259
259
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
260
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
260
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
261
261
  with:
262
262
  enable-cache: true
263
263
  - name: Install Python 3.13
@@ -273,7 +273,7 @@ jobs:
273
273
  --cov-report=html:htmlcov
274
274
  - name: Upload coverage artifact
275
275
  if: always()
276
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
276
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
277
277
  with:
278
278
  name: coverage-postgres-${{ matrix.django-version }}
279
279
  path: |
@@ -339,7 +339,7 @@ jobs:
339
339
  fi
340
340
  - name: Download canonical coverage artifact
341
341
  if: steps.gate.outputs.skip != 'true'
342
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
342
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
343
343
  with:
344
344
  # The Postgres job is the only run that reaches every line, so it is
345
345
  # the only one whose number means anything. Pinned to one cell of it:
@@ -428,12 +428,37 @@ jobs:
428
428
 
429
429
  tests-passed:
430
430
  name: tests
431
+ # always(), never !cancelled(): a skipped job reports a skipped check, and
432
+ # branch protection counts a skipped required check as passing.
431
433
  if: always()
432
434
  needs: [lint, docs, floor, test, test-postgres]
433
435
  runs-on: ubuntu-latest
434
436
  steps:
435
- - name: Verify required jobs succeeded
437
+ # Pass on an exact `success` from every job above, rather than failing on
438
+ # a list of bad results: such a list passes whatever it does not name, and
439
+ # GitHub reports a result it does not document. A job no hosted runner
440
+ # ever picks up ("The job was not acquired by Runner of type hosted even
441
+ # after multiple attempts") shows as cancelled on its own check but
442
+ # reaches `needs` as `abandoned`, and a gate that failed only on `failure`
443
+ # and `cancelled` went green over exactly that while the jobs it guards
444
+ # never ran. A matrix reaches `needs` as one result, and one cell that did
445
+ # not succeed is enough to make it something other than `success`.
446
+ #
447
+ # None of the jobs above has an `if:`, so `skipped` can only mean one of
448
+ # them never ran, and it fails like any other result. A job that gains a
449
+ # legitimate skip gets an exception naming it, never `skipped` accepted
450
+ # from all of them.
451
+ #
452
+ # The results come in through `env` rather than being spliced into the
453
+ # script, and every one is printed, so a red gate says which job and what
454
+ # GitHub reported for it.
455
+ - name: Verify every required job succeeded
456
+ env:
457
+ NEEDS: ${{ toJSON(needs) }}
436
458
  run: |
437
- if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
438
- echo "a required job failed"; exit 1
459
+ echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
460
+ not_ok="$(echo "$NEEDS" | jq -r '[to_entries[] | select(.value.result != "success") | "\(.key)=\(.value.result)"] | join(" ")')"
461
+ if [ -n "$not_ok" ]; then
462
+ echo "::error::required jobs did not succeed: $not_ok"
463
+ exit 1
439
464
  fi
@@ -72,14 +72,14 @@ jobs:
72
72
  PGDATABASE: django_data_shape
73
73
  steps:
74
74
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
75
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
75
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
76
76
  with:
77
77
  enable-cache: false
78
78
 
79
79
  # One cell, not the matrix. The matrix answers "does this combination
80
80
  # work"; this answers "has an upstream moved under us", and the newest
81
81
  # supported Python is where a new release lands first.
82
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
82
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
83
83
  with:
84
84
  python-version: "3.14"
85
85
 
@@ -7,6 +7,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] — 2026-11-07
11
+
12
+ ### Added
13
+
14
+ - **`derive_template(template, step, *, key)` caches a world the application
15
+ finishes.** Some columns are an application's own arithmetic, such as running
16
+ totals a replay through its services computes, and no declaration can describe
17
+ them. It copies a template `template_database` (or `derive_template`) returned,
18
+ runs `step(using)` once over the copy with the alias pointed at it, runs
19
+ `ANALYZE` over the whole database -- not only the declared tables, since the
20
+ step may write to others -- and keeps the result as a template of its own, so a
21
+ session pays a clone rather than the replay. `key` is required: a callable
22
+ cannot be hashed, so the caller names the step and changes the key when the
23
+ step changes. The derived name is a digest of the parent's name and the key,
24
+ so it also moves with everything the parent's name moves with. Steps chain, a
25
+ step that raises leaves nothing behind and is raised again as `StepFailed`
26
+ naming the key, and a parent this package did not finish is refused.
27
+ `strategy=` is the copy's, as for `clone_database`.
28
+ - **`template_fixture(template)`, a session-scoped pytest fixture that makes the
29
+ test database a clone of a template**, which is how a test reaches a derived
30
+ one. `template` is a callable taking the connection alias and returning a
31
+ template's name. It requests `django_db_setup` and then replaces the test
32
+ database pytest-django made, under the same name, so xdist suffixes and
33
+ pytest-django's teardown work as before. Only a test database is replaced: in a
34
+ run whose tests touch no database the alias still names your configured one
35
+ (pytest-django 4.11 and later), and the fixture leaves it alone. The pytest page lists what replacing the
36
+ database means for tests that ran earlier and for transactional tests.
37
+ - **`StepFailed`**, raised from the step's own exception.
38
+ - **Documentation:** the statistics page has a section on a world the
39
+ application finishes, and the pytest page one on `template_fixture`.
40
+
41
+ ## [0.23.0] — 2026-10-23
42
+
43
+ ### Added
44
+
45
+ - **`FanOut(max_children=)` caps how many children any one parent gets.** A
46
+ power-law fan-out gives its busiest parent a share that the seed moves by a
47
+ factor of five to twelve, and production's tails are bounded by the business,
48
+ so calibrating a world against production needs a ceiling. The cap applies
49
+ to the integer partition: the rows a capped parent gives up are re-spread
50
+ over the uncapped ones by their weights, a childless parent still gets none,
51
+ and a declaration whose parents cannot hold every row under the cap is
52
+ refused with the arithmetic. It is part of the declaration's digest, so a
53
+ cached uncapped world is never served for a capped one, and `fan_out_sizes`
54
+ reports the capped sizes. `max_children=1` is a one-to-one: a skewed
55
+ fan-out with a childless share is accepted on a unique column under it,
56
+ where without it only `FanOut(Constant(1))` with no childless share was.
57
+ - **Documentation:** the relations page has a table of the busiest parent's
58
+ share of the rows for each `Zipf` exponent at 100, 1,000 and 10,000 parents,
59
+ drawn over twenty seeds. A test re-measures it, so it cannot drift from
60
+ what the partition draws.
61
+ - **Documentation:** the statistics page says that anything writing to a built world leaves its
62
+ statistics stale and shows the `ANALYZE` to run, and how to declare a
63
+ placeholder for a column a later step fills.
64
+
65
+ ### Changed
66
+
67
+ - **`scaled_world` is annotated `-> Generator[int]` instead of
68
+ `-> Iterator[int]`.** `ty` 0.0.86 warns that a `@contextmanager` function
69
+ annotated with `Iterator` is deprecated. Nothing changes at runtime, and a
70
+ caller's annotation of the context manager it returns is unaffected. The
71
+ protocol's docstring example and the type-level fixtures follow.
72
+
73
+ ### Fixed
74
+
75
+ - **A key named in `parents=` as a string is the parent it spells.** The keys
76
+ read back from the parent table are the primary key's own type, so on
77
+ PostgreSQL a UUID named as the string it was loaded from JSON as found its
78
+ row and was then compared unequal to it, and the refusal said the row did not
79
+ exist -- pointing at a rollback rather than at the spelling. Each named key
80
+ now goes through the primary key field's `to_python` before it is compared,
81
+ for `FanOut(parents=)` and `Paired(parents=)` alike. A value the field cannot
82
+ read as a key is refused naming the table, the field and the value, where it
83
+ used to reach the driver, including from a field whose `to_python` raises
84
+ `ValueError` or `TypeError` rather than Django's `ValidationError`. The
85
+ reading is not taken on trust: on an integer key only the integer or its
86
+ string is taken, so `1.5`, `Decimal("1.9")` or a `uuid.UUID`, which `int()`
87
+ would turn into a row nobody named, is refused, as is a `bool` on any key;
88
+ and one row named in two spellings is refused as a repeat.
89
+ - **`parents=` narrows a UUID-keyed parent on SQLite.** The keys went to the
90
+ database as raw parameters, and SQLite has no binding for a UUID, so naming a
91
+ UUID-keyed parent failed there in every spelling. They are now prepared by
92
+ the primary key field, as the ORM prepares them.
93
+ - **`Paired(parents=)` refuses a repeated key.** It had no repeat check at all,
94
+ so a partner named twice was weighed twice without a word.
95
+ - **A negative or non-finite fan-out size is refused.** Nothing checked the
96
+ sign, so `Uniform(-2, 10)` or a `Skew` with a negative key built a partition
97
+ whose sizes were negative or did not add up to the rows, and an infinite size,
98
+ or finite sizes summing past a float, gave rows to childless parents or
99
+ failed inside the arithmetic. Each is now refused naming the table and field.
100
+ - **`fan_out_sizes` raises `WorldChanged` for a parent table that was emptied
101
+ since the build.** A declared parent with no rows left was refused as
102
+ `InvalidShape` saying the parent has no rows, before the check that names a
103
+ moved world could run, so a transactional test truncating a session world --
104
+ a cause the docstring names -- was told its declaration was wrong. Any
105
+ refusal of the partition over a declared parent that no longer holds the
106
+ declared rows is now `WorldChanged`, with the refusal chained as its cause.
107
+ `WorldChanged` is not an `InvalidShape`, so a caller catching `InvalidShape`
108
+ for that case no longer catches it.
109
+
10
110
  ## [0.22.0] — 2026-10-07
11
111
 
12
112
  ### Added
@@ -1682,7 +1782,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1682
1782
  into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
1683
1783
  first.
1684
1784
 
1685
- [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.22.0...HEAD
1785
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.24.0...HEAD
1786
+ [0.24.0]: https://github.com/Artui/django-data-shape/compare/v0.23.0...v0.24.0
1787
+ [0.23.0]: https://github.com/Artui/django-data-shape/compare/v0.22.0...v0.23.0
1686
1788
  [0.22.0]: https://github.com/Artui/django-data-shape/compare/v0.21.0...v0.22.0
1687
1789
  [0.21.0]: https://github.com/Artui/django-data-shape/compare/v0.20.0...v0.21.0
1688
1790
  [0.20.0]: https://github.com/Artui/django-data-shape/compare/v0.19.0...v0.20.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-data-shape
3
- Version: 0.22.0
3
+ Version: 0.24.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
@@ -279,7 +279,12 @@ history no forward migration can fix, such as a base ahead of the migrations on
279
279
  disk, is refused.
280
280
  A shape holding a `Derived` or a `KeyFunction` is refused rather than hashed --
281
281
  there is no honest digest of a callable, and every way of guessing one agrees
282
- while the data has changed. See
282
+ while the data has changed. A world your own code finishes after the build -- a
283
+ replay that fills running totals -- is cached too:
284
+ `derive_template(template, step, key="replay-3")` runs the step once over a copy
285
+ of the template, analyzes the whole database afterwards and keeps the result as
286
+ a template of its own, under a name keyed on the parent and the key you change
287
+ when the step does. See
283
288
  [Statistics and reuse](https://artui.github.io/django-data-shape/statistics/).
284
289
 
285
290
  ## From pytest
@@ -294,7 +299,9 @@ world = scale_fixture(Shape(Table(Order, rows=100, status=Constant("complete")))
294
299
  ```
295
300
 
296
301
  `orders` is one world built once for the whole session, composed with
297
- pytest-django rather than replacing it. `world` is the **scale protocol**: make
302
+ pytest-django rather than replacing it. `template_fixture` is the other way in:
303
+ it makes the test database a clone of a template, a derived one included, for
304
+ the whole session. `world` is the **scale protocol**: make
298
305
  the world be at factor F, then let the caller run its block, which is what a
299
306
  query count asserted to be `O(1)` rather than `O(N)` needs.
300
307
 
@@ -236,7 +236,12 @@ history no forward migration can fix, such as a base ahead of the migrations on
236
236
  disk, is refused.
237
237
  A shape holding a `Derived` or a `KeyFunction` is refused rather than hashed --
238
238
  there is no honest digest of a callable, and every way of guessing one agrees
239
- while the data has changed. See
239
+ while the data has changed. A world your own code finishes after the build -- a
240
+ replay that fills running totals -- is cached too:
241
+ `derive_template(template, step, key="replay-3")` runs the step once over a copy
242
+ of the template, analyzes the whole database afterwards and keeps the result as
243
+ a template of its own, under a name keyed on the parent and the key you change
244
+ when the step does. See
240
245
  [Statistics and reuse](https://artui.github.io/django-data-shape/statistics/).
241
246
 
242
247
  ## From pytest
@@ -251,7 +256,9 @@ world = scale_fixture(Shape(Table(Order, rows=100, status=Constant("complete")))
251
256
  ```
252
257
 
253
258
  `orders` is one world built once for the whole session, composed with
254
- pytest-django rather than replacing it. `world` is the **scale protocol**: make
259
+ pytest-django rather than replacing it. `template_fixture` is the other way in:
260
+ it makes the test database a clone of a template, a derived one included, for
261
+ the whole session. `world` is the **scale protocol**: make
255
262
  the world be at factor F, then let the caller run its block, which is what a
256
263
  query count asserted to be `O(1)` rather than `O(N)` needs.
257
264
 
@@ -3,8 +3,10 @@
3
3
  from django_data_shape.backends.require_clone_strategy import require_clone_strategy
4
4
  from django_data_shape.backends.unsupported_backend import UnsupportedBackend
5
5
  from django_data_shape.databases.clone_database import clone_database
6
+ from django_data_shape.databases.derive_template import derive_template
6
7
  from django_data_shape.databases.drop_database import drop_database
7
8
  from django_data_shape.databases.shape_digest import shape_digest
9
+ from django_data_shape.databases.step_failed import StepFailed
8
10
  from django_data_shape.databases.template_database import template_database
9
11
  from django_data_shape.databases.unhashable_shape import UnhashableShape
10
12
  from django_data_shape.databases.unusable_base import UnusableBase
@@ -103,6 +105,7 @@ __all__ = [
103
105
  "Skew",
104
106
  "SqlKeys",
105
107
  "SqlValue",
108
+ "StepFailed",
106
109
  "Table",
107
110
  "TableResult",
108
111
  "UnhashableShape",
@@ -117,6 +120,7 @@ __all__ = [
117
120
  "check_constraints",
118
121
  "check_invariants",
119
122
  "clone_database",
123
+ "derive_template",
120
124
  "drop_database",
121
125
  "fan_out_sizes",
122
126
  "require_clone_strategy",
@@ -0,0 +1,231 @@
1
+ """A template made from another by running the application's own code over it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from django.db import DEFAULT_DB_ALIAS, connections
10
+ from django.db.transaction import TransactionManagementError
11
+
12
+ from django_data_shape.backends.require_clone_strategy import require_clone_strategy
13
+ from django_data_shape.backends.require_postgres import require_postgres
14
+ from django_data_shape.databases.clone_database import clone_database
15
+ from django_data_shape.databases.step_failed import StepFailed
16
+ from django_data_shape.utils import (
17
+ FINISHED_TEMPLATE,
18
+ PARTIAL_SUFFIX,
19
+ TEMPLATE_KEY_BYTES,
20
+ TEMPLATE_PREFIX,
21
+ absorb,
22
+ pointed_at,
23
+ template_lock_id,
24
+ )
25
+
26
+ # Its own format tag, so a derived name can never be the name template_database
27
+ # would give some shape: the two digests absorb different things, and the tag is
28
+ # what keeps them in different spaces.
29
+ _FORMAT = "django-data-shape derived template 1"
30
+
31
+
32
+ def derive_template(
33
+ template: str,
34
+ step: Callable[[str], object],
35
+ *,
36
+ key: str,
37
+ using: str = DEFAULT_DB_ALIAS,
38
+ strategy: str | None = "file_copy",
39
+ ) -> str:
40
+ """Make sure a template holding ``template`` with ``step`` run over it exists, and return its name.
41
+
42
+ For the world an application finishes itself. A shape describes what can be
43
+ declared; some columns are the application's own arithmetic -- running
44
+ totals a replay through its services fills in -- and no declaration can
45
+ describe them. Run that code over a fresh test database every session and
46
+ the cache pays for the cheap half only: measured on a consumer's world, the
47
+ template took 2.5 s to build and the replay over it 93 s. This puts the
48
+ replay inside the cache. The step runs once per machine, over a copy of
49
+ ``template``, and the result is a template like any other, cloned with
50
+ :func:`~django_data_shape.databases.clone_database.clone_database` in a
51
+ fraction of a second::
52
+
53
+ template = template_database(shape)
54
+ finished = derive_template(template, replay_draws, key="replay-draws-3")
55
+ clone_database(finished, "test_myapp", replace=True)
56
+
57
+ **What the step is handed.** ``step(using)`` is called with the connection
58
+ alias pointed at the copy, under its working name, the way the build itself
59
+ reaches a template: the ORM, ``connections[using]`` and anything else going
60
+ through that alias write into the copy. Its return value is ignored. It runs
61
+ outside any transaction this function opens, so what it commits is what the
62
+ template holds; a step that leaves another connection attached to the copy
63
+ stops the copy being renamed, as anything attached to a database does.
64
+
65
+ **``key`` is required, and it is the step's identity.** A callable cannot be
66
+ hashed, for the reason a shape holding a
67
+ :class:`~django_data_shape.derivations.derived.Derived` is refused: two
68
+ closures agree while the data differs, which is the one direction a cache key
69
+ must never be wrong. So the caller names the step, and **changes the key when
70
+ the step changes**. The template's name is a digest of ``template``'s name
71
+ and ``key``, and ``template``'s name is itself a digest of the shape, the
72
+ schema, the settings, the base and this package's version, so a change to any
73
+ of those makes a new template here as well, without the key having to say so.
74
+
75
+ **The cache trusts the step to be a function of the world it is given.** A
76
+ step reading the clock, drawing unseeded randomness or reading anything
77
+ outside the database produces whatever its first run produced, under a key
78
+ that claims more. That is stated rather than detected, as the rows a base
79
+ holds are for ``template_database``.
80
+
81
+ **The whole database is analyzed after the step**, not the tables the shape
82
+ declared: the step may have written to tables the shape never named, and a
83
+ table with no statistics is worse than a small one. Anything else that
84
+ writes to a world after it is built leaves its statistics stale, whether or
85
+ not it goes through this function.
86
+
87
+ **Steps chain**, because a derived template is a template: derive from one
88
+ to run a second step over the first, and changing the second step's key
89
+ re-runs only the second step. Changing ``key`` never rebuilds what is under
90
+ it, which is why this derives from a template rather than taking the step as
91
+ an argument to ``template_database``.
92
+
93
+ **A step that raises leaves nothing behind.** The copy is dropped, so the
94
+ next call starts again from ``template``, and the exception is raised again
95
+ as :class:`~django_data_shape.databases.step_failed.StepFailed`, naming the
96
+ key, with the step's own exception as its cause. A ``KeyboardInterrupt`` or
97
+ any other ``BaseException`` drops the copy and goes on unwrapped.
98
+
99
+ **What it refuses**, each before anything is created:
100
+
101
+ - **A ``template`` this package did not finish.** Its name has to be one
102
+ ``template_database`` or this function returned, a prefix and a digest,
103
+ because the derived name is keyed on it: a database of the caller's own
104
+ keeps its name while its rows change, and a template derived from it would
105
+ be served stale. A partial is refused for the same reason, and so is a
106
+ name holding a double quote, which no generated name does. ``ValueError``,
107
+ because what is wrong is an argument.
108
+ - **A ``template`` that does not exist**, when the derived one is not there
109
+ either. ``ValueError`` again, naming it. On a hit the parent is not
110
+ needed and not looked for, so a parent dropped after deriving from it
111
+ costs nothing until the derived template goes too.
112
+ - **Anything but PostgreSQL**, and **being called inside a transaction**,
113
+ for the reasons ``template_database`` gives: there is no ``CREATE
114
+ DATABASE ... TEMPLATE`` elsewhere, and pointing the connection at the copy
115
+ closes it, which leaves an atomic block unusable.
116
+ - **A ``strategy`` the server cannot take.** It means what it means for
117
+ :func:`~django_data_shape.databases.clone_database.clone_database`, which
118
+ makes the copy: ``file_copy`` by default, because a world worth deriving
119
+ is a world worth copying cheaply, and ``None`` for a PostgreSQL older than
120
+ 15. It is not part of the key, because it decides what the copy costs and
121
+ not what it holds.
122
+
123
+ Parallel workers asking for the same derived template wait on one advisory
124
+ lock, taken on its name, while one of them runs the step. The finished
125
+ template refuses connections, as every template this package makes does;
126
+ nothing removes it, and :func:`~django_data_shape.databases.drop_database.drop_database`
127
+ is how a stale one goes.
128
+ """
129
+ # Loosely typed for the reason every other backend-specific reach in this
130
+ # package is: ``_nodb_cursor`` is the wrapper's, not the base class's.
131
+ connection: Any = connections[using]
132
+ require_postgres(connection, "Deriving a template database")
133
+ if connection.in_atomic_block:
134
+ raise TransactionManagementError(
135
+ "Deriving a template database points the connection at a copy and closes it, which "
136
+ "cannot be done inside an atomic block -- the connection would be unusable for the "
137
+ "rest of it. Call this from test session setup, before anything has opened a "
138
+ "transaction."
139
+ )
140
+ # Before the name is looked at, so a strategy this server cannot take is
141
+ # refused on a hit as well as on a miss
142
+ # (test_a_strategy_it_cannot_take_is_refused_before_anything_is_looked_up).
143
+ require_clone_strategy(connection, strategy)
144
+ if not FINISHED_TEMPLATE.fullmatch(template):
145
+ raise ValueError(
146
+ f"{template!r} is not a template this package finished, so nothing can be derived "
147
+ "from it. A derived template is named after its parent's name and the key, which "
148
+ "keeps up with the data only when the parent's name is itself a digest of "
149
+ "everything under it, as the names template_database and derive_template return "
150
+ "are: a database of your own keeps its name while its rows change, and a template "
151
+ "derived from it would be served stale. Pass a name one of those two returned."
152
+ )
153
+ name = f"{TEMPLATE_PREFIX}{_key(template, key)}"
154
+ quote = connection.ops.quote_name
155
+ with connection._nodb_cursor() as cursor:
156
+ # Held for the whole check-then-create, for the reason template_database
157
+ # holds its own: two workers that both read "not there" would both run
158
+ # the step, and the second would fail on a name the first had taken.
159
+ cursor.execute("SELECT pg_advisory_lock(%s)", [template_lock_id(name)])
160
+ try:
161
+ cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", [name])
162
+ if cursor.fetchone() is None:
163
+ _create(connection, cursor, template, step, key, name, using, quote, strategy)
164
+ finally:
165
+ cursor.execute("SELECT pg_advisory_unlock(%s)", [template_lock_id(name)])
166
+ return name
167
+
168
+
169
+ def _create(
170
+ connection: Any,
171
+ cursor: Any,
172
+ template: str,
173
+ step: Callable[[str], object],
174
+ key: str,
175
+ name: str,
176
+ using: str,
177
+ quote: Any,
178
+ strategy: str | None,
179
+ ) -> None:
180
+ """Copy the parent under a working name, run the step over it, analyze, then adopt it.
181
+
182
+ The rename is the commit, as it is for a built template: the derived name
183
+ exists only once the step and the ``ANALYZE`` after it have finished, so a
184
+ copy interrupted halfway is never mistaken for a finished one, and one a
185
+ killed process left behind is dropped first.
186
+ """
187
+ partial = f"{name}{PARTIAL_SUFFIX}"
188
+ cursor.execute(f"DROP DATABASE IF EXISTS {quote(partial)}")
189
+ # Looked up here rather than left to CREATE DATABASE, whose own refusal
190
+ # names neither this function nor what to do about it
191
+ # (test_a_parent_that_does_not_exist_is_refused_by_name).
192
+ cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", [template])
193
+ if cursor.fetchone() is None:
194
+ raise ValueError(
195
+ f"The template {template!r} does not exist, so nothing can be derived from it. Make "
196
+ "it again with template_database, or with derive_template if it was derived, and "
197
+ "derive from the name that returns."
198
+ )
199
+ clone_database(template, partial, using=using, strategy=strategy)
200
+ try:
201
+ with pointed_at(connection, partial):
202
+ step(using)
203
+ # The whole database rather than the tables the shape declared,
204
+ # because the step may have written to tables the shape never named
205
+ # (test_the_statistics_describe_the_world_after_the_step).
206
+ with connection.cursor() as analyzing:
207
+ analyzing.execute("ANALYZE")
208
+ except Exception as failed:
209
+ cursor.execute(f"DROP DATABASE IF EXISTS {quote(partial)}")
210
+ raise StepFailed(
211
+ f"The step deriving a template from {template!r} under key {key!r} raised "
212
+ f"{type(failed).__name__}: {failed}. Nothing was kept: the copy it ran in was "
213
+ "dropped, so the next call runs the step again from the same template."
214
+ ) from failed
215
+ except BaseException:
216
+ # Not a failure of the step, so not wrapped; the copy goes all the same,
217
+ # because a partial is never a finished template
218
+ # (test_an_interrupt_is_not_wrapped_and_leaves_nothing).
219
+ cursor.execute(f"DROP DATABASE IF EXISTS {quote(partial)}")
220
+ raise
221
+ cursor.execute(f"ALTER DATABASE {quote(partial)} RENAME TO {quote(name)}")
222
+ cursor.execute(f"ALTER DATABASE {quote(name)} WITH ALLOW_CONNECTIONS false")
223
+
224
+
225
+ def _key(template: str, key: str) -> str:
226
+ """The parent's name and the caller's key, digested to the length a template name takes."""
227
+ hasher = hashlib.blake2b(digest_size=TEMPLATE_KEY_BYTES)
228
+ absorb(hasher, _FORMAT)
229
+ absorb(hasher, template)
230
+ absorb(hasher, key)
231
+ return hasher.hexdigest()
@@ -0,0 +1,24 @@
1
+ """Raised when the step a derived template is made with fails."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class StepFailed(Exception):
7
+ """The step given to ``derive_template`` raised, and nothing was kept.
8
+
9
+ Raised from the step's own exception, which is its ``__cause__``, so the
10
+ traceback shows both: what went wrong inside the step, and which template and
11
+ key it was deriving. The key is the part worth naming, because it is the only
12
+ thing that tells one step from another -- a callable has no name a cache can
13
+ trust, which is why ``derive_template`` asks for a key at all.
14
+
15
+ Its own type rather than the step's exception passed through, because the
16
+ step's exception says what failed and not where: a ``KeyError`` from a
17
+ replay deep inside a session fixture reads the same as one from the test.
18
+ Only an ``Exception`` is wrapped. A ``KeyboardInterrupt`` or ``SystemExit``
19
+ is not a failure of the step, and goes on as it came.
20
+
21
+ Nothing is left behind either way: the partial copy the step ran in is
22
+ dropped, so the next call starts again from the parent template and runs the
23
+ step again.
24
+ """