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.
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/workflows/release.yml +2 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/workflows/tests.yml +35 -10
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/workflows/upstream-drift.yml +2 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/CHANGELOG.md +103 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/PKG-INFO +10 -3
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/README.md +9 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/__init__.py +4 -0
- django_data_shape-0.24.0/django_data_shape/databases/derive_template.py +231 -0
- django_data_shape-0.24.0/django_data_shape/databases/step_failed.py +24 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/template_database.py +33 -86
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/check_constraints.py +18 -8
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/table.py +2 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/__init__.py +2 -0
- django_data_shape-0.24.0/django_data_shape/fixtures/template_fixture.py +157 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out.py +44 -3
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out_sizes.py +41 -21
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/resolve_fan_out.py +126 -11
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/resolve_paired.py +27 -15
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scale_protocol.py +1 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scaled_world.py +2 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/utils.py +160 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/version.py +1 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/pytest.md +92 -7
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/reference.md +6 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/relations.md +88 -8
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/statistics.md +78 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/pyproject.toml +1 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/conftest_settings.py +31 -0
- django_data_shape-0.24.0/tests/databases/test_derive_template.py +368 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/test_template_database.py +11 -8
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_check_constraints.py +51 -0
- django_data_shape-0.24.0/tests/fixtures/test_template_fixture.py +119 -0
- django_data_shape-0.24.0/tests/fixtures/test_template_fixture_leaves_other_databases.py +89 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build.py +78 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_fan_out.py +36 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_fan_out_sizes.py +124 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_paired_parents.py +38 -0
- django_data_shape-0.24.0/tests/relations/test_parents_key_spelling.py +177 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_resolve_fan_out.py +222 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_resolve_paired.py +3 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/scale_protocol_consumers.py +3 -3
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/scale_protocol_impostors.py +2 -2
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_shape.py +4 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_utils.py +86 -1
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/testapp/models.py +26 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/uv.lock +64 -87
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/CODEOWNERS +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/SECURITY.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.github/dependabot.yml +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.gitignore +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/.pre-commit-config.yaml +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/CLAUDE.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/LICENSE +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/Makefile +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/require_clone_strategy.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/require_postgres.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/backends/unsupported_backend.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/clone_database.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/drop_database.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/require_quotable_name.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/shape_digest.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/unhashable_shape.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/databases/unusable_base.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/invalid_shape.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/order_derivations.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/order_tables.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/projection.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/shape.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/shape_from_factory.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/declaration/sql_value.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/after.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/aligned.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/copied.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/derivation.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/derived.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/given.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/offset.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/per_parent.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/product.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/derivations/scope.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/ascending.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/bounded.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/categorical.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/constant.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/distinct.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/distribution.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/sequential.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/skew.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/uniform.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/distributions/zipf.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/derivation_queried_database.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/generate_rows.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/generation/refuse_queries.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/check_invariants.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/invariant.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/invariants/invariant_violated.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/disjoint.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/infer_key_strategy.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/is_disjoint.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/key_function.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/key_strategy.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/md5_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/sequential_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/sql_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/keys/uuid_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/apply_statistics_targets.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/build.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/loading/shape_not_empty.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/py.typed +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out_plan.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/paired.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/paired_plan.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/relations/world_changed.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scaled_shape.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/scaling/shape_referenced.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/build_result.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/canonical.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/children_per_parent.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/django_data_shape/types/table_result.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/derivations.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/index.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/invariants.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/keys.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/docs/projections.md +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/mkdocs.yml +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/scripts/check-changelog +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/scripts/release-publish.sh +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/backends/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/backends/test_require_clone_strategy.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/backends/test_require_postgres.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/conftest.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/test_require_quotable_name.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/databases/test_shape_digest.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_order_derivations.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_order_tables.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_ceiling.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_sql_percent.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values_references.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values_uuid.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_shape.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_shape_from_factory.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/declaration/test_table.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_after.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_after_units.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_aligned.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_derived.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_given.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/derivations/test_per_parent.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_constant.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_sequential.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_skew.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_uniform.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/distributions/test_zipf.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/fixtures/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/fixtures/test_fixtures.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/generation/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/generation/test_generate_rows.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/generation/test_refuse_queries.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/invariants/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/invariants/test_invariant.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_infer_key_strategy.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_is_disjoint.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_key_function.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_md5_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_sequential_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/keys/test_uuid_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_apply_statistics_targets.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_derivations.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_graph.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_invariants.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_keys.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_m2m.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_portable.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/loading/test_build_projection.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_paired.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/relations/test_paired_plan.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scale_protocol.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_projection_ceiling.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_disjoint_advice.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_leaves_undeclared_tables_alone.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_over_a_session_world.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_over_disjoint_tables.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_pending_trigger_events.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_restores_sequences.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_documentation.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_package_layout.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_reference_documents_the_public_surface.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/test_version.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/testapp/__init__.py +0 -0
- {django_data_shape-0.22.0 → django_data_shape-0.24.0}/tests/types/__init__.py +0 -0
- {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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
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. `
|
|
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.
|
|
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. `
|
|
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
|
+
"""
|