model2data 1.10.4__tar.gz → 1.12.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 (169) hide show
  1. {model2data-1.10.4/model2data.egg-info → model2data-1.12.0}/PKG-INFO +3 -2
  2. {model2data-1.10.4 → model2data-1.12.0}/README.md +39 -10
  3. {model2data-1.10.4 → model2data-1.12.0}/README_PYPI.md +2 -1
  4. {model2data-1.10.4 → model2data-1.12.0}/model2data/cli.py +57 -7
  5. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/hint_tests.py +58 -9
  6. model2data-1.12.0/model2data/dbt/templates/hint_macros/model2data_when_tests.sql +56 -0
  7. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/tests.py +2 -1
  8. {model2data-1.10.4 → model2data-1.12.0}/model2data/defects/checks.py +49 -0
  9. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/core.py +204 -56
  10. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/days.py +38 -1
  11. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/faker.py +1 -1
  12. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/hints.py +27 -0
  13. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/kinds.py +6 -2
  14. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/timeline.py +73 -13
  15. model2data-1.12.0/model2data/generate/when.py +389 -0
  16. model2data-1.12.0/model2data/guide/setup.md +76 -0
  17. model2data-1.12.0/model2data/guide/triage.md +68 -0
  18. model2data-1.12.0/model2data/guide/tune.md +139 -0
  19. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/__init__.py +1 -1
  20. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/dbml.py +7 -0
  21. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/document.py +17 -4
  22. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/types.py +1 -1
  23. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/validate.py +157 -11
  24. {model2data-1.10.4 → model2data-1.12.0}/model2data/spec/PUBLISHING.md +15 -11
  25. {model2data-1.10.4 → model2data-1.12.0}/model2data/spec/README.md +37 -4
  26. {model2data-1.10.4 → model2data-1.12.0}/model2data/spec/model.schema.json +19 -5
  27. {model2data-1.10.4 → model2data-1.12.0/model2data.egg-info}/PKG-INFO +3 -2
  28. {model2data-1.10.4 → model2data-1.12.0}/model2data.egg-info/SOURCES.txt +8 -1
  29. {model2data-1.10.4 → model2data-1.12.0}/pyproject.toml +2 -1
  30. {model2data-1.10.4 → model2data-1.12.0}/tests/test_cli.py +2 -2
  31. {model2data-1.10.4 → model2data-1.12.0}/tests/test_defects.py +3 -1
  32. {model2data-1.10.4 → model2data-1.12.0}/tests/test_determinism.py +10 -9
  33. {model2data-1.10.4 → model2data-1.12.0}/tests/test_generation.py +70 -6
  34. model2data-1.12.0/tests/test_guide.py +186 -0
  35. {model2data-1.10.4 → model2data-1.12.0}/tests/test_model_messages.py +4 -4
  36. {model2data-1.10.4 → model2data-1.12.0}/tests/test_spec_alignment.py +109 -0
  37. {model2data-1.10.4 → model2data-1.12.0}/tests/test_timeline.py +65 -1
  38. model2data-1.12.0/tests/test_when.py +974 -0
  39. {model2data-1.10.4 → model2data-1.12.0}/LICENSE +0 -0
  40. {model2data-1.10.4 → model2data-1.12.0}/model2data/__init__.py +0 -0
  41. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/__init__.py +0 -0
  42. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  43. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  44. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  45. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  46. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  47. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  48. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  49. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  50. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  51. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  52. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  53. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  54. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  55. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  56. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  57. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  58. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/constants.py +0 -0
  59. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/database.py +0 -0
  60. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  61. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  62. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  63. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  64. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  65. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  66. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  67. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  68. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  69. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  70. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  71. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  72. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  73. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  74. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  75. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  76. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  77. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  78. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  79. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  80. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  81. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  82. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  83. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  84. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  85. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  86. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  87. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  88. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  89. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  90. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  91. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  92. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  93. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  94. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  95. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  96. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  97. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  98. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  99. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  100. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  101. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  102. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  103. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  104. {model2data-1.10.4 → model2data-1.12.0}/model2data/_vendor/pydbml/tools.py +0 -0
  105. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/__init__.py +0 -0
  106. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/naming.py +0 -0
  107. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/project.py +0 -0
  108. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  109. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  110. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  111. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  112. {model2data-1.10.4 → model2data-1.12.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  113. {model2data-1.10.4 → model2data-1.12.0}/model2data/defects/__init__.py +0 -0
  114. {model2data-1.10.4 → model2data-1.12.0}/model2data/defects/apply.py +0 -0
  115. {model2data-1.10.4 → model2data-1.12.0}/model2data/defects/presets.py +0 -0
  116. {model2data-1.10.4 → model2data-1.12.0}/model2data/defects/report.py +0 -0
  117. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/__init__.py +0 -0
  118. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/history.py +0 -0
  119. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/options.py +0 -0
  120. {model2data-1.10.4 → model2data-1.12.0}/model2data/generate/relationships.py +0 -0
  121. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/_yaml.py +0 -0
  122. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/dump.py +0 -0
  123. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/engine.py +0 -0
  124. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/errors.py +0 -0
  125. {model2data-1.10.4 → model2data-1.12.0}/model2data/model/reader.py +0 -0
  126. {model2data-1.10.4 → model2data-1.12.0}/model2data/output.py +0 -0
  127. {model2data-1.10.4 → model2data-1.12.0}/model2data/parse/__init__.py +0 -0
  128. {model2data-1.10.4 → model2data-1.12.0}/model2data/parse/dbml.py +0 -0
  129. {model2data-1.10.4 → model2data-1.12.0}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
  130. {model2data-1.10.4 → model2data-1.12.0}/model2data/utils.py +0 -0
  131. {model2data-1.10.4 → model2data-1.12.0}/model2data.egg-info/dependency_links.txt +0 -0
  132. {model2data-1.10.4 → model2data-1.12.0}/model2data.egg-info/entry_points.txt +0 -0
  133. {model2data-1.10.4 → model2data-1.12.0}/model2data.egg-info/requires.txt +0 -0
  134. {model2data-1.10.4 → model2data-1.12.0}/model2data.egg-info/top_level.txt +0 -0
  135. {model2data-1.10.4 → model2data-1.12.0}/setup.cfg +0 -0
  136. {model2data-1.10.4 → model2data-1.12.0}/tests/test_as_of_anchor.py +0 -0
  137. {model2data-1.10.4 → model2data-1.12.0}/tests/test_cli_model.py +0 -0
  138. {model2data-1.10.4 → model2data-1.12.0}/tests/test_column_time_hints.py +0 -0
  139. {model2data-1.10.4 → model2data-1.12.0}/tests/test_coverage_gaps.py +0 -0
  140. {model2data-1.10.4 → model2data-1.12.0}/tests/test_days.py +0 -0
  141. {model2data-1.10.4 → model2data-1.12.0}/tests/test_dbml_parser.py +0 -0
  142. {model2data-1.10.4 → model2data-1.12.0}/tests/test_dbml_parser_fuzz.py +0 -0
  143. {model2data-1.10.4 → model2data-1.12.0}/tests/test_dbt_integration.py +0 -0
  144. {model2data-1.10.4 → model2data-1.12.0}/tests/test_dbt_naming.py +0 -0
  145. {model2data-1.10.4 → model2data-1.12.0}/tests/test_dbt_project.py +0 -0
  146. {model2data-1.10.4 → model2data-1.12.0}/tests/test_dbt_tests.py +0 -0
  147. {model2data-1.10.4 → model2data-1.12.0}/tests/test_defects_dbt.py +0 -0
  148. {model2data-1.10.4 → model2data-1.12.0}/tests/test_defects_replay.py +0 -0
  149. {model2data-1.10.4 → model2data-1.12.0}/tests/test_defects_report.py +0 -0
  150. {model2data-1.10.4 → model2data-1.12.0}/tests/test_defects_review.py +0 -0
  151. {model2data-1.10.4 → model2data-1.12.0}/tests/test_distributions.py +0 -0
  152. {model2data-1.10.4 → model2data-1.12.0}/tests/test_examples_model.py +0 -0
  153. {model2data-1.10.4 → model2data-1.12.0}/tests/test_faker_name_inference.py +0 -0
  154. {model2data-1.10.4 → model2data-1.12.0}/tests/test_fast_paths.py +0 -0
  155. {model2data-1.10.4 → model2data-1.12.0}/tests/test_hint_tests.py +0 -0
  156. {model2data-1.10.4 → model2data-1.12.0}/tests/test_history.py +0 -0
  157. {model2data-1.10.4 → model2data-1.12.0}/tests/test_issue_lines.py +0 -0
  158. {model2data-1.10.4 → model2data-1.12.0}/tests/test_lone_country.py +0 -0
  159. {model2data-1.10.4 → model2data-1.12.0}/tests/test_model.py +0 -0
  160. {model2data-1.10.4 → model2data-1.12.0}/tests/test_model_dbml.py +0 -0
  161. {model2data-1.10.4 → model2data-1.12.0}/tests/test_model_edges.py +0 -0
  162. {model2data-1.10.4 → model2data-1.12.0}/tests/test_model_vocabulary.py +0 -0
  163. {model2data-1.10.4 → model2data-1.12.0}/tests/test_options.py +0 -0
  164. {model2data-1.10.4 → model2data-1.12.0}/tests/test_release_stress.py +0 -0
  165. {model2data-1.10.4 → model2data-1.12.0}/tests/test_reproducibility.py +0 -0
  166. {model2data-1.10.4 → model2data-1.12.0}/tests/test_row_identity.py +0 -0
  167. {model2data-1.10.4 → model2data-1.12.0}/tests/test_shaping.py +0 -0
  168. {model2data-1.10.4 → model2data-1.12.0}/tests/test_spec_schema.py +0 -0
  169. {model2data-1.10.4 → model2data-1.12.0}/tests/test_table_seeds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.10.4
3
+ Version: 1.12.0
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Author: JB Analytica
6
6
  License-Expression: MIT
@@ -132,7 +132,7 @@ access required.
132
132
  ## The model file
133
133
 
134
134
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its format
135
- is [spec 0.3.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
135
+ is [spec 0.4.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
136
136
  check against:
137
137
 
138
138
  ```yaml
@@ -292,6 +292,7 @@ dbt package, so `dbt build` still works offline):
292
292
  | `generate: {after: other}` | `model2data_not_before` (where both are not null) | none |
293
293
  | `generate: {null_rate}` | `model2data_max_null_share`: nulls at most `null_rate` + tolerance | `--test-tolerance`, default 0.1 |
294
294
  | `generate: {distinct: n}` | `model2data_max_distinct`: at most `n` distinct values | none |
295
+ | `generate: {when}` | `model2data_when`: null exactly where the condition does not hold (with `null_rate`, only there; its null share is then tested among the matching rows) | none |
295
296
  | `grain` on a table | `model2data_unique_combination` | none |
296
297
  | an enum-typed column | `accepted_values` (always written) | none |
297
298
 
@@ -16,6 +16,13 @@ relationship-preserving synthetic data
16
16
  DuckDB or Postgres profile. No sample data to hunt down, no dbt boilerplate to hand-write, no
17
17
  production data to risk exposing.
18
18
 
19
+ Driving model2data from a coding agent? Have it start here; the instructions ship with the
20
+ installed version and pick a topic from the current directory:
21
+
22
+ ```bash
23
+ uvx model2data@latest guide
24
+ ```
25
+
19
26
  A working analytics stack — real (synthetic) data, tested dbt models, queryable in
20
27
  DuckDB — from a schema file, in seconds:
21
28
 
@@ -50,8 +57,9 @@ access required.
50
57
  - **Relationship-preserving.** Foreign keys resolve to real parent rows; tables are generated in
51
58
  dependency order.
52
59
  - **Deterministic, byte for byte.** The same model, `--seed` and `--as-of` (and the same options)
53
- give the same files, byte for byte, on any machine and in every 1.x release — safe to commit
54
- fixtures, safe to diff across CI runs and upgrades. See [The determinism promise](#the-determinism-promise).
60
+ give the same files, byte for byte, on any machine and any day with the same model2data
61
+ version — safe to commit fixtures, safe to diff across CI runs. See
62
+ [The determinism promise](#the-determinism-promise).
55
63
  - **Re-rollable one table at a time.** `--table-seed orders=7` regenerates a single table and
56
64
  leaves every other table byte-identical, so you can keep the four tables that look right.
57
65
  - **A real dbt project, not just CSVs.** Seeds, staging models that `ref()` them, schema tests,
@@ -123,7 +131,7 @@ flowchart LR
123
131
  ## The model file
124
132
 
125
133
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its
126
- format is [spec 0.3.0](model2data/spec/README.md), with a JSON Schema
134
+ format is [spec 0.4.0](model2data/spec/README.md), with a JSON Schema
127
135
  ([`model.schema.json`](model2data/spec/model.schema.json)) your editor can autocomplete and check
128
136
  against:
129
137
 
@@ -222,14 +230,14 @@ jobs:
222
230
  ```
223
231
 
224
232
  Inputs: `files` (glob, default `**/*.model2data.yml`), `version` (default: the release the tag
225
- points at, e.g. `1.10.4`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
233
+ points at, e.g. `1.12.0`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
226
234
 
227
235
  **pre-commit**
228
236
 
229
237
  ```yaml
230
238
  repos:
231
239
  - repo: https://github.com/JB-Analytica/model2data
232
- rev: v1.10.4
240
+ rev: v1.12.0
233
241
  hooks:
234
242
  - id: model2data-validate
235
243
  ```
@@ -243,7 +251,7 @@ model2data:
243
251
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
244
252
  changes: ["**/*.model2data.yml"]
245
253
  script:
246
- - pip install model2data==1.10.4
254
+ - pip install model2data==1.12.0
247
255
  - model2data validate --glob "**/*.model2data.yml"
248
256
  ```
249
257
 
@@ -316,13 +324,15 @@ The same model, seed and `--as-of` — with the same options (`--rows`, `--rows-
316
324
 
317
325
  - on any machine and operating system, and on every supported Python version;
318
326
  - with any Faker or pandas version model2data accepts;
319
- - in every 1.x release. A release may make generation faster or add options, but never changes
320
- what an existing model, seed and set of options produce. A fix that cannot keep that waits for
321
- a new major version, and the changelog says so.
327
+ - with the same model2data version, whenever you run it.
328
+
329
+ A new release may change what a seed produces, to fix a bug or make the data more realistic.
330
+ When it does, the changelog says which models are affected and why. Pin the version
331
+ (`model2data==1.11.0`) when fixtures must stay the same across upgrades.
322
332
 
323
333
  `tests/test_determinism.py` holds the file hashes of a spread of models, presets, multi-day runs
324
334
  and locales, and runs on every pull request — against the newest Faker and pandas too — so a
325
- change to a single output byte fails the build rather than reaching a release.
335
+ change to a single output byte fails the build unless it is deliberate and in the changelog.
326
336
 
327
337
  If one table comes out wrong and the rest looks right, `--table-seed` re-rolls just that table.
328
338
  Every other table's seed CSV stays byte-identical, and children of the re-rolled table still
@@ -394,6 +404,24 @@ shipped_at:
394
404
  generate: {after: ordered_at}
395
405
  ```
396
406
 
407
+ A column that only has a value in some states says which with `when` (spec 0.4.0: the model says
408
+ `model2data: 0.4.0`): it holds a value on
409
+ exactly the rows whose named column holds one of the listed values, and is null on every other
410
+ row — no `pending` order with a `shipped_at`, no `cancelled` subscription without a
411
+ `cancelled_at`:
412
+
413
+ ```yaml
414
+ shipped_at:
415
+ type: timestamp
416
+ generate: {after: ordered_at, when: {status: [shipped, delivered]}}
417
+ ```
418
+
419
+ The named column is another column of the table (an enum, boolean, number or text column; several
420
+ named must all match), and the column carrying `when` is nullable. On the matching rows it is
421
+ never null, unless it has a `null_rate`, which then counts only those rows. On days after the
422
+ first (`--days`), a row that moves into a listed status gets its value that day and a row that
423
+ moves out loses it. Every other column comes out exactly as it would without the hint.
424
+
397
425
  The flags above (or `run.shape` in the model) shape every date and timestamp column the same
398
426
  way, run-wide. `business_hours`, `growth`, and `seasonality` column hints override that for one
399
427
  column at a time — the whole point being a run can be uniform everywhere except the one column
@@ -525,6 +553,7 @@ dbt package, so `dbt build` still works offline):
525
553
  | `generate: {after: other}` | `model2data_not_before` (where both are not null) | none |
526
554
  | `generate: {null_rate}` | `model2data_max_null_share`: nulls at most `null_rate` + tolerance | `--test-tolerance`, default 0.1 |
527
555
  | `generate: {distinct: n}` | `model2data_max_distinct`: at most `n` distinct values | none |
556
+ | `generate: {when}` | `model2data_when`: null exactly where the condition does not hold (with `null_rate`, only there; its null share is then tested among the matching rows) | none |
528
557
  | `grain` on a table | `model2data_unique_combination` | none |
529
558
  | an enum-typed column | `accepted_values` (always written) | none |
530
559
 
@@ -85,7 +85,7 @@ access required.
85
85
  ## The model file
86
86
 
87
87
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its format
88
- is [spec 0.3.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
88
+ is [spec 0.4.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
89
89
  check against:
90
90
 
91
91
  ```yaml
@@ -245,6 +245,7 @@ dbt package, so `dbt build` still works offline):
245
245
  | `generate: {after: other}` | `model2data_not_before` (where both are not null) | none |
246
246
  | `generate: {null_rate}` | `model2data_max_null_share`: nulls at most `null_rate` + tolerance | `--test-tolerance`, default 0.1 |
247
247
  | `generate: {distinct: n}` | `model2data_max_distinct`: at most `n` distinct values | none |
248
+ | `generate: {when}` | `model2data_when`: null exactly where the condition does not hold (with `null_rate`, only there; its null share is then tested among the matching rows) | none |
248
249
  | `grain` on a table | `model2data_unique_combination` | none |
249
250
  | an enum-typed column | `accepted_values` (always written) | none |
250
251
 
@@ -2,6 +2,8 @@ import random
2
2
  import shutil
3
3
  from collections.abc import Iterable
4
4
  from datetime import date, datetime
5
+ from enum import Enum
6
+ from importlib import resources
5
7
  from pathlib import Path
6
8
  from typing import Any, Optional
7
9
 
@@ -163,13 +165,13 @@ app = typer.Typer(
163
165
  cls=_GenerateByDefault,
164
166
  help=(
165
167
  "model2data: Generate analytics-ready datasets from a data model.\n\n"
166
- "Given a model -- a .model2data.yml document (spec 0.3.0), the same as JSON,\n"
168
+ "Given a model -- a .model2data.yml document (spec 0.4.0), the same as JSON,\n"
167
169
  "or a DBML file -- this tool produces:\n"
168
170
  "• Synthetic but realistic data\n"
169
171
  "• A runnable dbt project scaffold\n"
170
172
  "• dbt seeds, staging models, and profiles\n\n"
171
173
  "`model2data --file MODEL` generates; `validate` and `convert` check and\n"
172
- "convert a model."
174
+ "convert a model. Agents: run `model2data guide` first."
173
175
  ),
174
176
  add_completion=False,
175
177
  )
@@ -369,7 +371,7 @@ def main(
369
371
  "--hint-tests",
370
372
  help=(
371
373
  "Write the model's generation hints as dbt tests (min/max range, after, "
372
- "null_rate, distinct, grain) at this severity: error, warn or off. They "
374
+ "null_rate, distinct, when, grain) at this severity: error, warn or off. They "
373
375
  "describe intent, so they warn by default rather than break a first dbt "
374
376
  "build on real data."
375
377
  ),
@@ -735,8 +737,12 @@ def main(
735
737
 
736
738
 
737
739
  def _spec_of(file: Path) -> str:
738
- """The spec a conforming model is written against: 0.3.0, or 0.2.0 (DBML included)."""
739
- return "0.3.0" if str(load(file).version).startswith("0.3") else "0.2.0"
740
+ """The spec a conforming model is written against: 0.4.0, 0.3.0, or 0.2.0 (DBML without
741
+ `when` included)."""
742
+ version = str(load(file).version)
743
+ return (
744
+ "0.4.0" if version.startswith("0.4") else "0.3.0" if version.startswith("0.3") else "0.2.0"
745
+ )
740
746
 
741
747
 
742
748
  def _print_defects(report: DefectsReport) -> None:
@@ -815,7 +821,7 @@ def validate_command(
815
821
  False, "--require-files", help="Exit 1 when no model file was given or matched."
816
822
  ),
817
823
  ):
818
- """Check that models conform to spec 0.3.0 (or 0.2.x), printing every issue with its path.
824
+ """Check that models conform to spec 0.4.0 (or 0.2.x, 0.3.x), printing every issue with its path.
819
825
 
820
826
  Takes one or more files and/or --glob patterns. Exits 1 when any file has an error;
821
827
  warnings are printed, and the model conforms.
@@ -874,7 +880,7 @@ def convert_command(
874
880
  force: bool = typer.Option(False, "--force", help="Overwrite the output file if it exists."),
875
881
  ):
876
882
  """Convert a model -- DBML, typically -- to a .model2data.yml document (spec 0.2.0, or
877
- 0.3.0 when it has defects)."""
883
+ 0.3.0 when it has defects, 0.4.0 when it has `when`)."""
878
884
  try:
879
885
  model = load(file)
880
886
  except ModelError as error:
@@ -891,3 +897,47 @@ def convert_command(
891
897
  raise typer.Exit(1)
892
898
  output.write_text(text, encoding="utf-8")
893
899
  typer.echo(f"✅ Wrote {output}")
900
+
901
+
902
+ class GuideTopic(str, Enum):
903
+ setup = "setup"
904
+ triage = "triage"
905
+ tune = "tune"
906
+
907
+
908
+ _MODEL_PATTERNS = ("*.model2data.yml", "*.model2data.yaml", "*.model2data.json", "*.dbml")
909
+
910
+
911
+ def _detect_topic(cwd: Path) -> tuple[GuideTopic, str]:
912
+ """`triage` when the current directory holds a model, else `setup`, and why.
913
+
914
+ Reads file names in `cwd` only: no subdirectories, no network.
915
+ """
916
+ models = sorted(path.name for pattern in _MODEL_PATTERNS for path in cwd.glob(pattern))
917
+ if models:
918
+ return GuideTopic.triage, f"found {', '.join(models)}"
919
+ return GuideTopic.setup, "no *.model2data.yml or *.dbml in this directory"
920
+
921
+
922
+ @app.command("guide")
923
+ def guide_command(
924
+ topic: Optional[GuideTopic] = typer.Argument( # noqa: B008
925
+ None,
926
+ help=(
927
+ "setup: no model here yet -- install, write one, first run. "
928
+ "triage: what validate, generate and dbt build printed, and what to do. "
929
+ "tune: every option, model key and exit code. "
930
+ "Default: picked from the current directory (a *.model2data.yml or *.dbml "
931
+ "file in it means triage)."
932
+ ),
933
+ ),
934
+ ):
935
+ """Print instructions for an agent about to use model2data, as Markdown.
936
+
937
+ Reads nothing but file names in the current directory and writes nothing.
938
+ """
939
+ if topic is None:
940
+ topic, reason = _detect_topic(Path.cwd())
941
+ typer.echo(f"# model2data guide: {reason} -> {topic.value}\n")
942
+ page = resources.files("model2data").joinpath(f"guide/{topic.value}.md")
943
+ typer.echo(page.read_text("utf-8"), nl=False)
@@ -14,19 +14,27 @@ What becomes a test, and what does not:
14
14
  | `generate.after: other` | `model2data_not_before` | none |
15
15
  | `generate.null_rate` | `model2data_max_null_share` | yes |
16
16
  | `generate.distinct: n` | `model2data_max_distinct` | none |
17
+ | `generate.when` | `model2data_when` | none |
17
18
  | table `grain` | `model2data_unique_combination` | none |
18
19
 
20
+ A column with `when` and `null_rate` has its null share tested among the rows
21
+ `when` matches (`model2data_when_max_null_share`), since only those rows can
22
+ hold a value, and its `model2data_when` test lets those rows be null.
23
+
19
24
  Enum columns keep the `accepted_values` test `generate_dbt_yml` always wrote.
20
25
  `true_rate`, `weights`, `skew`, `distribution` and the temporal shape hints
21
26
  (`business_hours`, `growth`, `seasonality`) describe a statistical shape, not a
22
27
  constraint a row can break, so they produce no test.
23
28
 
24
29
  The generic tests live in `macros/model2data_hint_tests.sql` of the generated
25
- project (see `write_hint_macros`): plain SQL, no dbt package.
30
+ project (see `write_hint_macros`), and the `when` ones in
31
+ `macros/model2data_when_tests.sql`, written only for a model that has one, so
32
+ a project without `when` is what it was: plain SQL, no dbt package.
26
33
  """
27
34
 
28
35
  from __future__ import annotations
29
36
 
37
+ import copy
30
38
  from collections.abc import Mapping
31
39
  from dataclasses import dataclass, field
32
40
  from pathlib import Path
@@ -43,6 +51,8 @@ DEFAULT_TOLERANCE = 0.1
43
51
 
44
52
  MACROS_FILE = "model2data_hint_tests.sql"
45
53
  _MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / MACROS_FILE
54
+ WHEN_MACROS_FILE = "model2data_when_tests.sql"
55
+ _WHEN_MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / WHEN_MACROS_FILE
46
56
 
47
57
 
48
58
  @dataclass(frozen=True)
@@ -53,7 +63,7 @@ class HintTest:
53
63
  `column` is None for a table-level test (grain). `test` is the generic
54
64
  test's name, `arguments` its parameters as dbt's `arguments:` block, and
55
65
  `hint` the hint it comes from (`min`, `max`, `after`, `null_rate`,
56
- `distinct`, `grain`). `min` and `max` of one column share one test.
66
+ `distinct`, `when`, `grain`). `min` and `max` of one column share one test.
57
67
  """
58
68
 
59
69
  table: str
@@ -148,14 +158,39 @@ def _column_tests(
148
158
  tests.append(HintTest(table, column.name, "model2data_not_before", arguments, "after"))
149
159
 
150
160
  null_rate = note.get("null_rate")
151
- if isinstance(null_rate, (int, float)) and not isinstance(null_rate, bool):
161
+ if not isinstance(null_rate, (int, float)) or isinstance(null_rate, bool):
162
+ null_rate = None
163
+ conditions = _when_conditions(note.get("when"), kinds_of)
164
+ if conditions is not None:
165
+ when_arguments: dict[str, Any] = {"conditions": conditions}
166
+ if null_rate is not None:
167
+ when_arguments["required"] = False
168
+ tests.append(HintTest(table, column.name, "model2data_when", when_arguments, "when"))
169
+
170
+ if null_rate is not None:
152
171
  # Rounded so 0.1 + 0.1 reaches the YAML as 0.2, not 0.2000000000000001.
153
172
  limit = round(min(1.0, null_rate + tolerance), 10)
154
- tests.append(
155
- HintTest(
156
- table, column.name, "model2data_max_null_share", {"max_share": limit}, "null_rate"
173
+ if conditions is None:
174
+ tests.append(
175
+ HintTest(
176
+ table,
177
+ column.name,
178
+ "model2data_max_null_share",
179
+ {"max_share": limit},
180
+ "null_rate",
181
+ )
182
+ )
183
+ else:
184
+ tests.append(
185
+ HintTest(
186
+ table,
187
+ column.name,
188
+ "model2data_when_max_null_share",
189
+ # Its own copy, or the YAML would share the mapping as an anchor.
190
+ {"conditions": copy.deepcopy(conditions), "max_share": limit},
191
+ "null_rate",
192
+ )
157
193
  )
158
- )
159
194
 
160
195
  distinct = note.get("distinct")
161
196
  if isinstance(distinct, int) and not isinstance(distinct, bool) and distinct > 0:
@@ -167,14 +202,28 @@ def _column_tests(
167
202
  return tests
168
203
 
169
204
 
170
- def write_hint_macros(dest: Path) -> Path:
205
+ def _when_conditions(when: Any, kinds_of: dict[str, ColumnDef]) -> Optional[dict[str, list]]:
206
+ """A `when` hint as the test's `conditions`: an enum's values as the member text it holds."""
207
+ if not isinstance(when, dict) or not when or not all(name in kinds_of for name in when):
208
+ return None
209
+ return {
210
+ name: [str(value) for value in values] if kinds_of[name].enum_values else list(values)
211
+ for name, values in when.items()
212
+ }
213
+
214
+
215
+ def write_hint_macros(dest: Path, *, when: bool = False) -> Path:
171
216
  """Write the generic tests into `dest/macros/`, and return the file's path.
172
217
 
173
218
  A project whose YAML holds `hint_tests_for` tests needs this file next to
174
- it; `generate_dbt_yml` writes it itself when it emits any.
219
+ it; `generate_dbt_yml` writes it itself when it emits any. With `when`, the
220
+ file of the `when` tests is written beside it (`WHEN_MACROS_FILE`), which a
221
+ project holding a `model2data_when` test needs too.
175
222
  """
176
223
  macros = dest / "macros"
177
224
  macros.mkdir(parents=True, exist_ok=True)
178
225
  target = macros / MACROS_FILE
179
226
  target.write_text(_MACROS_SOURCE.read_text())
227
+ if when:
228
+ (macros / WHEN_MACROS_FILE).write_text(_WHEN_MACROS_SOURCE.read_text())
180
229
  return target
@@ -0,0 +1,56 @@
1
+ {#-
2
+ Generic tests model2data writes for a column's `when` hint: the column holds a
3
+ value only on the rows whose named columns hold one of the listed values.
4
+ Written beside model2data_hint_tests.sql, and only by a model that has a
5
+ `when`. Plain ANSI SQL, runs on DuckDB and Postgres.
6
+ `conditions` maps a raw column name to the values it must hold; a row whose
7
+ named column is null does not match.
8
+ -#}
9
+
10
+ {% macro model2data_when_literal(value) -%}
11
+ {%- if value is sameas true -%}true
12
+ {%- elif value is sameas false -%}false
13
+ {%- elif value is number -%}{{ value }}
14
+ {%- else -%}'{{ value | string | replace("'", "''") }}'
15
+ {%- endif -%}
16
+ {%- endmacro %}
17
+
18
+
19
+ {% macro model2data_when_matches(conditions) -%}
20
+ (case when true
21
+ {%- for column, values in conditions.items() %}
22
+ and {{ adapter.quote(column) }} in (
23
+ {%- for value in values -%}
24
+ {{ model2data_when_literal(value) }}{% if not loop.last %}, {% endif %}
25
+ {%- endfor -%}
26
+ )
27
+ {%- endfor %}
28
+ then 1 else 0 end = 1)
29
+ {%- endmacro %}
30
+
31
+
32
+ {#-
33
+ A row that does not match holds a value, or (unless `required` is false, for
34
+ a column with a `null_rate`) a row that matches holds none.
35
+ -#}
36
+ {% test model2data_when(model, column_name, conditions, required=true) %}
37
+ select {{ column_name }} as value
38
+ from {{ model }}
39
+ where (not {{ model2data_when_matches(conditions) }} and {{ column_name }} is not null)
40
+ {%- if required %}
41
+ or ({{ model2data_when_matches(conditions) }} and {{ column_name }} is null)
42
+ {%- endif %}
43
+ {% endtest %}
44
+
45
+
46
+ {#- `null_rate` with `when`: the null share among the matching rows only. -#}
47
+ {% test model2data_when_max_null_share(model, column_name, conditions, max_share) %}
48
+ select null_share
49
+ from (
50
+ select
51
+ avg(case when {{ column_name }} is null then 1.0 else 0.0 end) as null_share
52
+ from {{ model }}
53
+ where {{ model2data_when_matches(conditions) }}
54
+ ) as shares
55
+ where null_share > {{ max_share }}
56
+ {% endtest %}
@@ -68,7 +68,8 @@ def generate_dbt_yml(
68
68
 
69
69
  hinted = _hinted(tables, hint_tests, test_tolerance)
70
70
  if hinted:
71
- write_hint_macros(dest)
71
+ when = any(test.hint == "when" for tests in hinted.values() for test in tests)
72
+ write_hint_macros(dest, when=when)
72
73
  if any(_history_entries(table) for table in tables.values()):
73
74
  write_history_macros(dest)
74
75
  fk_map = _fk_map(tables, refs)
@@ -14,6 +14,10 @@ SQL reads:
14
14
  and `model2data_max_distinct`: as their macros in
15
15
  `dbt/templates/hint_macros/model2data_hint_tests.sql`, nulls skipped where the
16
16
  SQL skips them.
17
+ - `model2data_when` and `model2data_when_max_null_share`: as their macros in
18
+ `dbt/templates/hint_macros/model2data_when_tests.sql`, a row matching when
19
+ each named column's text is one of its values (a boolean as `true`/`false`
20
+ in any case, a number as a number).
17
21
  - `model2data_unique_combination` and a composite key's `unique_combination`: a
18
22
  combination of values on two rows, nulls grouping together as in `group by`.
19
23
  - A history table's `model2data_one_current_row` (a key with other than one
@@ -342,6 +346,49 @@ def _max_null_share(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache:
342
346
  return nulls / len(frame) > test.arguments["max_share"]
343
347
 
344
348
 
349
+ def _matches(test: DbtTest, frame: pd.DataFrame, cache: Columns) -> list[bool]:
350
+ """Per row: does each column of the test's `conditions` hold one of its values?"""
351
+ result = [True] * len(frame)
352
+ for column, values in test.arguments["conditions"].items():
353
+ texts = {value for value in values if isinstance(value, str)}
354
+ flags = {str(value).lower() for value in values if isinstance(value, bool)}
355
+ numbers = {
356
+ float(value)
357
+ for value in values
358
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
359
+ }
360
+ for row, text in enumerate(cache.values(frame, column)):
361
+ if text is None or not (
362
+ text in texts or text.lower() in flags or _number(text) in numbers
363
+ ):
364
+ result[row] = False
365
+ return result
366
+
367
+
368
+ def _when(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns) -> bool:
369
+ required = test.arguments.get("required", True)
370
+ values = cache.values(frame, str(test.column))
371
+ for matches, value in zip(_matches(test, frame, cache), values, strict=True):
372
+ if (not matches and value is not None) or (required and matches and value is None):
373
+ return True
374
+ return False
375
+
376
+
377
+ def _when_max_null_share(
378
+ test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns
379
+ ) -> bool:
380
+ values = cache.values(frame, str(test.column))
381
+ held = [
382
+ value
383
+ for matches, value in zip(_matches(test, frame, cache), values, strict=True)
384
+ if matches
385
+ ]
386
+ if not held:
387
+ return False
388
+ nulls = sum(value is None for value in held)
389
+ return nulls / len(held) > test.arguments["max_share"]
390
+
391
+
345
392
  def _max_distinct(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns) -> bool:
346
393
  return len(cache.number_set(frame, str(test.column))) > test.arguments["max_count"]
347
394
 
@@ -388,6 +435,8 @@ _CHECKS: dict[str, Callable[[DbtTest, pd.DataFrame, Mapping, Columns], bool]] =
388
435
  "model2data_not_before": _not_before,
389
436
  "model2data_max_null_share": _max_null_share,
390
437
  "model2data_max_distinct": _max_distinct,
438
+ "model2data_when": _when,
439
+ "model2data_when_max_null_share": _when_max_null_share,
391
440
  "model2data_unique_combination": _unique_combination,
392
441
  "unique_combination": _unique_combination,
393
442
  "model2data_one_current_row": _one_current_row,