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.
- {model2data-1.8.0/model2data.egg-info → model2data-1.10.0}/PKG-INFO +57 -2
- {model2data-1.8.0 → model2data-1.10.0}/README.md +98 -1
- {model2data-1.8.0 → model2data-1.10.0}/README_PYPI.md +56 -1
- {model2data-1.8.0 → model2data-1.10.0}/model2data/cli.py +183 -60
- model2data-1.10.0/model2data/dbt/naming.py +61 -0
- model2data-1.10.0/model2data/dbt/templates/history_macros/model2data_history_tests.sql +38 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/tests.py +253 -55
- model2data-1.10.0/model2data/defects/__init__.py +59 -0
- model2data-1.10.0/model2data/defects/apply.py +1064 -0
- model2data-1.10.0/model2data/defects/checks.py +179 -0
- model2data-1.10.0/model2data/defects/presets.py +311 -0
- model2data-1.10.0/model2data/defects/report.py +245 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/days.py +9 -4
- model2data-1.10.0/model2data/generate/history.py +152 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/__init__.py +7 -1
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/document.py +50 -1
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/dump.py +15 -3
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/types.py +39 -1
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/validate.py +302 -7
- model2data-1.10.0/model2data/output.py +113 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/PUBLISHING.md +13 -9
- {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/README.md +92 -3
- {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/model.schema.json +59 -5
- {model2data-1.8.0 → model2data-1.10.0/model2data.egg-info}/PKG-INFO +57 -2
- {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/SOURCES.txt +15 -0
- {model2data-1.8.0 → model2data-1.10.0}/pyproject.toml +1 -1
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_cli_model.py +92 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_tests.py +44 -0
- model2data-1.10.0/tests/test_defects.py +772 -0
- model2data-1.10.0/tests/test_defects_dbt.py +238 -0
- model2data-1.10.0/tests/test_defects_replay.py +138 -0
- model2data-1.10.0/tests/test_defects_report.py +370 -0
- model2data-1.10.0/tests/test_defects_review.py +302 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_hint_tests.py +12 -1
- model2data-1.10.0/tests/test_history.py +254 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_model_edges.py +2 -1
- model2data-1.10.0/tests/test_model_messages.py +603 -0
- model2data-1.8.0/tests/test_model_messages.py +0 -250
- {model2data-1.8.0 → model2data-1.10.0}/LICENSE +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/LICENSE +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/constants.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/database.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/_vendor/pydbml/tools.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/hint_tests.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/project.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/core.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/faker.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/hints.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/kinds.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/options.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/relationships.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/generate/timeline.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/_yaml.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/dbml.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/engine.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/errors.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/model/reader.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/parse/__init__.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/parse/dbml.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data/utils.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/requires.txt +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/model2data.egg-info/top_level.txt +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/setup.cfg +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_as_of_anchor.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_cli.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_column_time_hints.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_coverage_gaps.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_days.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbml_parser.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbml_parser_fuzz.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_integration.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_naming.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_dbt_project.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_distributions.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_examples_model.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_faker_name_inference.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_generation.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_lone_country.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_model.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_model_dbml.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_model_vocabulary.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_options.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_release_stress.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_reproducibility.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_row_identity.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_shaping.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_spec_alignment.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_spec_schema.py +0 -0
- {model2data-1.8.0 → model2data-1.10.0}/tests/test_table_seeds.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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} {
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
818
|
+
"""Check that models conform to spec 0.3.0 (or 0.2.x), printing every issue with its path.
|
|
722
819
|
|
|
723
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
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:
|