model2data 1.9.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 (159) hide show
  1. {model2data-1.9.0/model2data.egg-info → model2data-1.10.0}/PKG-INFO +7 -1
  2. {model2data-1.9.0 → model2data-1.10.0}/README.md +48 -0
  3. {model2data-1.9.0 → model2data-1.10.0}/README_PYPI.md +6 -0
  4. {model2data-1.9.0 → model2data-1.10.0}/model2data/cli.py +84 -11
  5. {model2data-1.9.0 → model2data-1.10.0/model2data.egg-info}/PKG-INFO +7 -1
  6. {model2data-1.9.0 → model2data-1.10.0}/pyproject.toml +1 -1
  7. {model2data-1.9.0 → model2data-1.10.0}/tests/test_cli_model.py +92 -0
  8. {model2data-1.9.0 → model2data-1.10.0}/LICENSE +0 -0
  9. {model2data-1.9.0 → model2data-1.10.0}/model2data/__init__.py +0 -0
  10. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/__init__.py +0 -0
  11. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  12. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  13. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  14. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  15. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  16. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  17. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  18. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  19. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  20. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  21. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  22. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  23. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  24. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  25. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  26. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  27. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/constants.py +0 -0
  28. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/database.py +0 -0
  29. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  30. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  31. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  32. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  33. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  34. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  35. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  36. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  37. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  38. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  39. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  40. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  41. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  42. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  43. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  44. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  45. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  46. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  47. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  48. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  49. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  50. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  51. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  52. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  53. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  54. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  55. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  56. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  57. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  58. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  59. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  60. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  61. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  62. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  63. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  64. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  65. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  66. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  67. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  68. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  69. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  70. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  71. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  72. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  73. {model2data-1.9.0 → model2data-1.10.0}/model2data/_vendor/pydbml/tools.py +0 -0
  74. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/__init__.py +0 -0
  75. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/hint_tests.py +0 -0
  76. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/naming.py +0 -0
  77. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/project.py +0 -0
  78. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  79. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  80. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  81. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  82. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  83. {model2data-1.9.0 → model2data-1.10.0}/model2data/dbt/tests.py +0 -0
  84. {model2data-1.9.0 → model2data-1.10.0}/model2data/defects/__init__.py +0 -0
  85. {model2data-1.9.0 → model2data-1.10.0}/model2data/defects/apply.py +0 -0
  86. {model2data-1.9.0 → model2data-1.10.0}/model2data/defects/checks.py +0 -0
  87. {model2data-1.9.0 → model2data-1.10.0}/model2data/defects/presets.py +0 -0
  88. {model2data-1.9.0 → model2data-1.10.0}/model2data/defects/report.py +0 -0
  89. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/__init__.py +0 -0
  90. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/core.py +0 -0
  91. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/days.py +0 -0
  92. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/faker.py +0 -0
  93. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/hints.py +0 -0
  94. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/history.py +0 -0
  95. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/kinds.py +0 -0
  96. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/options.py +0 -0
  97. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/relationships.py +0 -0
  98. {model2data-1.9.0 → model2data-1.10.0}/model2data/generate/timeline.py +0 -0
  99. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/__init__.py +0 -0
  100. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/_yaml.py +0 -0
  101. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/dbml.py +0 -0
  102. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/document.py +0 -0
  103. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/dump.py +0 -0
  104. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/engine.py +0 -0
  105. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/errors.py +0 -0
  106. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/reader.py +0 -0
  107. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/types.py +0 -0
  108. {model2data-1.9.0 → model2data-1.10.0}/model2data/model/validate.py +0 -0
  109. {model2data-1.9.0 → model2data-1.10.0}/model2data/output.py +0 -0
  110. {model2data-1.9.0 → model2data-1.10.0}/model2data/parse/__init__.py +0 -0
  111. {model2data-1.9.0 → model2data-1.10.0}/model2data/parse/dbml.py +0 -0
  112. {model2data-1.9.0 → model2data-1.10.0}/model2data/spec/PUBLISHING.md +0 -0
  113. {model2data-1.9.0 → model2data-1.10.0}/model2data/spec/README.md +0 -0
  114. {model2data-1.9.0 → model2data-1.10.0}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
  115. {model2data-1.9.0 → model2data-1.10.0}/model2data/spec/model.schema.json +0 -0
  116. {model2data-1.9.0 → model2data-1.10.0}/model2data/utils.py +0 -0
  117. {model2data-1.9.0 → model2data-1.10.0}/model2data.egg-info/SOURCES.txt +0 -0
  118. {model2data-1.9.0 → model2data-1.10.0}/model2data.egg-info/dependency_links.txt +0 -0
  119. {model2data-1.9.0 → model2data-1.10.0}/model2data.egg-info/entry_points.txt +0 -0
  120. {model2data-1.9.0 → model2data-1.10.0}/model2data.egg-info/requires.txt +0 -0
  121. {model2data-1.9.0 → model2data-1.10.0}/model2data.egg-info/top_level.txt +0 -0
  122. {model2data-1.9.0 → model2data-1.10.0}/setup.cfg +0 -0
  123. {model2data-1.9.0 → model2data-1.10.0}/tests/test_as_of_anchor.py +0 -0
  124. {model2data-1.9.0 → model2data-1.10.0}/tests/test_cli.py +0 -0
  125. {model2data-1.9.0 → model2data-1.10.0}/tests/test_column_time_hints.py +0 -0
  126. {model2data-1.9.0 → model2data-1.10.0}/tests/test_coverage_gaps.py +0 -0
  127. {model2data-1.9.0 → model2data-1.10.0}/tests/test_days.py +0 -0
  128. {model2data-1.9.0 → model2data-1.10.0}/tests/test_dbml_parser.py +0 -0
  129. {model2data-1.9.0 → model2data-1.10.0}/tests/test_dbml_parser_fuzz.py +0 -0
  130. {model2data-1.9.0 → model2data-1.10.0}/tests/test_dbt_integration.py +0 -0
  131. {model2data-1.9.0 → model2data-1.10.0}/tests/test_dbt_naming.py +0 -0
  132. {model2data-1.9.0 → model2data-1.10.0}/tests/test_dbt_project.py +0 -0
  133. {model2data-1.9.0 → model2data-1.10.0}/tests/test_dbt_tests.py +0 -0
  134. {model2data-1.9.0 → model2data-1.10.0}/tests/test_defects.py +0 -0
  135. {model2data-1.9.0 → model2data-1.10.0}/tests/test_defects_dbt.py +0 -0
  136. {model2data-1.9.0 → model2data-1.10.0}/tests/test_defects_replay.py +0 -0
  137. {model2data-1.9.0 → model2data-1.10.0}/tests/test_defects_report.py +0 -0
  138. {model2data-1.9.0 → model2data-1.10.0}/tests/test_defects_review.py +0 -0
  139. {model2data-1.9.0 → model2data-1.10.0}/tests/test_distributions.py +0 -0
  140. {model2data-1.9.0 → model2data-1.10.0}/tests/test_examples_model.py +0 -0
  141. {model2data-1.9.0 → model2data-1.10.0}/tests/test_faker_name_inference.py +0 -0
  142. {model2data-1.9.0 → model2data-1.10.0}/tests/test_generation.py +0 -0
  143. {model2data-1.9.0 → model2data-1.10.0}/tests/test_hint_tests.py +0 -0
  144. {model2data-1.9.0 → model2data-1.10.0}/tests/test_history.py +0 -0
  145. {model2data-1.9.0 → model2data-1.10.0}/tests/test_lone_country.py +0 -0
  146. {model2data-1.9.0 → model2data-1.10.0}/tests/test_model.py +0 -0
  147. {model2data-1.9.0 → model2data-1.10.0}/tests/test_model_dbml.py +0 -0
  148. {model2data-1.9.0 → model2data-1.10.0}/tests/test_model_edges.py +0 -0
  149. {model2data-1.9.0 → model2data-1.10.0}/tests/test_model_messages.py +0 -0
  150. {model2data-1.9.0 → model2data-1.10.0}/tests/test_model_vocabulary.py +0 -0
  151. {model2data-1.9.0 → model2data-1.10.0}/tests/test_options.py +0 -0
  152. {model2data-1.9.0 → model2data-1.10.0}/tests/test_release_stress.py +0 -0
  153. {model2data-1.9.0 → model2data-1.10.0}/tests/test_reproducibility.py +0 -0
  154. {model2data-1.9.0 → model2data-1.10.0}/tests/test_row_identity.py +0 -0
  155. {model2data-1.9.0 → model2data-1.10.0}/tests/test_shaping.py +0 -0
  156. {model2data-1.9.0 → model2data-1.10.0}/tests/test_spec_alignment.py +0 -0
  157. {model2data-1.9.0 → model2data-1.10.0}/tests/test_spec_schema.py +0 -0
  158. {model2data-1.9.0 → model2data-1.10.0}/tests/test_table_seeds.py +0 -0
  159. {model2data-1.9.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.9.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
@@ -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
@@ -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
@@ -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
@@ -196,15 +196,16 @@ def _read_model(file: Path) -> Model:
196
196
  return model
197
197
 
198
198
 
199
- def _print_issues(file: Path, issues: list[Issue]) -> None:
199
+ def _print_issues(file: Path, issues: list[Issue], label: Optional[str] = None) -> None:
200
200
  """Errors, then warnings, each with its document path."""
201
+ label = label or file.name
201
202
  errors = [issue for issue in issues if issue.is_error]
202
203
  warnings = [issue for issue in issues if not issue.is_error]
203
204
  for marker, group, word in (("❌", errors, "error"), ("⚠️ ", warnings, "warning")):
204
205
  if not group:
205
206
  continue
206
207
  count = f"1 {word}" if len(group) == 1 else f"{len(group)} {word}s"
207
- typer.echo(f"{marker} {file.name}: {count}")
208
+ typer.echo(f"{marker} {label}: {count}")
208
209
  for issue in group:
209
210
  text = str(issue).removeprefix("warning: ").replace("\n", "\n ")
210
211
  typer.echo(f" - {text}")
@@ -756,26 +757,98 @@ def _print_defects(report: DefectsReport) -> None:
756
757
  typer.echo(f" - {failure.test}{warn}")
757
758
 
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
+
759
791
  @app.command("validate")
760
792
  def validate_command(
761
- file: Path = typer.Argument( # noqa: B008
762
- ...,
793
+ files: Optional[list[Path]] = typer.Argument( # noqa: B008
794
+ None,
763
795
  exists=True,
764
796
  file_okay=True,
765
797
  dir_okay=False,
766
798
  readable=True,
767
- 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."
768
816
  ),
769
817
  ):
770
- """Check that a model conforms to spec 0.3.0 (or 0.2.x), 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.
771
819
 
772
- 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.
773
822
  """
774
- issues = validate(file)
775
- _print_issues(file, issues)
776
- 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:
777
851
  raise typer.Exit(1)
778
- typer.echo(f"✅ {file.name} conforms to spec {_spec_of(file)}.")
779
852
 
780
853
 
781
854
  @app.command("convert")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.9.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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model2data"
7
- version = "1.9.0"
7
+ version = "1.10.0"
8
8
  description = "Generate analytics-ready datasets from DBML models"
9
9
  readme = "README_PYPI.md"
10
10
  requires-python = ">=3.10"
@@ -246,3 +246,95 @@ def test_help_lists_the_commands():
246
246
  assert result.exit_code == 0
247
247
  for command in ("generate", "validate", "convert"):
248
248
  assert command in result.output
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # validate: several files, --glob, --format github, and the CI integration files
253
+ # ---------------------------------------------------------------------------
254
+ def _broken(tmp_path, name="bad.model2data.yml"):
255
+ return _write(tmp_path, name, MODEL.replace("rows: 12", "rows: 0"))
256
+
257
+
258
+ def test_validate_several_files_all_valid(tmp_path):
259
+ a = _write(tmp_path, "a.model2data.yml", MODEL)
260
+ b = _write(tmp_path, "b.model2data.yml", MODEL)
261
+ result = runner.invoke(app, ["validate", str(a), str(b)])
262
+ assert result.exit_code == 0, result.output
263
+ assert f"✅ {a} conforms to spec 0.2.0." in result.output
264
+ assert "✅ 2 model files conform." in result.output
265
+
266
+
267
+ def test_validate_several_files_one_invalid_exits_1_with_path_prefixed_issues(tmp_path):
268
+ good = _write(tmp_path, "good.model2data.yml", MODEL)
269
+ bad = _broken(tmp_path)
270
+ result = runner.invoke(app, ["validate", str(good), str(bad)])
271
+ assert result.exit_code == 1
272
+ assert f"❌ {bad}: 1 error" in result.output
273
+ assert " - run.rows: must be 1 or more (got 0)" in result.output
274
+ assert "❌ 1 of 2 model files do not conform." in result.output
275
+
276
+
277
+ def test_validate_glob_matches_nested_files(tmp_path, monkeypatch):
278
+ (tmp_path / "deep" / "er").mkdir(parents=True)
279
+ _write(tmp_path, "top.model2data.yml", MODEL)
280
+ _write(tmp_path / "deep" / "er", "nested.model2data.yml", MODEL)
281
+ (tmp_path / "node_modules").mkdir()
282
+ _write(tmp_path / "node_modules", "skipped.model2data.yml", MODEL)
283
+ monkeypatch.chdir(tmp_path)
284
+ result = runner.invoke(app, ["validate", "--glob", "**/*.model2data.yml"])
285
+ assert result.exit_code == 0, result.output
286
+ assert "top.model2data.yml" in result.output
287
+ assert "nested.model2data.yml" in result.output
288
+ assert "skipped" not in result.output
289
+ assert "2 model files conform" in result.output
290
+
291
+
292
+ def test_validate_glob_with_no_match_is_a_note_unless_files_are_required(tmp_path, monkeypatch):
293
+ monkeypatch.chdir(tmp_path)
294
+ result = runner.invoke(app, ["validate", "--glob", "**/*.model2data.yml"])
295
+ assert result.exit_code == 0
296
+ assert "no model files matched" in result.output
297
+ required = runner.invoke(app, ["validate", "--glob", "**/*.model2data.yml", "--require-files"])
298
+ assert required.exit_code == 1
299
+ assert "no model files matched" in required.output
300
+
301
+
302
+ def test_validate_format_github_prints_annotations(tmp_path, monkeypatch):
303
+ _broken(tmp_path)
304
+ monkeypatch.chdir(tmp_path)
305
+ result = runner.invoke(app, ["validate", "bad.model2data.yml", "--format", "github"])
306
+ assert result.exit_code == 1
307
+ assert "::error file=bad.model2data.yml::run.rows: must be 1 or more (got 0)" in result.output
308
+ yaml_error = _write(tmp_path, "dup.model2data.yml", MODEL + "name: again\n")
309
+ result = runner.invoke(app, ["validate", yaml_error.name, "--format", "github"])
310
+ assert "::error file=dup.model2data.yml,line=25::" in result.output
311
+
312
+
313
+ def test_validate_format_github_prints_warnings_and_exits_0():
314
+ result = runner.invoke(
315
+ app, ["validate", str(EXAMPLES / "hackernews.dbml"), "--format", "github"]
316
+ )
317
+ assert result.exit_code == 0
318
+ assert "::warning file=" in result.output
319
+
320
+
321
+ def test_validate_rejects_an_unknown_format(tmp_path):
322
+ model = _write(tmp_path, "m.model2data.yml", MODEL)
323
+ assert runner.invoke(app, ["validate", str(model), "--format", "xml"]).exit_code != 0
324
+
325
+
326
+ def test_ci_integration_files_run_the_validate_command():
327
+ import yaml
328
+
329
+ root = Path(__file__).resolve().parent.parent
330
+ action = yaml.safe_load((root / "action.yml").read_text())
331
+ assert action["runs"]["using"] == "composite"
332
+ assert set(action["inputs"]) == {"files", "version", "python-version"}
333
+ steps = " ".join(str(step.get("run", "")) for step in action["runs"]["steps"])
334
+ assert "model2data validate --glob" in steps
335
+ assert "--format github" in steps
336
+ hooks = yaml.safe_load((root / ".pre-commit-hooks.yaml").read_text())
337
+ assert hooks[0]["id"] == "model2data-validate"
338
+ assert hooks[0]["entry"] == "model2data validate"
339
+ assert hooks[0]["language"] == "python"
340
+ assert hooks[0]["pass_filenames"] is True
File without changes
File without changes