django-data-shape 0.23.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.23.0 → django_data_shape-0.24.0}/.github/workflows/release.yml +2 -2
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.github/workflows/tests.yml +5 -5
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.github/workflows/upstream-drift.yml +2 -2
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/CHANGELOG.md +33 -1
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/PKG-INFO +10 -3
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/README.md +9 -2
- {django_data_shape-0.23.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.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/template_database.py +33 -86
- {django_data_shape-0.23.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.23.0 → django_data_shape-0.24.0}/django_data_shape/utils.py +75 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/version.py +1 -1
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/pytest.md +88 -6
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/reference.md +6 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/statistics.md +54 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/pyproject.toml +1 -1
- {django_data_shape-0.23.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.23.0 → django_data_shape-0.24.0}/tests/databases/test_template_database.py +11 -8
- 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.23.0 → django_data_shape-0.24.0}/uv.lock +56 -55
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.github/CODEOWNERS +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.github/SECURITY.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.github/dependabot.yml +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.gitignore +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/.pre-commit-config.yaml +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/CLAUDE.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/LICENSE +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/Makefile +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/backends/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/backends/require_clone_strategy.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/backends/require_postgres.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/backends/unsupported_backend.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/clone_database.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/drop_database.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/require_quotable_name.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/shape_digest.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/unhashable_shape.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/databases/unusable_base.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/check_constraints.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/invalid_shape.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/order_derivations.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/order_tables.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/projection.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/shape.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/shape_from_factory.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/sql_value.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/declaration/table.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/after.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/aligned.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/copied.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/derivation.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/derived.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/given.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/offset.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/per_parent.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/product.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/derivations/scope.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/ascending.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/bounded.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/categorical.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/constant.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/distinct.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/distribution.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/sequential.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/skew.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/uniform.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/distributions/zipf.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/generation/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/generation/derivation_queried_database.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/generation/generate_rows.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/generation/refuse_queries.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/invariants/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/invariants/check_invariants.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/invariants/invariant.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/invariants/invariant_violated.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/disjoint.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/infer_key_strategy.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/is_disjoint.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/key_function.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/key_strategy.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/md5_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/sequential_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/sql_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/keys/uuid_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/loading/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/loading/apply_statistics_targets.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/loading/build.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/loading/shape_not_empty.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/py.typed +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out_plan.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/fan_out_sizes.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/paired.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/paired_plan.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/resolve_fan_out.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/resolve_paired.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/relations/world_changed.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/scaling/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scale_protocol.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scaled_shape.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/scaling/scaled_world.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/scaling/shape_referenced.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/types/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/types/build_result.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/types/canonical.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/types/children_per_parent.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/django_data_shape/types/table_result.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/derivations.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/index.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/invariants.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/keys.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/projections.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/docs/relations.md +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/mkdocs.yml +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/scripts/check-changelog +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/scripts/release-publish.sh +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/backends/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/backends/test_require_clone_strategy.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/backends/test_require_postgres.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/conftest.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/databases/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/databases/test_require_quotable_name.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/databases/test_shape_digest.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_check_constraints.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_order_derivations.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_order_tables.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_projection.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_ceiling.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_sql_percent.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values_references.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_projection_values_uuid.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_shape.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_shape_from_factory.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/declaration/test_table.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_after.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_after_units.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_aligned.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_derived.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_given.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/derivations/test_per_parent.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/distributions/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/distributions/test_constant.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/distributions/test_sequential.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/distributions/test_skew.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/distributions/test_uniform.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/distributions/test_zipf.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/fixtures/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/fixtures/test_fixtures.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/generation/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/generation/test_generate_rows.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/generation/test_refuse_queries.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/invariants/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/invariants/test_invariant.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/test_infer_key_strategy.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/test_is_disjoint.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/test_key_function.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/test_md5_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/test_sequential_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/keys/test_uuid_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_apply_statistics_targets.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_derivations.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_graph.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_invariants.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_keys.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_m2m.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_portable.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/loading/test_build_projection.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_fan_out.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_fan_out_sizes.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_paired.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_paired_parents.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_paired_plan.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_parents_key_spelling.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_resolve_fan_out.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/relations/test_resolve_paired.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/scale_protocol_consumers.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/scale_protocol_impostors.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scale_protocol.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_projection_ceiling.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_shape.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_disjoint_advice.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_leaves_undeclared_tables_alone.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_over_a_session_world.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_over_disjoint_tables.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_pending_trigger_events.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/scaling/test_scaled_world_restores_sequences.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/test_documentation.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/test_package_layout.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/test_reference_documents_the_public_surface.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/test_utils.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/test_version.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/testapp/__init__.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/testapp/models.py +0 -0
- {django_data_shape-0.23.0 → django_data_shape-0.24.0}/tests/types/__init__.py +0 -0
- {django_data_shape-0.23.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
|
|
@@ -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,37 @@ 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
|
+
|
|
10
41
|
## [0.23.0] — 2026-10-23
|
|
11
42
|
|
|
12
43
|
### Added
|
|
@@ -1751,7 +1782,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
1751
1782
|
into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
|
|
1752
1783
|
first.
|
|
1753
1784
|
|
|
1754
|
-
[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
|
|
1755
1787
|
[0.23.0]: https://github.com/Artui/django-data-shape/compare/v0.22.0...v0.23.0
|
|
1756
1788
|
[0.22.0]: https://github.com/Artui/django-data-shape/compare/v0.21.0...v0.22.0
|
|
1757
1789
|
[0.21.0]: https://github.com/Artui/django-data-shape/compare/v0.20.0...v0.21.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
|
+
"""
|