django-data-shape 0.22.0__tar.gz → 0.23.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 (213) hide show
  1. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.github/workflows/release.yml +1 -1
  2. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.github/workflows/tests.yml +35 -10
  3. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.github/workflows/upstream-drift.yml +1 -1
  4. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/CHANGELOG.md +71 -1
  5. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/PKG-INFO +1 -1
  6. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/check_constraints.py +18 -8
  7. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/table.py +2 -2
  8. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/fan_out.py +44 -3
  9. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/fan_out_sizes.py +41 -21
  10. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/resolve_fan_out.py +126 -11
  11. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/resolve_paired.py +27 -15
  12. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/scaling/scale_protocol.py +1 -1
  13. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/scaling/scaled_world.py +2 -2
  14. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/utils.py +85 -0
  15. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/version.py +1 -1
  16. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/pytest.md +4 -1
  17. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/relations.md +88 -8
  18. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/statistics.md +24 -0
  19. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/pyproject.toml +1 -1
  20. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_check_constraints.py +51 -0
  21. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build.py +78 -1
  22. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_fan_out.py +36 -2
  23. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_fan_out_sizes.py +124 -0
  24. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_paired_parents.py +38 -0
  25. django_data_shape-0.23.0/tests/relations/test_parents_key_spelling.py +177 -0
  26. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_resolve_fan_out.py +222 -2
  27. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_resolve_paired.py +3 -0
  28. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/scale_protocol_consumers.py +3 -3
  29. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/scale_protocol_impostors.py +2 -2
  30. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_shape.py +4 -1
  31. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/test_utils.py +86 -1
  32. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/testapp/models.py +26 -0
  33. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/uv.lock +50 -74
  34. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.github/CODEOWNERS +0 -0
  35. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.github/SECURITY.md +0 -0
  36. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.github/dependabot.yml +0 -0
  37. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.gitignore +0 -0
  38. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/.pre-commit-config.yaml +0 -0
  39. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/CLAUDE.md +0 -0
  40. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/LICENSE +0 -0
  41. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/Makefile +0 -0
  42. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/README.md +0 -0
  43. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/__init__.py +0 -0
  44. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/backends/__init__.py +0 -0
  45. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/backends/require_clone_strategy.py +0 -0
  46. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/backends/require_postgres.py +0 -0
  47. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/backends/unsupported_backend.py +0 -0
  48. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/__init__.py +0 -0
  49. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/clone_database.py +0 -0
  50. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/drop_database.py +0 -0
  51. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/require_quotable_name.py +0 -0
  52. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/shape_digest.py +0 -0
  53. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/template_database.py +0 -0
  54. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/unhashable_shape.py +0 -0
  55. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/databases/unusable_base.py +0 -0
  56. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/__init__.py +0 -0
  57. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/invalid_shape.py +0 -0
  58. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/order_derivations.py +0 -0
  59. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/order_tables.py +0 -0
  60. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/projection.py +0 -0
  61. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/shape.py +0 -0
  62. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/shape_from_factory.py +0 -0
  63. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/declaration/sql_value.py +0 -0
  64. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/__init__.py +0 -0
  65. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/after.py +0 -0
  66. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/aligned.py +0 -0
  67. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/copied.py +0 -0
  68. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/derivation.py +0 -0
  69. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/derived.py +0 -0
  70. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/given.py +0 -0
  71. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/offset.py +0 -0
  72. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/per_parent.py +0 -0
  73. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/product.py +0 -0
  74. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/derivations/scope.py +0 -0
  75. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/__init__.py +0 -0
  76. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/ascending.py +0 -0
  77. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/bounded.py +0 -0
  78. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/categorical.py +0 -0
  79. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/constant.py +0 -0
  80. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/distinct.py +0 -0
  81. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/distribution.py +0 -0
  82. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/sequential.py +0 -0
  83. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/skew.py +0 -0
  84. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/uniform.py +0 -0
  85. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/distributions/zipf.py +0 -0
  86. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/fixtures/__init__.py +0 -0
  87. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
  88. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
  89. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
  90. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/generation/__init__.py +0 -0
  91. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/generation/derivation_queried_database.py +0 -0
  92. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/generation/generate_rows.py +0 -0
  93. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/generation/refuse_queries.py +0 -0
  94. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/invariants/__init__.py +0 -0
  95. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/invariants/check_invariants.py +0 -0
  96. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/invariants/invariant.py +0 -0
  97. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/invariants/invariant_violated.py +0 -0
  98. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/__init__.py +0 -0
  99. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/disjoint.py +0 -0
  100. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/infer_key_strategy.py +0 -0
  101. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/is_disjoint.py +0 -0
  102. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/key_function.py +0 -0
  103. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/key_strategy.py +0 -0
  104. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/md5_keys.py +0 -0
  105. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/sequential_keys.py +0 -0
  106. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/sql_keys.py +0 -0
  107. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/keys/uuid_keys.py +0 -0
  108. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/loading/__init__.py +0 -0
  109. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/loading/apply_statistics_targets.py +0 -0
  110. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/loading/build.py +0 -0
  111. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/loading/shape_not_empty.py +0 -0
  112. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/py.typed +0 -0
  113. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/__init__.py +0 -0
  114. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/fan_out_plan.py +0 -0
  115. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/paired.py +0 -0
  116. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/paired_plan.py +0 -0
  117. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/relations/world_changed.py +0 -0
  118. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/scaling/__init__.py +0 -0
  119. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/scaling/scaled_shape.py +0 -0
  120. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/scaling/shape_referenced.py +0 -0
  121. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/types/__init__.py +0 -0
  122. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/types/build_result.py +0 -0
  123. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/types/canonical.py +0 -0
  124. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/types/children_per_parent.py +0 -0
  125. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/django_data_shape/types/table_result.py +0 -0
  126. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/derivations.md +0 -0
  127. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/index.md +0 -0
  128. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/invariants.md +0 -0
  129. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/keys.md +0 -0
  130. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/projections.md +0 -0
  131. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/docs/reference.md +0 -0
  132. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/mkdocs.yml +0 -0
  133. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/scripts/check-changelog +0 -0
  134. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/scripts/release-publish.sh +0 -0
  135. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/__init__.py +0 -0
  136. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/backends/__init__.py +0 -0
  137. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/backends/test_require_clone_strategy.py +0 -0
  138. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/backends/test_require_postgres.py +0 -0
  139. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/conftest.py +0 -0
  140. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/conftest_settings.py +0 -0
  141. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/databases/__init__.py +0 -0
  142. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/databases/test_require_quotable_name.py +0 -0
  143. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/databases/test_shape_digest.py +0 -0
  144. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/databases/test_template_database.py +0 -0
  145. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/__init__.py +0 -0
  146. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_order_derivations.py +0 -0
  147. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_order_tables.py +0 -0
  148. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_projection.py +0 -0
  149. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_projection_ceiling.py +0 -0
  150. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_projection_sql_percent.py +0 -0
  151. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_projection_values.py +0 -0
  152. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_projection_values_references.py +0 -0
  153. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_projection_values_uuid.py +0 -0
  154. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_shape.py +0 -0
  155. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_shape_from_factory.py +0 -0
  156. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/declaration/test_table.py +0 -0
  157. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/__init__.py +0 -0
  158. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_after.py +0 -0
  159. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_after_units.py +0 -0
  160. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_aligned.py +0 -0
  161. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
  162. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_derived.py +0 -0
  163. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_given.py +0 -0
  164. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/derivations/test_per_parent.py +0 -0
  165. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/distributions/__init__.py +0 -0
  166. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/distributions/test_constant.py +0 -0
  167. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/distributions/test_sequential.py +0 -0
  168. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/distributions/test_skew.py +0 -0
  169. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/distributions/test_uniform.py +0 -0
  170. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/distributions/test_zipf.py +0 -0
  171. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/fixtures/__init__.py +0 -0
  172. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/fixtures/test_fixtures.py +0 -0
  173. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/generation/__init__.py +0 -0
  174. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/generation/test_generate_rows.py +0 -0
  175. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/generation/test_refuse_queries.py +0 -0
  176. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/invariants/__init__.py +0 -0
  177. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/invariants/test_invariant.py +0 -0
  178. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/__init__.py +0 -0
  179. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/test_infer_key_strategy.py +0 -0
  180. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/test_is_disjoint.py +0 -0
  181. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/test_key_function.py +0 -0
  182. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/test_md5_keys.py +0 -0
  183. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/test_sequential_keys.py +0 -0
  184. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/keys/test_uuid_keys.py +0 -0
  185. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/__init__.py +0 -0
  186. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_apply_statistics_targets.py +0 -0
  187. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_derivations.py +0 -0
  188. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_graph.py +0 -0
  189. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_invariants.py +0 -0
  190. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_keys.py +0 -0
  191. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_m2m.py +0 -0
  192. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_portable.py +0 -0
  193. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/loading/test_build_projection.py +0 -0
  194. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/__init__.py +0 -0
  195. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_paired.py +0 -0
  196. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/relations/test_paired_plan.py +0 -0
  197. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/__init__.py +0 -0
  198. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scale_protocol.py +0 -0
  199. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_projection_ceiling.py +0 -0
  200. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world.py +0 -0
  201. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world_disjoint_advice.py +0 -0
  202. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world_leaves_undeclared_tables_alone.py +0 -0
  203. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world_over_a_session_world.py +0 -0
  204. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world_over_disjoint_tables.py +0 -0
  205. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world_pending_trigger_events.py +0 -0
  206. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/scaling/test_scaled_world_restores_sequences.py +0 -0
  207. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/test_documentation.py +0 -0
  208. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/test_package_layout.py +0 -0
  209. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/test_reference_documents_the_public_surface.py +0 -0
  210. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/test_version.py +0 -0
  211. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/testapp/__init__.py +0 -0
  212. {django_data_shape-0.22.0 → django_data_shape-0.23.0}/tests/types/__init__.py +0 -0
  213. {django_data_shape-0.22.0 → django_data_shape-0.23.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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
60
60
  with:
61
61
  enable-cache: true
62
62
  - name: Install
@@ -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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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,7 +72,7 @@ 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@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
76
76
  with:
77
77
  enable-cache: false
78
78
 
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.23.0] — 2026-10-23
11
+
12
+ ### Added
13
+
14
+ - **`FanOut(max_children=)` caps how many children any one parent gets.** A
15
+ power-law fan-out gives its busiest parent a share that the seed moves by a
16
+ factor of five to twelve, and production's tails are bounded by the business,
17
+ so calibrating a world against production needs a ceiling. The cap applies
18
+ to the integer partition: the rows a capped parent gives up are re-spread
19
+ over the uncapped ones by their weights, a childless parent still gets none,
20
+ and a declaration whose parents cannot hold every row under the cap is
21
+ refused with the arithmetic. It is part of the declaration's digest, so a
22
+ cached uncapped world is never served for a capped one, and `fan_out_sizes`
23
+ reports the capped sizes. `max_children=1` is a one-to-one: a skewed
24
+ fan-out with a childless share is accepted on a unique column under it,
25
+ where without it only `FanOut(Constant(1))` with no childless share was.
26
+ - **Documentation:** the relations page has a table of the busiest parent's
27
+ share of the rows for each `Zipf` exponent at 100, 1,000 and 10,000 parents,
28
+ drawn over twenty seeds. A test re-measures it, so it cannot drift from
29
+ what the partition draws.
30
+ - **Documentation:** the statistics page says that anything writing to a built world leaves its
31
+ statistics stale and shows the `ANALYZE` to run, and how to declare a
32
+ placeholder for a column a later step fills.
33
+
34
+ ### Changed
35
+
36
+ - **`scaled_world` is annotated `-> Generator[int]` instead of
37
+ `-> Iterator[int]`.** `ty` 0.0.86 warns that a `@contextmanager` function
38
+ annotated with `Iterator` is deprecated. Nothing changes at runtime, and a
39
+ caller's annotation of the context manager it returns is unaffected. The
40
+ protocol's docstring example and the type-level fixtures follow.
41
+
42
+ ### Fixed
43
+
44
+ - **A key named in `parents=` as a string is the parent it spells.** The keys
45
+ read back from the parent table are the primary key's own type, so on
46
+ PostgreSQL a UUID named as the string it was loaded from JSON as found its
47
+ row and was then compared unequal to it, and the refusal said the row did not
48
+ exist -- pointing at a rollback rather than at the spelling. Each named key
49
+ now goes through the primary key field's `to_python` before it is compared,
50
+ for `FanOut(parents=)` and `Paired(parents=)` alike. A value the field cannot
51
+ read as a key is refused naming the table, the field and the value, where it
52
+ used to reach the driver, including from a field whose `to_python` raises
53
+ `ValueError` or `TypeError` rather than Django's `ValidationError`. The
54
+ reading is not taken on trust: on an integer key only the integer or its
55
+ string is taken, so `1.5`, `Decimal("1.9")` or a `uuid.UUID`, which `int()`
56
+ would turn into a row nobody named, is refused, as is a `bool` on any key;
57
+ and one row named in two spellings is refused as a repeat.
58
+ - **`parents=` narrows a UUID-keyed parent on SQLite.** The keys went to the
59
+ database as raw parameters, and SQLite has no binding for a UUID, so naming a
60
+ UUID-keyed parent failed there in every spelling. They are now prepared by
61
+ the primary key field, as the ORM prepares them.
62
+ - **`Paired(parents=)` refuses a repeated key.** It had no repeat check at all,
63
+ so a partner named twice was weighed twice without a word.
64
+ - **A negative or non-finite fan-out size is refused.** Nothing checked the
65
+ sign, so `Uniform(-2, 10)` or a `Skew` with a negative key built a partition
66
+ whose sizes were negative or did not add up to the rows, and an infinite size,
67
+ or finite sizes summing past a float, gave rows to childless parents or
68
+ failed inside the arithmetic. Each is now refused naming the table and field.
69
+ - **`fan_out_sizes` raises `WorldChanged` for a parent table that was emptied
70
+ since the build.** A declared parent with no rows left was refused as
71
+ `InvalidShape` saying the parent has no rows, before the check that names a
72
+ moved world could run, so a transactional test truncating a session world --
73
+ a cause the docstring names -- was told its declaration was wrong. Any
74
+ refusal of the partition over a declared parent that no longer holds the
75
+ declared rows is now `WorldChanged`, with the refusal chained as its cause.
76
+ `WorldChanged` is not an `InvalidShape`, so a caller catching `InvalidShape`
77
+ for that case no longer catches it.
78
+
10
79
  ## [0.22.0] — 2026-10-07
11
80
 
12
81
  ### Added
@@ -1682,7 +1751,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1682
1751
  into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
1683
1752
  first.
1684
1753
 
1685
- [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.22.0...HEAD
1754
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.23.0...HEAD
1755
+ [0.23.0]: https://github.com/Artui/django-data-shape/compare/v0.22.0...v0.23.0
1686
1756
  [0.22.0]: https://github.com/Artui/django-data-shape/compare/v0.21.0...v0.22.0
1687
1757
  [0.21.0]: https://github.com/Artui/django-data-shape/compare/v0.20.0...v0.21.0
1688
1758
  [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.23.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
@@ -243,8 +243,8 @@ def _check_a_fan_out_beside_a_draw(
243
243
  :class:`~django_data_shape.distributions.sequential.Sequential` beside a
244
244
  fan-out keeps the constraint in every row with nothing arranged around it. A
245
245
  partition that gives no parent two rows is the second -- there is then no
246
- group for two draws to collide inside -- and :func:`_cannot_collide` is the
247
- four conditions that establish it. Neither exemption is "this usually works":
246
+ group for two draws to collide inside -- and :func:`_cannot_collide` is what
247
+ establishes it, by a cap of one or by four conditions together. Neither exemption is "this usually works":
248
248
  a shape that merely usually works is the case this refusal is for, and the
249
249
  measured ones sit at ten and eleven times out of twenty.
250
250
 
@@ -330,9 +330,10 @@ def _check_a_fan_out_on_a_unique_column(table: Table, rows_of: dict[type[Model],
330
330
  "table's rows over the parent's keys by weight, so a skew putting two rows on one "
331
331
  "parent is what it was asked for -- and the unique index then refuses the second, "
332
332
  "inside COPY, at a row number that moves when the seed does. A one-to-one is filled "
333
- "by a fan-out whose sizes give every parent the same weight and whose rows do not "
334
- "outnumber the parents: FanOut(Constant(1)) over at least as many parents as this "
335
- "table has rows. childless= is how a parent with no row at all is asked for."
333
+ "by max_children=1, which caps every parent at one row whatever the sizes weigh and "
334
+ "leaves childless= free to ask for parents with no row at all; or by "
335
+ "FanOut(Constant(1)) with no childless share over at least as many parents as this "
336
+ "table has rows."
336
337
  )
337
338
 
338
339
 
@@ -409,7 +410,13 @@ def _cannot_collide(table: Table, column: str, rows_of: dict[type[Model], int])
409
410
  partition in which no group holds two rows cannot produce one, whatever the
410
411
  other column draws and whatever the seed.
411
412
 
412
- Four conditions, and all four are needed. The sizes have to be provably flat
413
+ The first way is a cap of one. ``max_children=1`` bounds the partition
414
+ itself, so no parent holds two rows whatever the sizes weigh, whatever the
415
+ childless share, and wherever the parents come from; a declaration whose
416
+ rows outnumber the parents that can hold them is refused when the partition
417
+ is drawn, naming the arithmetic, before a row reaches ``COPY``.
418
+
419
+ Without it, four conditions, and all four are needed. The sizes have to be provably flat
413
420
  -- ``Bounded`` with exactly one distinct value, so every parent is weighed
414
421
  the same -- because ``_sizes`` divides ``rows`` by a total and hands the
415
422
  remainder to the parents with the largest fractional part: flat weights make
@@ -439,7 +446,7 @@ def _cannot_collide(table: Table, column: str, rows_of: dict[type[Model], int])
439
446
 
440
447
  .. warning::
441
448
 
442
- **The coverage gate cannot see these four conditions, and the tests are
449
+ **The coverage gate cannot see these five conditions, and the tests are
443
450
  what hold them.** They are one boolean expression, so they are one branch
444
451
  arc: drop any single conjunct and the suite still reports 100% line and
445
452
  branch, because the surviving expression is still taken both ways. The
@@ -447,6 +454,9 @@ def _cannot_collide(table: Table, column: str, rows_of: dict[type[Model], int])
447
454
  against a tree with that condition removed. If you edit this expression,
448
455
  edit those with it -- they are, in order:
449
456
 
457
+ - ``max_children == 1`` --
458
+ ``test_a_cap_of_one_is_a_proof_whatever_the_sizes``, beside the
459
+ ``test_a_cap_of_two_is_not`` that keeps the comparison from widening.
450
460
  - ``Bounded`` with one value -- ``test_sizes_that_are_not_provably_flat_are_not_exempt``,
451
461
  which uses ``Uniform(1, 10)`` sizes because they give a largest group of
452
462
  two where ``Constant(1)`` gives one, at identical numbers.
@@ -460,7 +470,7 @@ def _cannot_collide(table: Table, column: str, rows_of: dict[type[Model], int])
460
470
  """
461
471
  fan_out = cast("FanOut", table.fields[column])
462
472
  parent_rows = _parents_available(table, column, fan_out, rows_of)
463
- return (
473
+ return fan_out.max_children == 1 or (
464
474
  isinstance(fan_out.sizes, Bounded)
465
475
  and fan_out.sizes.distinct_values() == 1
466
476
  and not fan_out.childless
@@ -692,8 +692,8 @@ class Table:
692
692
  f"{because} A relation is declared as "
693
693
  f"a fan-out over the parent's real keys -- {required_relations[0]}=FanOut(Zipf()) "
694
694
  "for the realistic heavy tail, FanOut(Uniform(1, 10)) for something flatter, and "
695
- "FanOut(Constant(1)) over at least as many parents as this table has rows for a "
696
- "one-to-one. childless= is the share of parents nobody references. The parent's "
695
+ "FanOut(Zipf(), max_children=1) for a one-to-one. childless= is the share of "
696
+ "parents nobody references. The parent's "
697
697
  "own table has to be built in the same shape or already loaded, because that is "
698
698
  "where the keys are read from."
699
699
  )
@@ -48,6 +48,23 @@ class FanOut:
48
48
  cosmetic. Emitting them parent by parent gives a perfectly clustered table
49
49
  that no production system has and that flatters every index scan; the
50
50
  default interleaves them the way rows really arrive.
51
+
52
+ ``max_children`` is a ceiling on any one parent's share. **It lives here and
53
+ not on the size distribution** because the weights are normalised: capping
54
+ a ``Zipf`` weight caps nothing, since the realised count is a share of
55
+ ``rows`` that the other weights decide. The cap applies to the integer
56
+ partition, and the rows a capped parent gives up are re-spread over the
57
+ others by their weights; a childless parent still gets none. It exists for
58
+ calibrating against production, whose tails the business bounds. **A cap
59
+ flattens the head into the body**, which the planner sees, so a capped
60
+ fan-out is a different world from the uncapped one rather than a free
61
+ improvement on it -- and choosing a ``Zipf`` exponent that already gives
62
+ production's maximum is often the better fix.
63
+
64
+ The cap counts every row the partition places, the ones ``null`` then
65
+ leaves without a parent included, so a parent at the cap can end with fewer
66
+ non-null children but never more. A cap of one is a one-to-one, whatever
67
+ the sizes weigh.
51
68
  """
52
69
 
53
70
  def __init__(
@@ -58,6 +75,7 @@ class FanOut:
58
75
  childless: float = 0.0,
59
76
  null: float = 0.0,
60
77
  placement: str = "arrival",
78
+ max_children: int | None = None,
61
79
  ) -> None:
62
80
  for name, share in (("childless", childless), ("null", null)):
63
81
  if not 0.0 <= share < 1.0:
@@ -68,6 +86,15 @@ class FanOut:
68
86
  raise InvalidShape(
69
87
  f"FanOut placement must be one of {', '.join(PLACEMENTS)}, got {placement!r}."
70
88
  )
89
+ # bool is an int, and a cap of one spelled True has almost certainly
90
+ # been written for a different argument.
91
+ if max_children is not None and (
92
+ isinstance(max_children, bool) or not isinstance(max_children, int) or max_children < 1
93
+ ):
94
+ raise InvalidShape(
95
+ f"FanOut max_children is the most children any one parent may have, so it must "
96
+ f"be a whole number of at least 1, got {max_children!r}."
97
+ )
71
98
  narrowed = None if parents is None else tuple(parents)
72
99
  if narrowed is not None:
73
100
  # An empty sequence is falsy, and reading it as "not declared" would
@@ -97,6 +124,7 @@ class FanOut:
97
124
  self._childless = childless
98
125
  self._null = null
99
126
  self._placement = placement
127
+ self._max_children = max_children
100
128
 
101
129
  @property
102
130
  def sizes(self) -> Distribution:
@@ -118,6 +146,10 @@ class FanOut:
118
146
  def placement(self) -> str:
119
147
  return self._placement
120
148
 
149
+ @property
150
+ def max_children(self) -> int | None:
151
+ return self._max_children
152
+
121
153
  def canonical(self) -> object:
122
154
  """The size distribution, the parents it spreads over, and the shares. See ``Canonical``.
123
155
 
@@ -125,13 +157,22 @@ class FanOut:
125
157
  afterwards: two shapes differing only in which parents they cover are
126
158
  two different worlds, and the template-database cache keys on this. A
127
159
  clone built for one tenant handed to a test asking for another would be
128
- the cache serving a world nobody declared.
160
+ the cache serving a world nobody declared. ``max_children`` is here for
161
+ the same reason.
129
162
  """
130
- return (self._sizes, self._parents, self._childless, self._null, self._placement)
163
+ return (
164
+ self._sizes,
165
+ self._parents,
166
+ self._childless,
167
+ self._null,
168
+ self._placement,
169
+ self._max_children,
170
+ )
131
171
 
132
172
  def __repr__(self) -> str:
133
173
  narrowed = "" if self._parents is None else f"parents={self._parents!r}, "
174
+ capped = "" if self._max_children is None else f", max_children={self._max_children!r}"
134
175
  return (
135
176
  f"FanOut({self._sizes!r}, {narrowed}childless={self._childless!r}, "
136
- f"null={self._null!r}, placement={self._placement!r})"
177
+ f"null={self._null!r}, placement={self._placement!r}{capped})"
137
178
  )
@@ -104,34 +104,54 @@ def fan_out_sizes(
104
104
  relation = dict(declaration.relations())[field]
105
105
  parent = cast("type[Model]", cast("Field[object, object]", relation).related_model)
106
106
 
107
- plan = resolve_fan_out(
108
- fan_out,
109
- parent,
110
- declaration.rows,
111
- shape.seed,
112
- declaration.db_table,
113
- field,
114
- connections[using],
115
- )
116
- keys = plan.parent_keys()
117
-
118
107
  # A narrowed fan-out covers the keys it named and no others, so the parent
119
108
  # table holding more rows than the partition covers is the declaration
120
109
  # working rather than the world moving. The failure WorldChanged exists for
121
110
  # is still caught, and caught earlier: a named key that is no longer a row is
122
- # refused by name when the partition is resolved, above.
111
+ # refused by name when the partition is resolved, below.
123
112
  parent_declaration = (
124
113
  None if fan_out.parents is not None else declared.get(parent._meta.db_table)
125
114
  )
126
- if isinstance(parent_declaration, Table) and len(keys) != parent_declaration.rows:
127
- raise WorldChanged(
128
- f"{parent.__name__} holds {len(keys)} rows and this shape declares "
129
- f"{parent_declaration.rows}, so the fan-out for {model.__name__}.{field} would be "
130
- "spread over parents the children were never spread across. The counts are derived "
131
- "from the parent keys rather than counted off the child table, so an answer here "
132
- "would describe a world that was never built. The usual causes are a test that "
133
- "created more parents before asking, and a transactional test that truncated the "
134
- "tables a session-scoped fixture built."
115
+ try:
116
+ plan = resolve_fan_out(
117
+ fan_out,
118
+ parent,
119
+ declaration.rows,
120
+ shape.seed,
121
+ declaration.db_table,
122
+ field,
123
+ connections[using],
135
124
  )
125
+ except InvalidShape as refused:
126
+ # A partition the build drew can still be refused here, over parents
127
+ # that have since gone: fewer of them can no longer hold the rows under
128
+ # max_children, or none of them is left. Blaming the declaration then
129
+ # sends the reader to change a shape that built, so a parent table that
130
+ # no longer holds what the shape declares is named as the cause.
131
+ if isinstance(parent_declaration, Table):
132
+ holds = parent._base_manager.using(using).count()
133
+ if holds != parent_declaration.rows:
134
+ raise _world_changed(
135
+ parent, holds, parent_declaration.rows, model, field
136
+ ) from refused
137
+ raise
138
+ keys = plan.parent_keys()
139
+
140
+ if isinstance(parent_declaration, Table) and len(keys) != parent_declaration.rows:
141
+ raise _world_changed(parent, len(keys), parent_declaration.rows, model, field)
136
142
 
137
143
  return ChildrenPerParent(keys, plan.sizes(), fan_out.null)
144
+
145
+
146
+ def _world_changed(
147
+ parent: type[Model], holds: int, declares: int, model: type[Model], field: str
148
+ ) -> WorldChanged:
149
+ return WorldChanged(
150
+ f"{parent.__name__} holds {holds} rows and this shape declares "
151
+ f"{declares}, so the fan-out for {model.__name__}.{field} would be "
152
+ "spread over parents the children were never spread across. The counts are derived "
153
+ "from the parent keys rather than counted off the child table, so an answer here "
154
+ "would describe a world that was never built. The usual causes are a test that "
155
+ "created more parents before asking, and a transactional test that truncated the "
156
+ "tables a session-scoped fixture built."
157
+ )