model2data 1.8.0__tar.gz → 1.10.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 (160) hide show
  1. {model2data-1.8.0/model2data.egg-info → model2data-1.10.0}/PKG-INFO +57 -2
  2. {model2data-1.8.0 → model2data-1.10.0}/README.md +98 -1
  3. {model2data-1.8.0 → model2data-1.10.0}/README_PYPI.md +56 -1
  4. {model2data-1.8.0 → model2data-1.10.0}/model2data/cli.py +183 -60
  5. model2data-1.10.0/model2data/dbt/naming.py +61 -0
  6. model2data-1.10.0/model2data/dbt/templates/history_macros/model2data_history_tests.sql +38 -0
  7. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/tests.py +253 -55
  8. model2data-1.10.0/model2data/defects/__init__.py +59 -0
  9. model2data-1.10.0/model2data/defects/apply.py +1064 -0
  10. model2data-1.10.0/model2data/defects/checks.py +179 -0
  11. model2data-1.10.0/model2data/defects/presets.py +311 -0
  12. model2data-1.10.0/model2data/defects/report.py +245 -0
  13. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/days.py +9 -4
  14. model2data-1.10.0/model2data/generate/history.py +152 -0
  15. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/__init__.py +7 -1
  16. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/document.py +50 -1
  17. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/dump.py +15 -3
  18. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/types.py +39 -1
  19. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/validate.py +302 -7
  20. model2data-1.10.0/model2data/output.py +113 -0
  21. {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/PUBLISHING.md +13 -9
  22. {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/README.md +92 -3
  23. {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/model.schema.json +59 -5
  24. {model2data-1.8.0 → model2data-1.10.0/model2data.egg-info}/PKG-INFO +57 -2
  25. {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/SOURCES.txt +15 -0
  26. {model2data-1.8.0 → model2data-1.10.0}/pyproject.toml +1 -1
  27. {model2data-1.8.0 → model2data-1.10.0}/tests/test_cli_model.py +92 -0
  28. {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_tests.py +44 -0
  29. model2data-1.10.0/tests/test_defects.py +772 -0
  30. model2data-1.10.0/tests/test_defects_dbt.py +238 -0
  31. model2data-1.10.0/tests/test_defects_replay.py +138 -0
  32. model2data-1.10.0/tests/test_defects_report.py +370 -0
  33. model2data-1.10.0/tests/test_defects_review.py +302 -0
  34. {model2data-1.8.0 → model2data-1.10.0}/tests/test_hint_tests.py +12 -1
  35. model2data-1.10.0/tests/test_history.py +254 -0
  36. {model2data-1.8.0 → model2data-1.10.0}/tests/test_model_edges.py +2 -1
  37. model2data-1.10.0/tests/test_model_messages.py +603 -0
  38. model2data-1.8.0/tests/test_model_messages.py +0 -250
  39. {model2data-1.8.0 → model2data-1.10.0}/LICENSE +0 -0
  40. {model2data-1.8.0 → model2data-1.10.0}/model2data/__init__.py +0 -0
  41. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/__init__.py +0 -0
  42. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  43. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  44. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  45. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  46. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  47. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  48. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  49. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  50. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  51. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  52. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  53. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  54. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  55. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  56. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  57. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  58. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/constants.py +0 -0
  59. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/database.py +0 -0
  60. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  61. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  62. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  63. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  64. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  65. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  66. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  67. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  68. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  69. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  70. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  71. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  72. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  73. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  74. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  75. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  76. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  77. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  78. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  79. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  80. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  81. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  82. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  83. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  84. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  85. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  86. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  87. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  88. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  89. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  90. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  91. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  92. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  93. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  94. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  95. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  96. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  97. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  98. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  99. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  100. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  101. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  102. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  103. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  104. {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/tools.py +0 -0
  105. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/__init__.py +0 -0
  106. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/hint_tests.py +0 -0
  107. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/project.py +0 -0
  108. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  109. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  110. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  111. {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  112. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/__init__.py +0 -0
  113. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/core.py +0 -0
  114. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/faker.py +0 -0
  115. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/hints.py +0 -0
  116. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/kinds.py +0 -0
  117. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/options.py +0 -0
  118. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/relationships.py +0 -0
  119. {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/timeline.py +0 -0
  120. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/_yaml.py +0 -0
  121. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/dbml.py +0 -0
  122. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/engine.py +0 -0
  123. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/errors.py +0 -0
  124. {model2data-1.8.0 → model2data-1.10.0}/model2data/model/reader.py +0 -0
  125. {model2data-1.8.0 → model2data-1.10.0}/model2data/parse/__init__.py +0 -0
  126. {model2data-1.8.0 → model2data-1.10.0}/model2data/parse/dbml.py +0 -0
  127. {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
  128. {model2data-1.8.0 → model2data-1.10.0}/model2data/utils.py +0 -0
  129. {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/dependency_links.txt +0 -0
  130. {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/entry_points.txt +0 -0
  131. {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/requires.txt +0 -0
  132. {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/top_level.txt +0 -0
  133. {model2data-1.8.0 → model2data-1.10.0}/setup.cfg +0 -0
  134. {model2data-1.8.0 → model2data-1.10.0}/tests/test_as_of_anchor.py +0 -0
  135. {model2data-1.8.0 → model2data-1.10.0}/tests/test_cli.py +0 -0
  136. {model2data-1.8.0 → model2data-1.10.0}/tests/test_column_time_hints.py +0 -0
  137. {model2data-1.8.0 → model2data-1.10.0}/tests/test_coverage_gaps.py +0 -0
  138. {model2data-1.8.0 → model2data-1.10.0}/tests/test_days.py +0 -0
  139. {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbml_parser.py +0 -0
  140. {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbml_parser_fuzz.py +0 -0
  141. {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_integration.py +0 -0
  142. {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_naming.py +0 -0
  143. {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_project.py +0 -0
  144. {model2data-1.8.0 → model2data-1.10.0}/tests/test_distributions.py +0 -0
  145. {model2data-1.8.0 → model2data-1.10.0}/tests/test_examples_model.py +0 -0
  146. {model2data-1.8.0 → model2data-1.10.0}/tests/test_faker_name_inference.py +0 -0
  147. {model2data-1.8.0 → model2data-1.10.0}/tests/test_generation.py +0 -0
  148. {model2data-1.8.0 → model2data-1.10.0}/tests/test_lone_country.py +0 -0
  149. {model2data-1.8.0 → model2data-1.10.0}/tests/test_model.py +0 -0
  150. {model2data-1.8.0 → model2data-1.10.0}/tests/test_model_dbml.py +0 -0
  151. {model2data-1.8.0 → model2data-1.10.0}/tests/test_model_vocabulary.py +0 -0
  152. {model2data-1.8.0 → model2data-1.10.0}/tests/test_options.py +0 -0
  153. {model2data-1.8.0 → model2data-1.10.0}/tests/test_release_stress.py +0 -0
  154. {model2data-1.8.0 → model2data-1.10.0}/tests/test_reproducibility.py +0 -0
  155. {model2data-1.8.0 → model2data-1.10.0}/tests/test_row_identity.py +0 -0
  156. {model2data-1.8.0 → model2data-1.10.0}/tests/test_shaping.py +0 -0
  157. {model2data-1.8.0 → model2data-1.10.0}/tests/test_spec_alignment.py +0 -0
  158. {model2data-1.8.0 → model2data-1.10.0}/tests/test_spec_schema.py +0 -0
  159. {model2data-1.8.0 → model2data-1.10.0}/tests/test_table_seeds.py +0 -0
  160. {model2data-1.8.0 → model2data-1.10.0}/tests/test_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.8.0
3
+ Version: 1.10.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.2.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.3.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
@@ -177,6 +177,12 @@ run:
177
177
  CLI option overrides the one it names. `model2data validate FILE` checks a model and prints every
178
178
  issue with its path in the document.
179
179
 
180
+ **Validate models in CI.** `model2data validate a.model2data.yml b.model2data.yml` (or
181
+ `--glob "**/*.model2data.yml"`, `--format github` for inline pull request annotations) exits 1
182
+ when any model has an error. A GitHub Action (`uses: JB-Analytica/model2data@v1`), a pre-commit
183
+ hook (`model2data-validate`) and a GitLab CI snippet are in the
184
+ [README](https://github.com/JB-Analytica/model2data#validate-models-in-ci).
185
+
180
186
  **DBML is supported input.** `--file` also takes a `.dbml` file, converted to the same model
181
187
  before generating; a JSON note on a column (`[note: '{"min": 1}']`) becomes its `generate`, any
182
188
  other note its description. `model2data convert schema.dbml -o schema.model2data.yml` writes the
@@ -330,6 +336,55 @@ days[3].tables["orders"].updated # rows day 3 changed, with their new values
330
336
  days[3].tables["orders"].state # the table after day 3
331
337
  ```
332
338
 
339
+ ### Break the data on purpose
340
+
341
+ A dbt test you have never seen fail is a test you hope works. `--defects` puts deliberate,
342
+ counted defects in the generated data, so `dbt build` fails exactly the tests it should — for
343
+ teaching dbt, or for proving a project's tests fire:
344
+
345
+ ```bash
346
+ model2data --file examples/ecommerce.model2data.yml --seed 42 --defects training
347
+ cd dbt_ecommerce && dbt build # fails unique, not_null, relationships, accepted_values once each
348
+ ```
349
+
350
+ `training` breaks each kind of standard dbt test once; `messy` puts a small share of every defect
351
+ on every table; `clean` (the default) is the data as it always was, byte for byte; `none` also
352
+ ignores the defects a model's tables list, for the clean data of a training model. A table can
353
+ also list its own defects in the model (spec 0.3.0):
354
+
355
+ ```yaml
356
+ orders:
357
+ defects:
358
+ - {type: duplicate_keys, count: 3} # fails unique
359
+ - {type: orphan_foreign_keys, column: customer_id, share: 0.02} # fails relationships
360
+ - {type: nulls, column: order_date, count: 5} # fails not_null
361
+ ```
362
+
363
+ The types are `duplicate_keys`, `orphan_foreign_keys`, `nulls`, `invalid_values` (fails
364
+ `accepted_values`), `messy_text` (whitespace and casing to clean in staging), and, with `--days`,
365
+ `late_arriving` (rows an incremental model filtering on `updated_at > max(updated_at)` skips) and
366
+ `late_updates` (versions a `strategy: timestamp` snapshot misses) and `overlapping_history` (see
367
+ below). Every run with defects writes
368
+ `defects_report.json` (each defect, the rows it broke, the tests it breaks) and
369
+ `EXPECTED_FAILURES.md` into the project, so a test that does not fire is visible. Defects are applied
370
+ after the clean data, from their own random stream: the same seed gives the same bytes, and a
371
+ defect on one table moves nothing in another. From Python, `model2data.defects.planned_defects`
372
+ says what a run will break, `model2data.output.finish_run` applies it (and builds the histories)
373
+ after `generate_days`, and `write_defects_report` / `write_expected_failures` write the two files.
374
+
375
+ ### A source that keeps its history
376
+
377
+ `history: true` in a table's `incremental` (spec 0.3.0) also writes `<table>_history`: every
378
+ version of every row over the generated days, with `valid_from`, `valid_to` (null for the current
379
+ version) and `is_current` — an SCD type 2 source to build snapshots and point-in-time joins
380
+ against. The project tests it with two package-free tests, one current row per key and no
381
+ overlapping versions; the `overlapping_history` defect breaks the second.
382
+
383
+ ```yaml
384
+ orders:
385
+ incremental: {new_per_day: 20, update_rate: 0.1, updated_at: updated_at, history: true}
386
+ ```
387
+
333
388
  ## Generated dbt project structure
334
389
 
335
390
  The generated dbt project includes:
@@ -123,7 +123,7 @@ flowchart LR
123
123
  ## The model file
124
124
 
125
125
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its
126
- format is [spec 0.2.0](model2data/spec/README.md), with a JSON Schema
126
+ format is [spec 0.3.0](model2data/spec/README.md), with a JSON Schema
127
127
  ([`model.schema.json`](model2data/spec/model.schema.json)) your editor can autocomplete and check
128
128
  against:
129
129
 
@@ -199,6 +199,54 @@ unquoted non-ASCII names — can't be read yet, and are refused with the line an
199
199
 
200
200
  ---
201
201
 
202
+ ## Validate models in CI
203
+
204
+ Teams that keep `*.model2data.yml` models in git can fail a pull request that breaks one.
205
+ `model2data validate` takes several files and `--glob` patterns, prefixes each issue with its
206
+ file, exits 1 if any model has an error, and with `--format github` prints annotations that show
207
+ inline on the pull request's Files tab. `--require-files` fails when nothing matched.
208
+
209
+ **GitHub Actions**
210
+
211
+ ```yaml
212
+ name: model2data
213
+ on:
214
+ pull_request:
215
+ paths: ["**/*.model2data.yml"]
216
+ jobs:
217
+ validate:
218
+ runs-on: ubuntu-latest
219
+ steps:
220
+ - uses: actions/checkout@v4
221
+ - uses: JB-Analytica/model2data@v1
222
+ ```
223
+
224
+ Inputs: `files` (glob, default `**/*.model2data.yml`), `version` (default: the release the tag
225
+ points at, e.g. `1.10.0`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
226
+
227
+ **pre-commit**
228
+
229
+ ```yaml
230
+ repos:
231
+ - repo: https://github.com/JB-Analytica/model2data
232
+ rev: v1.10.0
233
+ hooks:
234
+ - id: model2data-validate
235
+ ```
236
+
237
+ **GitLab CI**
238
+
239
+ ```yaml
240
+ model2data:
241
+ image: python:3.12-slim
242
+ rules:
243
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
244
+ changes: ["**/*.model2data.yml"]
245
+ script:
246
+ - pip install model2data==1.10.0
247
+ - model2data validate --glob "**/*.model2data.yml"
248
+ ```
249
+
202
250
  ## model2data studio — the same engine, in the browser
203
251
 
204
252
  [**model2data studio**](https://studio.jbanalytica.com) puts everything on this page behind a
@@ -473,6 +521,55 @@ they describe intent and should not break a first `dbt build` on real data); the
473
521
  same YAML as before. To call the mapping from Python, `model2data.dbt.hint_tests.hint_tests_for(model)`
474
522
  returns the tests as data.
475
523
 
524
+ ### Break the data on purpose
525
+
526
+ A dbt test you have never seen fail is a test you hope works. `--defects` puts deliberate,
527
+ counted defects in the generated data, so `dbt build` fails exactly the tests it should — for
528
+ teaching dbt, or for proving a project's tests fire:
529
+
530
+ ```bash
531
+ model2data --file examples/ecommerce.model2data.yml --seed 42 --defects training
532
+ cd dbt_ecommerce && dbt build # fails unique, not_null, relationships, accepted_values once each
533
+ ```
534
+
535
+ `training` breaks each kind of standard dbt test once; `messy` puts a small share of every defect
536
+ on every table; `clean` (the default) is the data as it always was, byte for byte; `none` also
537
+ ignores the defects a model's tables list, for the clean data of a training model. A table can
538
+ also list its own defects in the model (spec 0.3.0):
539
+
540
+ ```yaml
541
+ orders:
542
+ defects:
543
+ - {type: duplicate_keys, count: 3} # fails unique
544
+ - {type: orphan_foreign_keys, column: customer_id, share: 0.02} # fails relationships
545
+ - {type: nulls, column: order_date, count: 5} # fails not_null
546
+ ```
547
+
548
+ The types are `duplicate_keys`, `orphan_foreign_keys`, `nulls`, `invalid_values` (fails
549
+ `accepted_values`), `messy_text` (whitespace and casing to clean in staging), and, with `--days`,
550
+ `late_arriving` (rows an incremental model filtering on `updated_at > max(updated_at)` skips) and
551
+ `late_updates` (versions a `strategy: timestamp` snapshot misses) and `overlapping_history` (see
552
+ below). Every run with defects writes
553
+ `defects_report.json` (each defect, the rows it broke, the tests it breaks) and
554
+ `EXPECTED_FAILURES.md` into the project, so a test that does not fire is visible. Defects are applied
555
+ after the clean data, from their own random stream: the same seed gives the same bytes, and a
556
+ defect on one table moves nothing in another. From Python, `model2data.defects.planned_defects`
557
+ says what a run will break, `model2data.output.finish_run` applies it (and builds the histories)
558
+ after `generate_days`, and `write_defects_report` / `write_expected_failures` write the two files.
559
+
560
+ ### A source that keeps its history
561
+
562
+ `history: true` in a table's `incremental` (spec 0.3.0) also writes `<table>_history`: every
563
+ version of every row over the generated days, with `valid_from`, `valid_to` (null for the current
564
+ version) and `is_current` — an SCD type 2 source to build snapshots and point-in-time joins
565
+ against. The project tests it with two package-free tests, one current row per key and no
566
+ overlapping versions; the `overlapping_history` defect breaks the second.
567
+
568
+ ```yaml
569
+ orders:
570
+ incremental: {new_per_day: 20, update_rate: 0.1, updated_at: updated_at, history: true}
571
+ ```
572
+
476
573
  ---
477
574
 
478
575
  ## Generated dbt project structure
@@ -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.2.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.3.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
@@ -130,6 +130,12 @@ run:
130
130
  CLI option overrides the one it names. `model2data validate FILE` checks a model and prints every
131
131
  issue with its path in the document.
132
132
 
133
+ **Validate models in CI.** `model2data validate a.model2data.yml b.model2data.yml` (or
134
+ `--glob "**/*.model2data.yml"`, `--format github` for inline pull request annotations) exits 1
135
+ when any model has an error. A GitHub Action (`uses: JB-Analytica/model2data@v1`), a pre-commit
136
+ hook (`model2data-validate`) and a GitLab CI snippet are in the
137
+ [README](https://github.com/JB-Analytica/model2data#validate-models-in-ci).
138
+
133
139
  **DBML is supported input.** `--file` also takes a `.dbml` file, converted to the same model
134
140
  before generating; a JSON note on a column (`[note: '{"min": 1}']`) becomes its `generate`, any
135
141
  other note its description. `model2data convert schema.dbml -o schema.model2data.yml` writes the
@@ -283,6 +289,55 @@ days[3].tables["orders"].updated # rows day 3 changed, with their new values
283
289
  days[3].tables["orders"].state # the table after day 3
284
290
  ```
285
291
 
292
+ ### Break the data on purpose
293
+
294
+ A dbt test you have never seen fail is a test you hope works. `--defects` puts deliberate,
295
+ counted defects in the generated data, so `dbt build` fails exactly the tests it should — for
296
+ teaching dbt, or for proving a project's tests fire:
297
+
298
+ ```bash
299
+ model2data --file examples/ecommerce.model2data.yml --seed 42 --defects training
300
+ cd dbt_ecommerce && dbt build # fails unique, not_null, relationships, accepted_values once each
301
+ ```
302
+
303
+ `training` breaks each kind of standard dbt test once; `messy` puts a small share of every defect
304
+ on every table; `clean` (the default) is the data as it always was, byte for byte; `none` also
305
+ ignores the defects a model's tables list, for the clean data of a training model. A table can
306
+ also list its own defects in the model (spec 0.3.0):
307
+
308
+ ```yaml
309
+ orders:
310
+ defects:
311
+ - {type: duplicate_keys, count: 3} # fails unique
312
+ - {type: orphan_foreign_keys, column: customer_id, share: 0.02} # fails relationships
313
+ - {type: nulls, column: order_date, count: 5} # fails not_null
314
+ ```
315
+
316
+ The types are `duplicate_keys`, `orphan_foreign_keys`, `nulls`, `invalid_values` (fails
317
+ `accepted_values`), `messy_text` (whitespace and casing to clean in staging), and, with `--days`,
318
+ `late_arriving` (rows an incremental model filtering on `updated_at > max(updated_at)` skips) and
319
+ `late_updates` (versions a `strategy: timestamp` snapshot misses) and `overlapping_history` (see
320
+ below). Every run with defects writes
321
+ `defects_report.json` (each defect, the rows it broke, the tests it breaks) and
322
+ `EXPECTED_FAILURES.md` into the project, so a test that does not fire is visible. Defects are applied
323
+ after the clean data, from their own random stream: the same seed gives the same bytes, and a
324
+ defect on one table moves nothing in another. From Python, `model2data.defects.planned_defects`
325
+ says what a run will break, `model2data.output.finish_run` applies it (and builds the histories)
326
+ after `generate_days`, and `write_defects_report` / `write_expected_failures` write the two files.
327
+
328
+ ### A source that keeps its history
329
+
330
+ `history: true` in a table's `incremental` (spec 0.3.0) also writes `<table>_history`: every
331
+ version of every row over the generated days, with `valid_from`, `valid_to` (null for the current
332
+ version) and `is_current` — an SCD type 2 source to build snapshots and point-in-time joins
333
+ against. The project tests it with two package-free tests, one current row per key and no
334
+ overlapping versions; the `overlapping_history` defect breaks the second.
335
+
336
+ ```yaml
337
+ orders:
338
+ incremental: {new_per_day: 20, update_rate: 0.1, updated_at: updated_at, history: true}
339
+ ```
340
+
286
341
  ## Generated dbt project structure
287
342
 
288
343
  The generated dbt project includes:
@@ -1,7 +1,6 @@
1
1
  import random
2
- import re
3
2
  import shutil
4
- from dataclasses import replace
3
+ from collections.abc import Iterable
5
4
  from datetime import date, datetime
6
5
  from pathlib import Path
7
6
  from typing import Any, Optional
@@ -13,12 +12,19 @@ from typer.core import TyperGroup
13
12
  from typer.models import ParameterInfo
14
13
 
15
14
  from model2data.dbt.hint_tests import DEFAULT_TOLERANCE, SEVERITIES
15
+ from model2data.dbt.naming import dbt_names, for_dbt
16
16
  from model2data.dbt.project import (
17
17
  create_profiles_yml,
18
18
  create_project_scaffold,
19
19
  create_staging_models,
20
20
  )
21
21
  from model2data.dbt.tests import generate_dbt_yml, generate_unit_tests
22
+ from model2data.defects import (
23
+ DefectsReport,
24
+ planned_defects,
25
+ write_defects_report,
26
+ write_expected_failures,
27
+ )
22
28
  from model2data.generate.core import (
23
29
  generate_data_from_dbml,
24
30
  get_cyclic_tables,
@@ -36,8 +42,10 @@ from model2data.generate.faker import (
36
42
  get_unmapped_columns,
37
43
  reset_stats,
38
44
  )
45
+ from model2data.generate.history import history_tables
39
46
  from model2data.generate.options import TimeProfile
40
47
  from model2data.model import (
48
+ DEFECT_PRESETS,
41
49
  Issue,
42
50
  Model,
43
51
  ModelError,
@@ -47,7 +55,7 @@ from model2data.model import (
47
55
  to_engine,
48
56
  validate,
49
57
  )
50
- from model2data.parse.dbml import TableDef
58
+ from model2data.output import finish_run
51
59
  from model2data.utils import normalize_identifier
52
60
 
53
61
  SUPPORTED_ADAPTERS = ("duckdb", "postgres")
@@ -155,7 +163,7 @@ app = typer.Typer(
155
163
  cls=_GenerateByDefault,
156
164
  help=(
157
165
  "model2data: Generate analytics-ready datasets from a data model.\n\n"
158
- "Given a model -- a .model2data.yml document (spec 0.2.0), the same as JSON,\n"
166
+ "Given a model -- a .model2data.yml document (spec 0.3.0), the same as JSON,\n"
159
167
  "or a DBML file -- this tool produces:\n"
160
168
  "• Synthetic but realistic data\n"
161
169
  "• A runnable dbt project scaffold\n"
@@ -188,15 +196,16 @@ def _read_model(file: Path) -> Model:
188
196
  return model
189
197
 
190
198
 
191
- def _print_issues(file: Path, issues: list[Issue]) -> None:
199
+ def _print_issues(file: Path, issues: list[Issue], label: Optional[str] = None) -> None:
192
200
  """Errors, then warnings, each with its document path."""
201
+ label = label or file.name
193
202
  errors = [issue for issue in issues if issue.is_error]
194
203
  warnings = [issue for issue in issues if not issue.is_error]
195
204
  for marker, group, word in (("❌", errors, "error"), ("⚠️ ", warnings, "warning")):
196
205
  if not group:
197
206
  continue
198
207
  count = f"1 {word}" if len(group) == 1 else f"{len(group)} {word}s"
199
- typer.echo(f"{marker} {file.name}: {count}")
208
+ typer.echo(f"{marker} {label}: {count}")
200
209
  for issue in group:
201
210
  text = str(issue).removeprefix("warning: ").replace("\n", "\n ")
202
211
  typer.echo(f" - {text}")
@@ -211,37 +220,13 @@ def _model_stem(file: Path) -> str:
211
220
  return file.stem
212
221
 
213
222
 
214
- def _dbt_identifier(key: str) -> str:
215
- """A table key as the seed and model name it gets in the dbt project.
216
-
217
- Spec 0.2.0 leaves a name as written (`user accounts`, `raw.orders`) and has
218
- a consumer normalise it where it reaches a file or a dbt identifier, to
219
- `[a-z0-9_]`. Underscores are kept as they are, so `_dlt_loads` and
220
- `stories__kids` stay what they were.
221
- """
222
- cleaned = re.sub(r"[^0-9a-z_]+", "_", key.lower())
223
- if cleaned.strip("_") == "":
224
- cleaned = "table"
225
- elif cleaned[0].isdigit():
226
- cleaned = f"t_{cleaned}"
227
- return cleaned
228
-
229
-
230
- def _dbt_names(tables: dict[str, TableDef]) -> dict[str, str]:
223
+ def _dbt_names(tables: Iterable[str]) -> dict[str, str]:
231
224
  """Each table key's dbt identifier, refusing two keys that normalise alike."""
232
- names: dict[str, str] = {}
233
- owners: dict[str, str] = {}
234
- for key in tables:
235
- name = _dbt_identifier(key)
236
- if name in owners:
237
- typer.echo(
238
- f"❌ Tables {owners[name]!r} and {key!r} would both be the dbt seed {name!r}. "
239
- "Rename one of them."
240
- )
241
- raise typer.Exit(1)
242
- owners[name] = key
243
- names[key] = name
244
- return names
225
+ try:
226
+ return dbt_names(tables)
227
+ except ValueError as exc:
228
+ typer.echo(f"❌ {exc}")
229
+ raise typer.Exit(1) from None
245
230
 
246
231
 
247
232
  @app.command(
@@ -420,6 +405,18 @@ def main(
420
405
  "--next",
421
406
  help="Shorthand for --days 1.",
422
407
  ),
408
+ defects_preset: Optional[str] = typer.Option(
409
+ None,
410
+ "--defects",
411
+ metavar="PRESET",
412
+ help=(
413
+ "Break the data on purpose: 'clean' (no preset, the default), 'messy' (a mix on\n"
414
+ "every table) or 'training' (each kind of dbt test fails once); a table's own\n"
415
+ "`defects` still apply. 'none' ignores every defect, the tables' own too.\n"
416
+ "Overrides the model's run.defects. Writes defects_report.json and\n"
417
+ "EXPECTED_FAILURES.md into the project."
418
+ ),
419
+ ),
423
420
  days_format: str = typer.Option(
424
421
  "batches",
425
422
  "--days-format",
@@ -463,6 +460,14 @@ def main(
463
460
  tolerance = _given(test_tolerance)
464
461
  tolerance = DEFAULT_TOLERANCE if tolerance is None else float(tolerance)
465
462
 
463
+ preset = _given(defects_preset)
464
+ if preset is not None:
465
+ preset = preset.lower()
466
+ if preset not in DEFECT_PRESETS:
467
+ raise typer.BadParameter(
468
+ f"Choose one of: {', '.join(DEFECT_PRESETS)}.", param_hint="--defects"
469
+ )
470
+
466
471
  # -------------------------
467
472
  # Read the model (names untouched)
468
473
  # -------------------------
@@ -537,7 +542,11 @@ def main(
537
542
  if table_seeds:
538
543
  rolled = ", ".join(f"{key}={value}" for key, value in sorted(table_seeds.items()))
539
544
  typer.echo(f"🎲 Re-rolling with a table seed of its own: {rolled}")
540
- dbt_names = _dbt_names(tables)
545
+ names = _dbt_names([*tables, *history_tables(inputs)])
546
+ plan = planned_defects(model, preset, days=days or 0)
547
+ preset = preset or (run.defects if run.defects is not None else None)
548
+ if preset in ("messy", "training") and not plan:
549
+ typer.echo(f"ℹ️ The {preset} preset finds nothing to break in this model: no defects.")
541
550
 
542
551
  project_name = normalize_identifier(_given(name) or model.name or _model_stem(file))
543
552
  dest = Path.cwd() / f"dbt_{project_name}"
@@ -596,22 +605,33 @@ def main(
596
605
  typer.echo(f"❌ {exc}")
597
606
  raise typer.Exit(1) from None
598
607
 
608
+ # -------------------------
609
+ # Defects: broken on purpose, after the clean data, and reported
610
+ # -------------------------
611
+ if plan:
612
+ typer.echo("🧨 Breaking the data on purpose (defects)...")
613
+ out = finish_run(
614
+ inputs,
615
+ day_results or generated_tables,
616
+ plan,
617
+ seed=seed,
618
+ preset=preset,
619
+ as_of=anchor,
620
+ hint_tests=hint_tests,
621
+ test_tolerance=tolerance,
622
+ names=names,
623
+ )
624
+ report = out.report
625
+ generated_tables = out.frames
626
+ if day_results:
627
+ day_results = out.days
628
+
599
629
  # -------------------------
600
630
  # dbt names: a table key becomes a [a-z0-9_] seed and model name here,
601
631
  # once, so the CSV's stem, `stg_<name>` and every `ref()` agree.
602
632
  # -------------------------
603
- dbt_tables = {
604
- dbt_names[key]: replace(table, name=dbt_names[key]) for key, table in tables.items()
605
- }
606
- dbt_refs = [
607
- {
608
- **ref,
609
- "source_table": dbt_names.get(ref["source_table"], ref["source_table"]),
610
- "target_table": dbt_names.get(ref["target_table"], ref["target_table"]),
611
- }
612
- for ref in refs
613
- ]
614
- dbt_frames = {dbt_names.get(key, key): df for key, df in generated_tables.items()}
633
+ dbt_tables, dbt_refs = for_dbt(out.tables, refs, names)
634
+ dbt_frames = {names.get(key, key): df for key, df in generated_tables.items()}
615
635
 
616
636
  # -------------------------
617
637
  # Write dbt seeds (normalized names)
@@ -623,7 +643,7 @@ def main(
623
643
 
624
644
  if day_results and days_format != "final":
625
645
  write = write_batches if days_format == "batches" else write_changelog
626
- write(dest, day_results, dbt_names)
646
+ write(dest, day_results, names)
627
647
 
628
648
  # -------------------------
629
649
  # Build dbt assets
@@ -651,6 +671,10 @@ def main(
651
671
  # Keep the original model file for reference
652
672
  shutil.copy(file, dest / file.name)
653
673
 
674
+ if report is not None:
675
+ write_defects_report(report, dest)
676
+ write_expected_failures(report, dest)
677
+
654
678
  # -------------------------
655
679
  # Summary
656
680
  # -------------------------
@@ -698,6 +722,9 @@ def main(
698
722
  for label in duplicate_unique:
699
723
  typer.echo(f" - {label}")
700
724
 
725
+ if report is not None:
726
+ _print_defects(report)
727
+
701
728
  # -------------------------
702
729
  # Done
703
730
  # -------------------------
@@ -707,26 +734,121 @@ def main(
707
734
  typer.echo(" dbt build # loads the seeds, builds the models, runs every test")
708
735
 
709
736
 
737
+ 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
+
741
+
742
+ def _print_defects(report: DefectsReport) -> None:
743
+ typer.echo("\n🧨 Defects (defects_report.json, EXPECTED_FAILURES.md)")
744
+ for defect in report.defects:
745
+ column = defect.column if not isinstance(defect.column, list) else ", ".join(defect.column)
746
+ on = f".{column}" if column else ""
747
+ typer.echo(
748
+ f" {defect.table}{on}: {defect.defect}, {defect.applied} "
749
+ f"row{'' if defect.applied == 1 else 's'}"
750
+ )
751
+ if defect.note and not defect.applied:
752
+ typer.echo(f" ⚠️ {defect.note}")
753
+ count = len(report.expected_failures)
754
+ typer.echo(f" dbt build should fail {count} test{'' if count == 1 else 's'}:")
755
+ for failure in report.expected_failures:
756
+ warn = " (warn)" if failure.severity == "warn" else ""
757
+ typer.echo(f" - {failure.test}{warn}")
758
+
759
+
760
+ _SKIPPED_DIRS = {".git", ".venv", "node_modules"}
761
+
762
+
763
+ def _expand_globs(patterns: list[str]) -> list[Path]:
764
+ """Files matching recursive glob patterns relative to the cwd, sorted, without duplicates."""
765
+ found: dict[Path, None] = {}
766
+ for pattern in patterns:
767
+ for match in sorted(Path.cwd().glob(pattern)):
768
+ relative = match.relative_to(Path.cwd())
769
+ if match.is_file() and not _SKIPPED_DIRS.intersection(relative.parts):
770
+ found[relative] = None
771
+ return list(found)
772
+
773
+
774
+ def _github_escape(text: str, *, prop: bool = False) -> str:
775
+ """Escape a workflow-command message (or property value) the way GitHub expects."""
776
+ text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
777
+ return text.replace(":", "%3A").replace(",", "%2C") if prop else text
778
+
779
+
780
+ def _print_github_issues(file: Path, issues: list[Issue]) -> None:
781
+ """One `::error` / `::warning` annotation per issue, shown inline on a pull request."""
782
+ for issue in issues:
783
+ level = "error" if issue.is_error else "warning"
784
+ where = f"file={_github_escape(file.as_posix(), prop=True)}"
785
+ if issue.line:
786
+ where += f",line={issue.line}"
787
+ text = f"{issue.path}: {issue.message}" if issue.path else issue.message
788
+ typer.echo(f"::{level} {where}::{_github_escape(text)}")
789
+
790
+
710
791
  @app.command("validate")
711
792
  def validate_command(
712
- file: Path = typer.Argument( # noqa: B008
713
- ...,
793
+ files: Optional[list[Path]] = typer.Argument( # noqa: B008
794
+ None,
714
795
  exists=True,
715
796
  file_okay=True,
716
797
  dir_okay=False,
717
798
  readable=True,
718
- help="The model: a .model2data.yml / .yaml / .json document, or a .dbml file.",
799
+ help="The models: .model2data.yml / .yaml / .json documents, or .dbml files.",
800
+ ),
801
+ glob: Optional[list[str]] = typer.Option( # noqa: B008
802
+ None,
803
+ "--glob",
804
+ help=(
805
+ "A recursive glob relative to the current directory, e.g. '**/*.model2data.yml'. "
806
+ "Repeatable; expands without the shell, so CI needs no globstar."
807
+ ),
808
+ ),
809
+ output_format: str = typer.Option(
810
+ "text",
811
+ "--format",
812
+ help="'text' (default) or 'github' for ::error / ::warning annotations on a pull request.",
813
+ ),
814
+ require_files: bool = typer.Option(
815
+ False, "--require-files", help="Exit 1 when no model file was given or matched."
719
816
  ),
720
817
  ):
721
- """Check that a model conforms to spec 0.2.0, printing every issue with its path.
818
+ """Check that models conform to spec 0.3.0 (or 0.2.x), printing every issue with its path.
722
819
 
723
- Exits 1 when there is an error; warnings are printed, and the model conforms.
820
+ Takes one or more files and/or --glob patterns. Exits 1 when any file has an error;
821
+ warnings are printed, and the model conforms.
724
822
  """
725
- issues = validate(file)
726
- _print_issues(file, issues)
727
- if any(issue.is_error for issue in issues):
823
+ if output_format not in ("text", "github"):
824
+ raise typer.BadParameter("must be 'text' or 'github'", param_hint="--format")
825
+ targets = list(files or [])
826
+ if glob:
827
+ targets += [path for path in _expand_globs(glob) if path not in targets]
828
+ if not targets:
829
+ typer.echo("no model files matched")
830
+ raise typer.Exit(1 if require_files else 0)
831
+
832
+ failed = 0
833
+ for file in targets:
834
+ issues = validate(file)
835
+ if output_format == "github":
836
+ _print_github_issues(file, issues)
837
+ else:
838
+ _print_issues(file, issues, label=str(file) if len(targets) > 1 else file.name)
839
+ if any(issue.is_error for issue in issues):
840
+ failed += 1
841
+ elif output_format == "text":
842
+ typer.echo(
843
+ f"✅ {file if len(targets) > 1 else file.name} conforms to spec {_spec_of(file)}."
844
+ )
845
+ if len(targets) > 1 or output_format == "github":
846
+ checked = f"{len(targets)} model file{'s' if len(targets) != 1 else ''}"
847
+ typer.echo(
848
+ f"❌ {failed} of {checked} do not conform." if failed else f"✅ {checked} conform."
849
+ )
850
+ if failed:
728
851
  raise typer.Exit(1)
729
- typer.echo(f"✅ {file.name} conforms to spec 0.2.0.")
730
852
 
731
853
 
732
854
  @app.command("convert")
@@ -748,7 +870,8 @@ def convert_command(
748
870
  ),
749
871
  force: bool = typer.Option(False, "--force", help="Overwrite the output file if it exists."),
750
872
  ):
751
- """Convert a model -- DBML, typically -- to a spec 0.2.0 .model2data.yml document."""
873
+ """Convert a model -- DBML, typically -- to a .model2data.yml document (spec 0.2.0, or
874
+ 0.3.0 when it has defects)."""
752
875
  try:
753
876
  model = load(file)
754
877
  except ModelError as error: