django-data-shape 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. django_data_shape-0.5.0/CHANGELOG.md +288 -0
  2. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/PKG-INFO +84 -5
  3. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/README.md +80 -4
  4. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/__init__.py +20 -0
  5. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/build.py +138 -16
  6. django_data_shape-0.5.0/django_data_shape/derivation_queried_database.py +29 -0
  7. django_data_shape-0.5.0/django_data_shape/derivations/__init__.py +10 -0
  8. django_data_shape-0.5.0/django_data_shape/derivations/after.py +83 -0
  9. django_data_shape-0.5.0/django_data_shape/derivations/aligned.py +91 -0
  10. django_data_shape-0.5.0/django_data_shape/derivations/derivation.py +57 -0
  11. django_data_shape-0.5.0/django_data_shape/derivations/derived.py +92 -0
  12. django_data_shape-0.5.0/django_data_shape/derivations/given.py +86 -0
  13. django_data_shape-0.5.0/django_data_shape/derivations/scope.py +44 -0
  14. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/fan_out_plan.py +23 -1
  15. django_data_shape-0.5.0/django_data_shape/fixtures/__init__.py +19 -0
  16. django_data_shape-0.5.0/django_data_shape/fixtures/scale_fixture.py +76 -0
  17. django_data_shape-0.5.0/django_data_shape/fixtures/shape_fixture.py +94 -0
  18. django_data_shape-0.5.0/django_data_shape/fixtures/skip_unless_postgres.py +36 -0
  19. django_data_shape-0.5.0/django_data_shape/generate_rows.py +165 -0
  20. django_data_shape-0.5.0/django_data_shape/order_derivations.py +67 -0
  21. django_data_shape-0.5.0/django_data_shape/refuse_queries.py +62 -0
  22. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/require_postgres.py +9 -2
  23. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/resolve_fan_out.py +61 -8
  24. django_data_shape-0.5.0/django_data_shape/scale_protocol.py +48 -0
  25. django_data_shape-0.5.0/django_data_shape/scaled_shape.py +102 -0
  26. django_data_shape-0.5.0/django_data_shape/scaled_world.py +77 -0
  27. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/table.py +104 -5
  28. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/version.py +1 -1
  29. django_data_shape-0.5.0/docs/derivations.md +204 -0
  30. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/docs/index.md +18 -2
  31. django_data_shape-0.5.0/docs/pytest.md +332 -0
  32. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/docs/reference.md +30 -0
  33. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/docs/relations.md +6 -0
  34. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/mkdocs.yml +2 -0
  35. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/pyproject.toml +15 -1
  36. django_data_shape-0.5.0/tests/derivations/test_after.py +64 -0
  37. django_data_shape-0.5.0/tests/derivations/test_aligned.py +54 -0
  38. django_data_shape-0.5.0/tests/derivations/test_derived.py +72 -0
  39. django_data_shape-0.5.0/tests/derivations/test_given.py +61 -0
  40. django_data_shape-0.5.0/tests/scale_protocol_consumers.py +54 -0
  41. django_data_shape-0.5.0/tests/scale_protocol_impostors.py +32 -0
  42. django_data_shape-0.5.0/tests/test_build_derivations.py +224 -0
  43. django_data_shape-0.5.0/tests/test_build_portable.py +216 -0
  44. django_data_shape-0.5.0/tests/test_fixtures.py +139 -0
  45. django_data_shape-0.5.0/tests/test_generate_rows.py +202 -0
  46. django_data_shape-0.5.0/tests/test_order_derivations.py +88 -0
  47. django_data_shape-0.5.0/tests/test_refuse_queries.py +98 -0
  48. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_require_postgres.py +21 -0
  49. django_data_shape-0.5.0/tests/test_scale_protocol.py +46 -0
  50. django_data_shape-0.5.0/tests/test_scaled_shape.py +139 -0
  51. django_data_shape-0.5.0/tests/test_scaled_world.py +132 -0
  52. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_table.py +120 -0
  53. django_data_shape-0.5.0/tests/testapp/__init__.py +0 -0
  54. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/testapp/models.py +71 -0
  55. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/uv.lock +7 -1
  56. django_data_shape-0.3.0/CHANGELOG.md +0 -154
  57. django_data_shape-0.3.0/django_data_shape/generate_rows.py +0 -64
  58. django_data_shape-0.3.0/tests/test_generate_rows.py +0 -56
  59. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.github/CODEOWNERS +0 -0
  60. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.github/SECURITY.md +0 -0
  61. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.github/dependabot.yml +0 -0
  62. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.github/workflows/release.yml +0 -0
  63. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.github/workflows/tests.yml +0 -0
  64. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.github/workflows/upstream-drift.yml +0 -0
  65. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.gitignore +0 -0
  66. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/.pre-commit-config.yaml +0 -0
  67. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/CLAUDE.md +0 -0
  68. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/LICENSE +0 -0
  69. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/Makefile +0 -0
  70. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/build_result.py +0 -0
  71. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/__init__.py +0 -0
  72. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/bounded.py +0 -0
  73. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/constant.py +0 -0
  74. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/distribution.py +0 -0
  75. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/sequential.py +0 -0
  76. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/skew.py +0 -0
  77. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/uniform.py +0 -0
  78. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/distributions/zipf.py +0 -0
  79. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/fan_out.py +0 -0
  80. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/infer_key_strategy.py +0 -0
  81. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/invalid_shape.py +0 -0
  82. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/keys/__init__.py +0 -0
  83. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/keys/key_function.py +0 -0
  84. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/keys/key_strategy.py +0 -0
  85. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/keys/sequential_keys.py +0 -0
  86. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/keys/uuid_keys.py +0 -0
  87. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/order_tables.py +0 -0
  88. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/py.typed +0 -0
  89. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/shape.py +0 -0
  90. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/shape_not_empty.py +0 -0
  91. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/table_result.py +0 -0
  92. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/unsupported_backend.py +0 -0
  93. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/django_data_shape/utils.py +0 -0
  94. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/docs/keys.md +0 -0
  95. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/scripts/release-publish.sh +0 -0
  96. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/__init__.py +0 -0
  97. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/conftest.py +0 -0
  98. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/conftest_settings.py +0 -0
  99. {django_data_shape-0.3.0/tests/distributions → django_data_shape-0.5.0/tests/derivations}/__init__.py +0 -0
  100. {django_data_shape-0.3.0/tests/keys → django_data_shape-0.5.0/tests/distributions}/__init__.py +0 -0
  101. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/distributions/test_constant.py +0 -0
  102. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/distributions/test_sequential.py +0 -0
  103. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/distributions/test_skew.py +0 -0
  104. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/distributions/test_uniform.py +0 -0
  105. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/distributions/test_zipf.py +0 -0
  106. {django_data_shape-0.3.0/tests/testapp → django_data_shape-0.5.0/tests/keys}/__init__.py +0 -0
  107. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/keys/test_key_function.py +0 -0
  108. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/keys/test_sequential_keys.py +0 -0
  109. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/keys/test_uuid_keys.py +0 -0
  110. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_build.py +0 -0
  111. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_build_graph.py +0 -0
  112. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_build_keys.py +0 -0
  113. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_documentation.py +0 -0
  114. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_fan_out.py +0 -0
  115. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_infer_key_strategy.py +0 -0
  116. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_order_tables.py +0 -0
  117. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_resolve_fan_out.py +0 -0
  118. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_shape.py +0 -0
  119. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_utils.py +0 -0
  120. {django_data_shape-0.3.0 → django_data_shape-0.5.0}/tests/test_version.py +0 -0
@@ -0,0 +1,288 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0] — 2026-09-02
11
+
12
+ ### Added
13
+ - **The derivation mechanism**: one scope-parameterised thing rather than four
14
+ bespoke ones. `Derived`, `After`, `Given` and `Aligned` all ask the same
15
+ question -- compute this from something already known -- and differ only in
16
+ **where the inputs are read from**: this row, the parent row, or a shared rank.
17
+ `Derived` is the mechanism and takes `scope=` directly, so a correlation
18
+ nobody shipped a face for is still declarable; the other three are shorthand
19
+ over it and contain no resolution logic at all. Built separately, "custom
20
+ creation logic" and "correlate across a relation" become two vocabularies
21
+ overlapping on the half a consumer asks for first.
22
+ - `Derived("quantity", "unit_price", compute=operator.mul)` computes a column
23
+ from other columns of the same row. `compute` receives the resolved sources
24
+ positionally and nothing else -- not the row index, not a draw, because a
25
+ function of either of those is already a distribution and would be a
26
+ planner-visible declaration the planner-facing half could not see.
27
+ - `After("account.signed_up_at", within=timedelta(days=365))` puts a child's
28
+ column a spread gap past its parent's. It works in whatever unit the column
29
+ uses, and the documentation says plainly that the result is not monotonic with
30
+ the row, so it has a low `pg_stats.correlation` where `Sequential` has a high
31
+ one.
32
+ - `Given("account.plan", {...}, default=...)` chooses a distribution by the
33
+ parent's value. An unlisted value with no default is refused during the load,
34
+ naming the column and the value -- one of very few refusals here that cannot
35
+ happen at declaration time, because the parent's values live in the parent
36
+ table rather than in the declaration.
37
+ - `Aligned("size", Uniform(...))` reads a distribution at a rank shared with
38
+ every column naming that rank, with `reverse=True` for a column related the
39
+ other way. Independent marginals give a database that is realistic per column
40
+ and unrealistic per entity: no single row is extreme in two ways at once, and
41
+ that row is the one that breaks production. The coupling is exact and has no
42
+ strength parameter, because a partial coupling is a copula.
43
+ - **A derivation reaches its parent through the fan-out that already exists.** A
44
+ parent-scoped source is read out of the parent table, in the same query that
45
+ reads the parent's keys, so it costs one query per relation per build rather
46
+ than one per row -- which a partition can do and a per-child draw could not.
47
+ The values are **queried, not recomputed from a declaration**, so a parent
48
+ built with the ORM behaves exactly like one built here; that is the same
49
+ correction the keys took, applied to the columns beside them.
50
+ - **Column order is not computation order.** `Table.columns()` stays sorted by
51
+ name because it is the `COPY` column list; derivations get a topological order
52
+ of their own, and a cycle among them is refused at declaration time by name.
53
+ `Table.computation_order()` reports it.
54
+ - **Generation runs under a query guard, and it is a real check.** This package
55
+ may call your code, and your code may not call the database: a query issued
56
+ while rows are being generated raises `DerivationQueriedDatabase`, naming the
57
+ table, its derivations and the statement. That is what keeps a derivation from
58
+ becoming the per-row creation hook this package exists to replace -- a hook
59
+ whose body may query is a hook whose body will, and a package whose default
60
+ path is not `COPY` has no reason to exist. The guard sees the connection being
61
+ built; the rule holds beyond that and only its enforcement stops there.
62
+
63
+ ### Changed
64
+ - A parent's columns are read through the ORM rather than a raw cursor when a
65
+ derivation asks for them, because a raw cursor bypasses the field's own
66
+ `from_db_value` and a key is the one column where that never shows. Measured:
67
+ SQLite hands a raw `DateTimeField` back naive where the ORM hands back an
68
+ aware datetime, so `After` would compute its offset from a value six hours
69
+ from the one the application reads; a `JSONField` comes back as text rather
70
+ than a dict. Keys alone still take the hand-written statement, which is what
71
+ keeps every branch of the partition coverable without a connection.
72
+ - A relation declared with something other than a `FanOut` now says "a value
73
+ distribution or a derivation" rather than naming only the first.
74
+
75
+ ## [0.4.0] — 2026-09-02
76
+
77
+ ### Added
78
+ - **The pytest surface**, in `django_data_shape.fixtures`. `shape_fixture(shape)`
79
+ returns a session-scoped fixture that builds a shape once for a whole run;
80
+ bind it to a name in `conftest.py` and request that name from a test. It
81
+ composes with pytest-django rather than replacing it, asking for
82
+ `django_db_setup` and `django_db_blocker` by name so the coupling is to two
83
+ fixture names and not to pytest-django's internals. Session scope is
84
+ load-bearing: pytest creates higher-scoped fixtures first, so the rows are
85
+ committed before the transaction that wraps a test is opened, and every test
86
+ sees them while everything a test writes is rolled back with it.
87
+ - **The scale protocol**, which is what a growth assertion asks a world for:
88
+ make the world be at factor F, then let the caller run its block.
89
+ `scaled_world(shape, factor)` is a context manager that builds it and undoes
90
+ it; `scale_fixture(shape)` is the same thing as a fixture; and `ScaleProtocol`
91
+ is the structural type both satisfy, so a consumer asserting that a query
92
+ count is `O(1)` rather than `O(N)` depends on the shape of the call rather
93
+ than on this package. What the context manager yields is a plain row count,
94
+ not a `BuildResult`, for the same reason: a seam a stranger cannot implement
95
+ is not a seam.
96
+ - `scaled_shape(shape, factor)`, the declaration transform underneath it. **A
97
+ factor varies the declaration rather than subsetting one larger build**, because
98
+ a subset is not a smaller database but the same database with a filter -- the
99
+ statistics still describe every row, and the block under test would have to
100
+ cooperate by restricting itself, which puts the harness inside the thing being
101
+ measured. Every table scales, parents included, so the average fan-out is the
102
+ same at every factor and two worlds differ in size alone. The scaled tables go
103
+ through `Table`'s own constructor, so a declaration that only holds at its
104
+ original size is refused at the factor that breaks it, naming the factor.
105
+ - `skip_unless_postgres(connection, operation)`, the pytest twin of the backend
106
+ refusal. Both fixtures skip with the refusal's own message as the reason where
107
+ a shaped database cannot exist, so a suite that also runs on SQLite reports
108
+ what it did not check rather than passing over a database nobody shaped.
109
+ - A `pytest` extra. It is an extra rather than a dependency because the rest of
110
+ the package has nothing to do with pytest, which is also why these fixtures are
111
+ not re-exported from the top-level `__init__`: importing them is what requires
112
+ pytest, not importing the package.
113
+ - **`build(shape, require_statistics=False)` loads rows on any backend.** It asks
114
+ for rows and cardinality rather than for a database the planner can reason
115
+ about, and it is written as a requirement being dropped rather than as work
116
+ being skipped: on PostgreSQL it changes nothing at all, since `COPY` and
117
+ `ANALYZE` are both free and leaving them out would manufacture the unanalyzed
118
+ table this package exists to condemn. Elsewhere the rows are inserted in chunks
119
+ and no statistics are gathered. SQLite's own `ANALYZE` is deliberately not run,
120
+ because running it would claim the plan realism this package says it will not
121
+ claim. The driver check is unaffected: psycopg 2 is still refused on a
122
+ PostgreSQL connection, because the vendor picks the route and not the caller.
123
+ - **The growth harness works on every backend Django supports** and no longer
124
+ skips. A query count is an ORM property and means the same anywhere, so a
125
+ growth assertion is honest off PostgreSQL where a plan assertion is not, and
126
+ the scale harness was the only thing standing between a consumer on SQLite and
127
+ the milestone's headline seam. `shape_fixture` still skips: it exists to build
128
+ a world a planner will believe.
129
+
130
+ ### Fixed
131
+ - `ScaleProtocol` rejected the implementations its own docstring offered. A
132
+ structural type matches parameter names too, so a hand-rolled callable taking
133
+ `n` rather than `factor` did not satisfy it -- exactly the five-line callable
134
+ the documentation tells a consumer to write. The factor is positional-only now,
135
+ and two files of consumers and impostors are type-checked by the suite, because
136
+ a type-level claim with no type-level test is what let this ship.
137
+ - `ShapeNotEmpty` names the likely cause and not only the remedy. The first
138
+ consumer met it by composing both fixtures over one model, where the rows are
139
+ real, correct and written by a fixture the failing test never mentions -- so
140
+ "empty the table first" read as advice about somebody else's data.
141
+
142
+ ## [0.3.0] — 2026-09-01
143
+
144
+ ### Fixed
145
+ - **A UUID primary key no longer refuses to load.** It was refused outright,
146
+ which made this package unusable for a whole class of Django project. The
147
+ reframing is the fix: the design never needed integers, it needed a
148
+ deterministic injection from row index to key -- which is what lets a foreign
149
+ key be satisfied without a lookup, what makes a self-referential tree acyclic
150
+ on the index rather than the value, and what makes two builds of one shape
151
+ agree. Integers were only the most obvious such function.
152
+ - The primary key is prepared by its own field, like every other value. It did
153
+ not need to be while keys were always integers, and a UUID works either way
154
+ because psycopg adapts it -- but a key type needing conversion was stored
155
+ verbatim, which is the bug already found once on an ordinary column.
156
+ - Two documentation examples were syntax errors -- `...` after keyword arguments,
157
+ in the README and on the relations page -- and would have failed the moment a
158
+ reader pasted them.
159
+ - The install line is quoted. `pip install django-data-shape[postgres]` fails in
160
+ zsh with `no matches found`, which is the default shell on macOS.
161
+ - The README's usage example imports the model it uses, and documents the
162
+ refusals a first attempt actually meets: PostgreSQL and psycopg 3, integer
163
+ primary keys, empty tables, and callable model defaults.
164
+
165
+ ### Added
166
+ - **Key strategies.** A table's primary keys come from a deterministic function
167
+ of the row index rather than from a hard-coded dense `1..N` range. Integer keys
168
+ count from one, `UUIDField` keys are derived from the seed, and `KeyFunction`
169
+ declares one for any other type.
170
+ - `SequentialKeys`, `UuidKeys`, `KeyFunction` and the `KeyStrategy` protocol,
171
+ plus a `keys=` argument on `Table`.
172
+ - A composite primary key is refused by name. It is not among a model's concrete
173
+ fields, because it has no column of its own, so the package used to raise a
174
+ bare `StopIteration` from inside itself. The message says `keys=` cannot help
175
+ either: a strategy maps a row index to one value, and this is arity rather than
176
+ type.
177
+ - The documentation's Python examples are parsed by the test suite. A docs
178
+ example is the first code anybody runs, so it gets a guard rather than a
179
+ convention.
180
+
181
+ ## [0.2.0] — 2026-09-01
182
+
183
+ ### Added
184
+ - `FanOut`, which declares how a foreign key's children spread across their
185
+ parents: a size distribution, a `childless` share for parents with no children
186
+ at all, a `null` share for nullable columns, and `placement`.
187
+ - `Zipf`, the heavy-tailed weight distribution fan-out is realistically drawn
188
+ from. A table where every parent has ten children is not merely tidy -- it is
189
+ the one shape in which the planner is never wrong, because its `n_distinct`
190
+ average is the truth.
191
+ - Tables load in dependency order, and a cycle of fan-outs is refused by name.
192
+
193
+ ### The two representation decisions
194
+ - **Fan-out reads the parent's real keys rather than assuming the dense `1..N`
195
+ range this package assigns.** The case that matters is the hybrid: a project
196
+ builds its fifty companies with the ORM, where the row count is small and the
197
+ ORM is the right tool, and asks this package only for the two million orders.
198
+ Referential integrity then holds by construction, because every key emitted
199
+ came out of the parent table.
200
+ - **A fan-out is a partition of the child key range, not a per-child draw.**
201
+ Parent `j` owns rows `[start, end)`. A per-child draw cannot be inverted, and
202
+ "which children belong to parent T" is what a mirrored collection needs. The
203
+ childless tail and `placement` both fall out of the partition for free.
204
+
205
+ ### Notes
206
+ - `placement` defaults to `arrival`. Emitting children parent by parent gives a
207
+ perfectly clustered table that no production system has and that flatters
208
+ every index scan over the foreign key.
209
+ - A self-referential fan-out is refused: it would read keys from a table still
210
+ empty at load time. Self-referential trees are their own feature.
211
+ - A relation needs a `FanOut` and a plain column refuses one, in both
212
+ directions.
213
+
214
+ ## [0.1.1] — 2026-09-01
215
+
216
+ ### Added
217
+ - `Bounded`, an optional second protocol for distributions that can say how many
218
+ distinct values they produce. `Constant` and `Skew` implement it. It is
219
+ separate from `Distribution` on purpose: adding the method there would make it
220
+ required, so a custom distribution written against the single-method protocol
221
+ would stop satisfying it.
222
+ - A declaration that provably cannot be loaded is now refused at declaration
223
+ time. A `Constant` on a unique column with more than one row, or a `Skew` with
224
+ fewer values than rows, is arithmetic rather than a subtle problem, and it used
225
+ to be discovered by the database partway through a load that had already
226
+ written most of a table. Only single-column uniqueness is checked; multi-column
227
+ constraints are satisfiable through combinations across independently declared
228
+ columns, which is an analysis rather than a comparison.
229
+
230
+ ### Fixed
231
+ - `Uniform` with `places` raised `decimal.InvalidOperation` past 28 significant
232
+ digits -- Python's default context precision -- from inside the `COPY` loop, on
233
+ a column such as `numeric(30, 2)` that would have accepted the value. The
234
+ precision needed is now derived from the declared bounds.
235
+ - `Table` and `Shape` attributes are read-only. Every rule they enforce runs once
236
+ in `__init__`, so while the attributes were writable a declaration could be
237
+ edited afterwards into one that would have been refused, with nothing
238
+ re-checking it.
239
+
240
+ ## [0.1.0] — 2026-09-01
241
+
242
+ ### Added
243
+ - The shape vocabulary: `Shape`, `Table`, and the `Skew`, `Uniform`, `Sequential`
244
+ and `Constant` distributions, behind a single-method `Distribution` protocol.
245
+ - `build()`, which generates rows, loads them with `COPY FROM STDIN`, moves the
246
+ identity sequence past the keys it assigned, and runs `ANALYZE`. The order is
247
+ owned by the library: loading into a table analyzed while empty leaves the
248
+ planner applying old statistics to a new row count, which is a worse lie than
249
+ having no statistics at all.
250
+ - `InvalidShape`, raised at declaration time, and `UnsupportedBackend`, raised
251
+ for any connection that is not PostgreSQL. Generation is backend-neutral;
252
+ `COPY` and planner statistics are not, and degrading quietly would produce the
253
+ false confidence this package exists to remove.
254
+ - Primary keys are assigned as a dense `1..N` range rather than declared. That is
255
+ what will let a foreign key be satisfied without a lookup once relations land,
256
+ and what makes a self-referential tree acyclic by construction.
257
+ - A field carrying a Django `default=` is filled with the value `save()` would
258
+ have written, because defaults are applied by `save()` and nothing here calls
259
+ it. A callable default is refused instead of guessed: `uuid4` varies per row
260
+ and `dict` does not, and nothing on the field distinguishes them.
261
+
262
+ - `ShapeNotEmpty`, raised before anything is written when a target table already
263
+ holds rows. Keys start at 1 on every build, so the collision was previously a
264
+ unique-violation naming an index, which says nothing about what to do instead.
265
+ - The whole build runs in one transaction, so a shape whose second table fails
266
+ leaves nothing behind and can be re-run after a fix.
267
+ - Every declared value passes through its field's `get_db_prep_save`. Without it
268
+ a naive datetime was stored verbatim rather than localised -- hours from where
269
+ `save()` puts it under a non-UTC `TIME_ZONE` -- and a `JSONField` could not be
270
+ written at all.
271
+
272
+ ### Notes
273
+ - Relations are refused in both directions: declaring one raises, and so does
274
+ omitting one that cannot be null. An optional foreign key may be omitted and
275
+ loads entirely `NULL`, which is documented rather than left to be discovered.
276
+ - Only integer primary keys are supported. Any other kind is refused, rather than
277
+ a dense `1..N` integer range being written into a character column.
278
+ - psycopg 3 is required and psycopg 2 is refused by name. Rows stream straight
279
+ into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
280
+ first.
281
+
282
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.5.0...HEAD
283
+ [0.5.0]: https://github.com/Artui/django-data-shape/compare/v0.4.0...v0.5.0
284
+ [0.4.0]: https://github.com/Artui/django-data-shape/compare/v0.3.0...v0.4.0
285
+ [0.3.0]: https://github.com/Artui/django-data-shape/compare/v0.2.0...v0.3.0
286
+ [0.2.0]: https://github.com/Artui/django-data-shape/compare/v0.1.1...v0.2.0
287
+ [0.1.1]: https://github.com/Artui/django-data-shape/compare/v0.1.0...v0.1.1
288
+ [0.1.0]: https://github.com/Artui/django-data-shape/compare/v0.0.0...v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-data-shape
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -36,6 +36,9 @@ Requires-Python: >=3.10
36
36
  Requires-Dist: django>=4.2
37
37
  Provides-Extra: postgres
38
38
  Requires-Dist: psycopg[binary]>=3.2.0; extra == 'postgres'
39
+ Provides-Extra: pytest
40
+ Requires-Dist: pytest-django>=4.9.0; extra == 'pytest'
41
+ Requires-Dist: pytest>=8.0.0; extra == 'pytest'
39
42
  Description-Content-Type: text/markdown
40
43
 
41
44
  # django-data-shape
@@ -99,7 +102,10 @@ build(shape)
99
102
 
100
103
  `build()` generates the rows, loads them with `COPY`, moves the identity sequence
101
104
  past the keys it assigned, and runs `ANALYZE` so the planner can see the shape.
102
- It raises on any backend that is not PostgreSQL rather than degrading quietly.
105
+ It raises on any backend that is not PostgreSQL rather than degrading quietly --
106
+ unless you say `require_statistics=False`, which asks for rows and cardinality
107
+ instead of a database the planner can reason about, and is what the growth
108
+ harness below is built on.
103
109
 
104
110
  ## Relations
105
111
 
@@ -126,6 +132,72 @@ The parents can be rows this package built or rows your own code did -- their
126
132
  real keys are read, not assumed, so the ORM can own the small tables while this
127
133
  owns the large ones.
128
134
 
135
+ ## Derivations
136
+
137
+ A distribution says what a column looks like **across** rows. A derivation says
138
+ what one column is **given** the others -- and the four faces are one mechanism,
139
+ differing only in where the inputs are read from.
140
+
141
+ ```python
142
+ from django_data_shape import After, Aligned, Derived, FanOut, Given, Table, Zipf
143
+
144
+ Table(
145
+ Ticket,
146
+ rows=2_000_000,
147
+ account=FanOut(Zipf(1.2)),
148
+ # the parent row: this ticket's own account, read across the fan-out
149
+ opened_at=After("account.signed_up_at", within=timedelta(days=365)),
150
+ severity=Given("account.plan", {"free": mostly_low, "enterprise": mostly_high}),
151
+ # a shared rank: the big tickets are big in both columns at once
152
+ quantity=Aligned("size", Uniform(1, 100, places=0)),
153
+ unit_price=Aligned("size", Uniform(1, 500, places=2)),
154
+ # this row
155
+ total=Derived("quantity", "unit_price", compute=operator.mul),
156
+ )
157
+ ```
158
+
159
+ `Derived` is the mechanism and takes `scope=` directly, so a correlation nobody
160
+ shipped a face for is still declarable. Column order is the `COPY` column list
161
+ and says nothing about dependencies, so derivations get a computation order of
162
+ their own and a cycle among them is refused by name.
163
+
164
+ **This package may call your code, and your code may not call the database.**
165
+ Generation runs under a wrapper on the connection being built, so a query raises
166
+ rather than quietly costing a round trip per row. A per-row creation hook is the
167
+ thing this package replaces, and a hook whose body may query is a hook whose body
168
+ will.
169
+
170
+ ## From pytest
171
+
172
+ ```python
173
+ # conftest.py
174
+ from django_data_shape import Constant, Shape, Table
175
+ from django_data_shape.fixtures import scale_fixture, shape_fixture
176
+
177
+ orders = shape_fixture(Shape(Table(Order, rows=100_000, status=Constant("complete"))))
178
+ world = scale_fixture(Shape(Table(Order, rows=100, status=Constant("complete"))))
179
+ ```
180
+
181
+ `orders` is one world built once for the whole session, composed with
182
+ pytest-django rather than replacing it. `world` is the **scale protocol**: make
183
+ the world be at factor F, then let the caller run its block, which is what a
184
+ query count asserted to be `O(1)` rather than `O(N)` needs.
185
+
186
+ ```python
187
+ def test_the_dashboard_does_not_grow(world, django_assert_num_queries):
188
+ for factor in (1, 10):
189
+ with world(factor):
190
+ with django_assert_num_queries(3):
191
+ dashboard()
192
+ ```
193
+
194
+ A factor varies the declaration rather than subsetting one larger build, and
195
+ `pip install 'django-data-shape[pytest]'` is what these two need. **The growth
196
+ harness works on any backend Django supports**, because a query count is an ORM
197
+ property and means the same everywhere; the session world **skips with a stated
198
+ reason** where a shaped database cannot exist, because a plan over it is the
199
+ thing it exists to make honest.
200
+
129
201
  ## What it expects, and what it refuses
130
202
 
131
203
  A declaration that cannot describe a database raises before a row is generated,
@@ -133,7 +205,10 @@ naming the field. In particular:
133
205
 
134
206
  - **PostgreSQL and psycopg 3.** Rows stream into `COPY FROM STDIN`, which
135
207
  psycopg 2 cannot do without materialising them first. Both are refused by name
136
- rather than degraded around.
208
+ rather than degraded around. PostgreSQL is required for the statistics half
209
+ only: `build(shape, require_statistics=False)` loads rows on any backend and
210
+ claims nothing about a plan. psycopg 2 is refused either way, because the
211
+ vendor picks the route and not the caller.
137
212
  - **A key type it can assign.** Integer keys count from one and UUID keys are
138
213
  derived from the seed; anything else is refused rather than guessed, and
139
214
  `keys=KeyFunction(...)` declares one.
@@ -142,11 +217,15 @@ naming the field. In particular:
142
217
  - **A callable model default** such as `default=uuid4` must be declared as a
143
218
  distribution: `uuid4` varies per row and `dict` does not, and nothing on the
144
219
  field distinguishes them.
220
+ - **A derivation that queries the database.** The generation pass is guarded, so
221
+ the rule is a refusal rather than advice. Read what you need before the build
222
+ and close over it.
145
223
 
146
224
  ## Status
147
225
 
148
- Early. Single tables and the model graph. Derived fields, collections copied
149
- along a join, per-group invariants and template-database reuse come next.
226
+ Early. Single tables, the model graph, the pytest surface and the derivation
227
+ mechanism. Collections copied along a join, per-group invariants and
228
+ template-database reuse come next.
150
229
 
151
230
  Full documentation: <https://artui.github.io/django-data-shape/>
152
231
 
@@ -59,7 +59,10 @@ build(shape)
59
59
 
60
60
  `build()` generates the rows, loads them with `COPY`, moves the identity sequence
61
61
  past the keys it assigned, and runs `ANALYZE` so the planner can see the shape.
62
- It raises on any backend that is not PostgreSQL rather than degrading quietly.
62
+ It raises on any backend that is not PostgreSQL rather than degrading quietly --
63
+ unless you say `require_statistics=False`, which asks for rows and cardinality
64
+ instead of a database the planner can reason about, and is what the growth
65
+ harness below is built on.
63
66
 
64
67
  ## Relations
65
68
 
@@ -86,6 +89,72 @@ The parents can be rows this package built or rows your own code did -- their
86
89
  real keys are read, not assumed, so the ORM can own the small tables while this
87
90
  owns the large ones.
88
91
 
92
+ ## Derivations
93
+
94
+ A distribution says what a column looks like **across** rows. A derivation says
95
+ what one column is **given** the others -- and the four faces are one mechanism,
96
+ differing only in where the inputs are read from.
97
+
98
+ ```python
99
+ from django_data_shape import After, Aligned, Derived, FanOut, Given, Table, Zipf
100
+
101
+ Table(
102
+ Ticket,
103
+ rows=2_000_000,
104
+ account=FanOut(Zipf(1.2)),
105
+ # the parent row: this ticket's own account, read across the fan-out
106
+ opened_at=After("account.signed_up_at", within=timedelta(days=365)),
107
+ severity=Given("account.plan", {"free": mostly_low, "enterprise": mostly_high}),
108
+ # a shared rank: the big tickets are big in both columns at once
109
+ quantity=Aligned("size", Uniform(1, 100, places=0)),
110
+ unit_price=Aligned("size", Uniform(1, 500, places=2)),
111
+ # this row
112
+ total=Derived("quantity", "unit_price", compute=operator.mul),
113
+ )
114
+ ```
115
+
116
+ `Derived` is the mechanism and takes `scope=` directly, so a correlation nobody
117
+ shipped a face for is still declarable. Column order is the `COPY` column list
118
+ and says nothing about dependencies, so derivations get a computation order of
119
+ their own and a cycle among them is refused by name.
120
+
121
+ **This package may call your code, and your code may not call the database.**
122
+ Generation runs under a wrapper on the connection being built, so a query raises
123
+ rather than quietly costing a round trip per row. A per-row creation hook is the
124
+ thing this package replaces, and a hook whose body may query is a hook whose body
125
+ will.
126
+
127
+ ## From pytest
128
+
129
+ ```python
130
+ # conftest.py
131
+ from django_data_shape import Constant, Shape, Table
132
+ from django_data_shape.fixtures import scale_fixture, shape_fixture
133
+
134
+ orders = shape_fixture(Shape(Table(Order, rows=100_000, status=Constant("complete"))))
135
+ world = scale_fixture(Shape(Table(Order, rows=100, status=Constant("complete"))))
136
+ ```
137
+
138
+ `orders` is one world built once for the whole session, composed with
139
+ pytest-django rather than replacing it. `world` is the **scale protocol**: make
140
+ the world be at factor F, then let the caller run its block, which is what a
141
+ query count asserted to be `O(1)` rather than `O(N)` needs.
142
+
143
+ ```python
144
+ def test_the_dashboard_does_not_grow(world, django_assert_num_queries):
145
+ for factor in (1, 10):
146
+ with world(factor):
147
+ with django_assert_num_queries(3):
148
+ dashboard()
149
+ ```
150
+
151
+ A factor varies the declaration rather than subsetting one larger build, and
152
+ `pip install 'django-data-shape[pytest]'` is what these two need. **The growth
153
+ harness works on any backend Django supports**, because a query count is an ORM
154
+ property and means the same everywhere; the session world **skips with a stated
155
+ reason** where a shaped database cannot exist, because a plan over it is the
156
+ thing it exists to make honest.
157
+
89
158
  ## What it expects, and what it refuses
90
159
 
91
160
  A declaration that cannot describe a database raises before a row is generated,
@@ -93,7 +162,10 @@ naming the field. In particular:
93
162
 
94
163
  - **PostgreSQL and psycopg 3.** Rows stream into `COPY FROM STDIN`, which
95
164
  psycopg 2 cannot do without materialising them first. Both are refused by name
96
- rather than degraded around.
165
+ rather than degraded around. PostgreSQL is required for the statistics half
166
+ only: `build(shape, require_statistics=False)` loads rows on any backend and
167
+ claims nothing about a plan. psycopg 2 is refused either way, because the
168
+ vendor picks the route and not the caller.
97
169
  - **A key type it can assign.** Integer keys count from one and UUID keys are
98
170
  derived from the seed; anything else is refused rather than guessed, and
99
171
  `keys=KeyFunction(...)` declares one.
@@ -102,11 +174,15 @@ naming the field. In particular:
102
174
  - **A callable model default** such as `default=uuid4` must be declared as a
103
175
  distribution: `uuid4` varies per row and `dict` does not, and nothing on the
104
176
  field distinguishes them.
177
+ - **A derivation that queries the database.** The generation pass is guarded, so
178
+ the rule is a refusal rather than advice. Read what you need before the build
179
+ and close over it.
105
180
 
106
181
  ## Status
107
182
 
108
- Early. Single tables and the model graph. Derived fields, collections copied
109
- along a join, per-group invariants and template-database reuse come next.
183
+ Early. Single tables, the model graph, the pytest surface and the derivation
184
+ mechanism. Collections copied along a join, per-group invariants and
185
+ template-database reuse come next.
110
186
 
111
187
  Full documentation: <https://artui.github.io/django-data-shape/>
112
188
 
@@ -2,6 +2,13 @@
2
2
 
3
3
  from django_data_shape.build import build
4
4
  from django_data_shape.build_result import BuildResult
5
+ from django_data_shape.derivation_queried_database import DerivationQueriedDatabase
6
+ from django_data_shape.derivations.after import After
7
+ from django_data_shape.derivations.aligned import Aligned
8
+ from django_data_shape.derivations.derivation import Derivation
9
+ from django_data_shape.derivations.derived import Derived
10
+ from django_data_shape.derivations.given import Given
11
+ from django_data_shape.derivations.scope import Scope
5
12
  from django_data_shape.distributions.bounded import Bounded
6
13
  from django_data_shape.distributions.constant import Constant
7
14
  from django_data_shape.distributions.distribution import Distribution
@@ -15,6 +22,9 @@ from django_data_shape.keys.key_function import KeyFunction
15
22
  from django_data_shape.keys.key_strategy import KeyStrategy
16
23
  from django_data_shape.keys.sequential_keys import SequentialKeys
17
24
  from django_data_shape.keys.uuid_keys import UuidKeys
25
+ from django_data_shape.scale_protocol import ScaleProtocol
26
+ from django_data_shape.scaled_shape import scaled_shape
27
+ from django_data_shape.scaled_world import scaled_world
18
28
  from django_data_shape.shape import Shape
19
29
  from django_data_shape.shape_not_empty import ShapeNotEmpty
20
30
  from django_data_shape.table import Table
@@ -23,14 +33,22 @@ from django_data_shape.unsupported_backend import UnsupportedBackend
23
33
  from django_data_shape.version import __version__
24
34
 
25
35
  __all__ = [
36
+ "After",
37
+ "Aligned",
26
38
  "Bounded",
27
39
  "BuildResult",
28
40
  "Constant",
41
+ "Derivation",
42
+ "DerivationQueriedDatabase",
43
+ "Derived",
29
44
  "Distribution",
30
45
  "FanOut",
46
+ "Given",
31
47
  "InvalidShape",
32
48
  "KeyFunction",
33
49
  "KeyStrategy",
50
+ "ScaleProtocol",
51
+ "Scope",
34
52
  "SequentialKeys",
35
53
  "UuidKeys",
36
54
  "Sequential",
@@ -44,4 +62,6 @@ __all__ = [
44
62
  "UnsupportedBackend",
45
63
  "__version__",
46
64
  "build",
65
+ "scaled_shape",
66
+ "scaled_world",
47
67
  ]