model2data-studio 0.3.0__tar.gz → 0.4.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_studio-0.3.0 → model2data_studio-0.4.0}/PKG-INFO +67 -5
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/README.md +66 -4
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/model2data_studio/__init__.py +1 -1
- model2data_studio-0.4.0/model2data_studio/building.py +367 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/model2data_studio/cli.py +231 -24
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/model2data_studio/client.py +46 -5
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/model2data_studio/mcp_server.py +235 -12
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/pyproject.toml +1 -1
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/server.json +2 -2
- model2data_studio-0.4.0/tests/test_building.py +603 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/uv.lock +1 -1
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/.gitignore +0 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/LICENSE +0 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/model2data_studio/archive.py +0 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/model2data_studio/export.py +0 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/tests/test_cli.py +0 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/tests/test_export.py +0 -0
- {model2data_studio-0.3.0 → model2data_studio-0.4.0}/tests/test_mcp_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: model2data-studio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Generate a model2data studio project's data from the command line, so CI can rebuild fixtures instead of committing them.
|
|
5
5
|
Project-URL: Homepage, https://www.jbanalytica.com/model2data/
|
|
6
6
|
Project-URL: Studio, https://studio.jbanalytica.com
|
|
@@ -70,7 +70,9 @@ the account, so put it in your CI provider's secrets rather than in a file.
|
|
|
70
70
|
Leave the key's access on **Generate and export**, which is the default and
|
|
71
71
|
covers everything below: reading a project, generating, exporting. A key with
|
|
72
72
|
**Read only** access is refused by `generate` with a 403 saying so. **Generate
|
|
73
|
-
and save** adds writing the project back, which `push`
|
|
73
|
+
and save** adds writing the project back, which `push`, `project create`, `model push`,
|
|
74
|
+
`metrics push` and `dashboard push` need (`project create` also needs a key that is not
|
|
75
|
+
limited to one project) — for a
|
|
74
76
|
pipeline that pushes a model up rather than pulling data down — and **Full access** adds
|
|
75
77
|
deleting a project and managing its share links, which the CLI never does. A
|
|
76
78
|
key that leaks out of a CI log can only do what it was given.
|
|
@@ -86,10 +88,18 @@ model2data-studio generate --project <id> --out . --format dbt --adapter duckdb
|
|
|
86
88
|
|
|
87
89
|
model2data-studio push --project <id> shop.model2data.yml # save a model as the project's
|
|
88
90
|
|
|
91
|
+
model2data-studio project create Shop --model shop.model2data.yml --metrics shop.metrics.yml
|
|
92
|
+
model2data-studio model check shop.model2data.yml # exits 1 on an error; no key needed
|
|
93
|
+
model2data-studio model push <id> shop.model2data.yml # replace the model, keep the rest
|
|
94
|
+
model2data-studio model guide # how to write one: the model spec
|
|
95
|
+
|
|
89
96
|
model2data-studio metrics list --project ID # what each metric computes
|
|
90
97
|
model2data-studio metrics check --project shop.metrics.yml # exits 1 on an error, for CI
|
|
91
98
|
model2data-studio metrics values --project ID --seed 42 --as-of 2026-10-01
|
|
92
99
|
model2data-studio metrics suggest --project shop.model2data.yml -o shop.metrics.yml
|
|
100
|
+
model2data-studio metrics check shop.metrics.yml --model shop.model2data.yml # or --project <id>
|
|
101
|
+
model2data-studio metrics push <id> shop.metrics.yml # replace the metrics file, keep the rest
|
|
102
|
+
model2data-studio metrics guide # how to write one: the metrics spec
|
|
93
103
|
|
|
94
104
|
model2data-studio dashboard pull <id> --out dashboards/ # one <name>.dashboard.yml each
|
|
95
105
|
model2data-studio dashboard push <id> dashboards/*.dashboard.yml
|
|
@@ -200,7 +210,10 @@ runs on the free plan.
|
|
|
200
210
|
| | |
|
|
201
211
|
| --- | --- |
|
|
202
212
|
| `metrics list --project TARGET` | each metric's name, label, kind, what it computes, its filter and its date column; a metric the model implies rather than the file writes is marked `*` |
|
|
203
|
-
| `metrics check --project TARGET` | every issue as `file:line: error: path: message`, the shape a CI log viewer links; exits 1 on an error, 0 on warnings alone |
|
|
213
|
+
| `metrics check --project TARGET` | every issue as `file:line:column: error: path: message`, the shape a CI log viewer links; exits 1 on an error, 0 on warnings alone |
|
|
214
|
+
| `metrics check FILE.metrics.yml --model FILE` | the same, for a metrics file against a model file (YAML, JSON or DBML), wherever the two live; `--project ID` instead reads it against a saved project's model. Needs no key with `--model` |
|
|
215
|
+
| `metrics push PROJECT_ID FILE.metrics.yml` | checks the file against the project's model, then saves it as the project's metrics file, keeping the model, dashboards and settings; says which metrics were added and removed. Nothing is saved on an error |
|
|
216
|
+
| `metrics guide` | the guide to writing a metrics file, as markdown: the metrics spec, then what the studio adds (no key needed) |
|
|
204
217
|
| `metrics values --project TARGET` | generates the data (the same run `generate` makes with the same arguments) and prints each metric's known value. `--seed`, `--rows` and `--as-of` as for `generate`; `--by-month` adds each month; `--json` prints the values shaped like the export's `metric_values.json` (its `run` and `metrics`; the spec version and rounding note are the export's own) |
|
|
205
218
|
| `metrics suggest --project TARGET` | a metrics file of the metrics the model implies, to start one from: stdout, or `-o FILE` (`--force` to replace one) |
|
|
206
219
|
|
|
@@ -254,6 +267,40 @@ A dashboard file that names something the project does not have is still saved:
|
|
|
254
267
|
that is a warning, not an error, so a dashboard can be sketched before the
|
|
255
268
|
metric it needs exists.
|
|
256
269
|
|
|
270
|
+
## Building a project: model, metrics, dashboards
|
|
271
|
+
|
|
272
|
+
A whole project can be built from files, by a pipeline or an agent, without
|
|
273
|
+
opening the studio: a model (`<stem>.model2data.yml`, model2data's open
|
|
274
|
+
[model spec](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md),
|
|
275
|
+
or DBML), a metrics file (the
|
|
276
|
+
[metrics spec](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/metrics/README.md))
|
|
277
|
+
and dashboard files. Each kind has a guide (the spec, served by the studio), a
|
|
278
|
+
check that says where each error is by line and column, and a save that checks
|
|
279
|
+
first and saves nothing on an error. Since 0.4.0.
|
|
280
|
+
|
|
281
|
+
| | |
|
|
282
|
+
| --- | --- |
|
|
283
|
+
| `project create NAME --model FILE [--metrics FILE]` | checks both, then creates the project (100 rows a table, seed 42, dates anchored on today) and prints its id and studio URL. The plan's project limit applies |
|
|
284
|
+
| `model check FILE` | every error and warning as `file:line:column: error: message`; exits 1 on an error. Needs no key |
|
|
285
|
+
| `model push PROJECT_ID FILE` | checks the model, then saves it as the project's model, keeping its metrics file, dashboards, settings and canvas; says how the tables changed, and whether the project's metrics file still reads against it |
|
|
286
|
+
| `model guide` | the guide to writing a model, as markdown (no key needed) |
|
|
287
|
+
|
|
288
|
+
DBML is saved as the model document it converts to, so the project generates
|
|
289
|
+
straight away. A save reads the project first and replaces only its own part;
|
|
290
|
+
if the project is saved elsewhere in between, it reads it again once rather
|
|
291
|
+
than writing over that save.
|
|
292
|
+
|
|
293
|
+
The order of work:
|
|
294
|
+
|
|
295
|
+
1. `model guide`, write `shop.model2data.yml`, `model check` it until it is clean.
|
|
296
|
+
2. `project create Shop --model shop.model2data.yml`: note the id it prints.
|
|
297
|
+
3. `metrics guide`, write `shop.metrics.yml`, `metrics check shop.metrics.yml --project <id>`.
|
|
298
|
+
4. `metrics push <id> shop.metrics.yml`, then `metrics values --project <id>` to see
|
|
299
|
+
that the numbers come out as intended.
|
|
300
|
+
5. `dashboard guide --project <id>`, write `sales.dashboard.yml`,
|
|
301
|
+
`dashboard check sales.dashboard.yml --project <id>`.
|
|
302
|
+
6. `dashboard push <id> sales.dashboard.yml`, and open the URL it prints.
|
|
303
|
+
|
|
257
304
|
## Regenerating dbt seeds in CI
|
|
258
305
|
|
|
259
306
|
```yaml
|
|
@@ -351,7 +398,7 @@ and for anything that reads a JSON config (Claude Desktop, Cursor, Zed):
|
|
|
351
398
|
|
|
352
399
|
`MODEL2DATA_STUDIO_URL` works here too, for a self-hosted studio.
|
|
353
400
|
|
|
354
|
-
|
|
401
|
+
Nineteen tools:
|
|
355
402
|
|
|
356
403
|
| | |
|
|
357
404
|
| --- | --- |
|
|
@@ -362,12 +409,27 @@ Twelve tools:
|
|
|
362
409
|
| `write_data` | generate and write the files, as `generate --out` does |
|
|
363
410
|
| `list_metrics` | the project's metrics, a line each: what it computes, its filter, its date column |
|
|
364
411
|
| `metric_values` | each metric's known value over a generated run, overall and by month (`seed`, `rows`, `as_of` as for `preview_data`). Pro, bar a bundled example. |
|
|
412
|
+
| `get_model_guide` | how to write a model: the open model spec, then what the studio adds (no key needed) |
|
|
413
|
+
| `check_model` | a model's errors and warnings (YAML, JSON or DBML), each with its line and column, and what it holds (no key needed) |
|
|
414
|
+
| `create_project` | checks a model and, optionally, a metrics file, then creates a project from them; answers with its id and studio URL. Needs a key not limited to one project |
|
|
415
|
+
| `save_model` | checks a model, then makes it a project's model, keeping everything else; says how the tables changed |
|
|
416
|
+
| `get_metrics_guide` | how to write a metrics file: the open metrics spec, then what the studio adds (no key needed) |
|
|
417
|
+
| `check_metrics` | a metrics file's errors and warnings, against a project's model (`project_id`) or a model's text (`model_text`, no key needed), and the metrics it defines |
|
|
418
|
+
| `save_metrics` | checks a metrics file, then makes it a project's, keeping everything else; says which metrics were added and removed |
|
|
365
419
|
| `list_dashboards` | the project's dashboards, by name |
|
|
366
420
|
| `get_dashboard` | one dashboard file's text, to edit and save back |
|
|
367
421
|
| `get_dashboard_guide` | how to write a dashboard file: the open dashboard spec, then what the studio adds (no key needed); with `project_id`, ending with that project's metrics and columns |
|
|
368
422
|
| `check_dashboard` | a dashboard file's errors and warnings, each with its line and column, without saving (no key needed without `project_id`) |
|
|
369
423
|
| `save_dashboard` | checks a dashboard file, then creates or replaces it by name; a file with errors is not saved and the errors come back. Answers with the link to the project |
|
|
370
424
|
|
|
425
|
+
The server's instructions give an agent the order of work for building a
|
|
426
|
+
project from nothing: `get_model_guide` → write the model → `check_model` →
|
|
427
|
+
`create_project` → `get_metrics_guide` → write the metrics → `check_metrics` →
|
|
428
|
+
`save_metrics` → `metric_values` to sanity-check the numbers →
|
|
429
|
+
`get_dashboard_guide` → write a dashboard → `check_dashboard` →
|
|
430
|
+
`save_dashboard` → the studio URL (and share link) to open. Every check and save
|
|
431
|
+
is the studio's; the tools move text.
|
|
432
|
+
|
|
371
433
|
`preview_data` is the one worth having. An agent that can *read* generated rows
|
|
372
434
|
can act on them — write an assertion against them, notice that `shipped_at`
|
|
373
435
|
precedes `ordered_at`, spot a column coming out as placeholder sentences. It
|
|
@@ -411,7 +473,7 @@ workflow refuses a tag that disagrees with any of them), merge, then tag the
|
|
|
411
473
|
merge commit on `main` and push the tag:
|
|
412
474
|
|
|
413
475
|
```bash
|
|
414
|
-
git tag cli-v0.
|
|
476
|
+
git tag cli-v0.4.0 && git push origin cli-v0.4.0
|
|
415
477
|
```
|
|
416
478
|
|
|
417
479
|
`.github/workflows/release-cli.yml` runs the tests, builds, publishes to PyPI
|
|
@@ -41,7 +41,9 @@ the account, so put it in your CI provider's secrets rather than in a file.
|
|
|
41
41
|
Leave the key's access on **Generate and export**, which is the default and
|
|
42
42
|
covers everything below: reading a project, generating, exporting. A key with
|
|
43
43
|
**Read only** access is refused by `generate` with a 403 saying so. **Generate
|
|
44
|
-
and save** adds writing the project back, which `push`
|
|
44
|
+
and save** adds writing the project back, which `push`, `project create`, `model push`,
|
|
45
|
+
`metrics push` and `dashboard push` need (`project create` also needs a key that is not
|
|
46
|
+
limited to one project) — for a
|
|
45
47
|
pipeline that pushes a model up rather than pulling data down — and **Full access** adds
|
|
46
48
|
deleting a project and managing its share links, which the CLI never does. A
|
|
47
49
|
key that leaks out of a CI log can only do what it was given.
|
|
@@ -57,10 +59,18 @@ model2data-studio generate --project <id> --out . --format dbt --adapter duckdb
|
|
|
57
59
|
|
|
58
60
|
model2data-studio push --project <id> shop.model2data.yml # save a model as the project's
|
|
59
61
|
|
|
62
|
+
model2data-studio project create Shop --model shop.model2data.yml --metrics shop.metrics.yml
|
|
63
|
+
model2data-studio model check shop.model2data.yml # exits 1 on an error; no key needed
|
|
64
|
+
model2data-studio model push <id> shop.model2data.yml # replace the model, keep the rest
|
|
65
|
+
model2data-studio model guide # how to write one: the model spec
|
|
66
|
+
|
|
60
67
|
model2data-studio metrics list --project ID # what each metric computes
|
|
61
68
|
model2data-studio metrics check --project shop.metrics.yml # exits 1 on an error, for CI
|
|
62
69
|
model2data-studio metrics values --project ID --seed 42 --as-of 2026-10-01
|
|
63
70
|
model2data-studio metrics suggest --project shop.model2data.yml -o shop.metrics.yml
|
|
71
|
+
model2data-studio metrics check shop.metrics.yml --model shop.model2data.yml # or --project <id>
|
|
72
|
+
model2data-studio metrics push <id> shop.metrics.yml # replace the metrics file, keep the rest
|
|
73
|
+
model2data-studio metrics guide # how to write one: the metrics spec
|
|
64
74
|
|
|
65
75
|
model2data-studio dashboard pull <id> --out dashboards/ # one <name>.dashboard.yml each
|
|
66
76
|
model2data-studio dashboard push <id> dashboards/*.dashboard.yml
|
|
@@ -171,7 +181,10 @@ runs on the free plan.
|
|
|
171
181
|
| | |
|
|
172
182
|
| --- | --- |
|
|
173
183
|
| `metrics list --project TARGET` | each metric's name, label, kind, what it computes, its filter and its date column; a metric the model implies rather than the file writes is marked `*` |
|
|
174
|
-
| `metrics check --project TARGET` | every issue as `file:line: error: path: message`, the shape a CI log viewer links; exits 1 on an error, 0 on warnings alone |
|
|
184
|
+
| `metrics check --project TARGET` | every issue as `file:line:column: error: path: message`, the shape a CI log viewer links; exits 1 on an error, 0 on warnings alone |
|
|
185
|
+
| `metrics check FILE.metrics.yml --model FILE` | the same, for a metrics file against a model file (YAML, JSON or DBML), wherever the two live; `--project ID` instead reads it against a saved project's model. Needs no key with `--model` |
|
|
186
|
+
| `metrics push PROJECT_ID FILE.metrics.yml` | checks the file against the project's model, then saves it as the project's metrics file, keeping the model, dashboards and settings; says which metrics were added and removed. Nothing is saved on an error |
|
|
187
|
+
| `metrics guide` | the guide to writing a metrics file, as markdown: the metrics spec, then what the studio adds (no key needed) |
|
|
175
188
|
| `metrics values --project TARGET` | generates the data (the same run `generate` makes with the same arguments) and prints each metric's known value. `--seed`, `--rows` and `--as-of` as for `generate`; `--by-month` adds each month; `--json` prints the values shaped like the export's `metric_values.json` (its `run` and `metrics`; the spec version and rounding note are the export's own) |
|
|
176
189
|
| `metrics suggest --project TARGET` | a metrics file of the metrics the model implies, to start one from: stdout, or `-o FILE` (`--force` to replace one) |
|
|
177
190
|
|
|
@@ -225,6 +238,40 @@ A dashboard file that names something the project does not have is still saved:
|
|
|
225
238
|
that is a warning, not an error, so a dashboard can be sketched before the
|
|
226
239
|
metric it needs exists.
|
|
227
240
|
|
|
241
|
+
## Building a project: model, metrics, dashboards
|
|
242
|
+
|
|
243
|
+
A whole project can be built from files, by a pipeline or an agent, without
|
|
244
|
+
opening the studio: a model (`<stem>.model2data.yml`, model2data's open
|
|
245
|
+
[model spec](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md),
|
|
246
|
+
or DBML), a metrics file (the
|
|
247
|
+
[metrics spec](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/metrics/README.md))
|
|
248
|
+
and dashboard files. Each kind has a guide (the spec, served by the studio), a
|
|
249
|
+
check that says where each error is by line and column, and a save that checks
|
|
250
|
+
first and saves nothing on an error. Since 0.4.0.
|
|
251
|
+
|
|
252
|
+
| | |
|
|
253
|
+
| --- | --- |
|
|
254
|
+
| `project create NAME --model FILE [--metrics FILE]` | checks both, then creates the project (100 rows a table, seed 42, dates anchored on today) and prints its id and studio URL. The plan's project limit applies |
|
|
255
|
+
| `model check FILE` | every error and warning as `file:line:column: error: message`; exits 1 on an error. Needs no key |
|
|
256
|
+
| `model push PROJECT_ID FILE` | checks the model, then saves it as the project's model, keeping its metrics file, dashboards, settings and canvas; says how the tables changed, and whether the project's metrics file still reads against it |
|
|
257
|
+
| `model guide` | the guide to writing a model, as markdown (no key needed) |
|
|
258
|
+
|
|
259
|
+
DBML is saved as the model document it converts to, so the project generates
|
|
260
|
+
straight away. A save reads the project first and replaces only its own part;
|
|
261
|
+
if the project is saved elsewhere in between, it reads it again once rather
|
|
262
|
+
than writing over that save.
|
|
263
|
+
|
|
264
|
+
The order of work:
|
|
265
|
+
|
|
266
|
+
1. `model guide`, write `shop.model2data.yml`, `model check` it until it is clean.
|
|
267
|
+
2. `project create Shop --model shop.model2data.yml`: note the id it prints.
|
|
268
|
+
3. `metrics guide`, write `shop.metrics.yml`, `metrics check shop.metrics.yml --project <id>`.
|
|
269
|
+
4. `metrics push <id> shop.metrics.yml`, then `metrics values --project <id>` to see
|
|
270
|
+
that the numbers come out as intended.
|
|
271
|
+
5. `dashboard guide --project <id>`, write `sales.dashboard.yml`,
|
|
272
|
+
`dashboard check sales.dashboard.yml --project <id>`.
|
|
273
|
+
6. `dashboard push <id> sales.dashboard.yml`, and open the URL it prints.
|
|
274
|
+
|
|
228
275
|
## Regenerating dbt seeds in CI
|
|
229
276
|
|
|
230
277
|
```yaml
|
|
@@ -322,7 +369,7 @@ and for anything that reads a JSON config (Claude Desktop, Cursor, Zed):
|
|
|
322
369
|
|
|
323
370
|
`MODEL2DATA_STUDIO_URL` works here too, for a self-hosted studio.
|
|
324
371
|
|
|
325
|
-
|
|
372
|
+
Nineteen tools:
|
|
326
373
|
|
|
327
374
|
| | |
|
|
328
375
|
| --- | --- |
|
|
@@ -333,12 +380,27 @@ Twelve tools:
|
|
|
333
380
|
| `write_data` | generate and write the files, as `generate --out` does |
|
|
334
381
|
| `list_metrics` | the project's metrics, a line each: what it computes, its filter, its date column |
|
|
335
382
|
| `metric_values` | each metric's known value over a generated run, overall and by month (`seed`, `rows`, `as_of` as for `preview_data`). Pro, bar a bundled example. |
|
|
383
|
+
| `get_model_guide` | how to write a model: the open model spec, then what the studio adds (no key needed) |
|
|
384
|
+
| `check_model` | a model's errors and warnings (YAML, JSON or DBML), each with its line and column, and what it holds (no key needed) |
|
|
385
|
+
| `create_project` | checks a model and, optionally, a metrics file, then creates a project from them; answers with its id and studio URL. Needs a key not limited to one project |
|
|
386
|
+
| `save_model` | checks a model, then makes it a project's model, keeping everything else; says how the tables changed |
|
|
387
|
+
| `get_metrics_guide` | how to write a metrics file: the open metrics spec, then what the studio adds (no key needed) |
|
|
388
|
+
| `check_metrics` | a metrics file's errors and warnings, against a project's model (`project_id`) or a model's text (`model_text`, no key needed), and the metrics it defines |
|
|
389
|
+
| `save_metrics` | checks a metrics file, then makes it a project's, keeping everything else; says which metrics were added and removed |
|
|
336
390
|
| `list_dashboards` | the project's dashboards, by name |
|
|
337
391
|
| `get_dashboard` | one dashboard file's text, to edit and save back |
|
|
338
392
|
| `get_dashboard_guide` | how to write a dashboard file: the open dashboard spec, then what the studio adds (no key needed); with `project_id`, ending with that project's metrics and columns |
|
|
339
393
|
| `check_dashboard` | a dashboard file's errors and warnings, each with its line and column, without saving (no key needed without `project_id`) |
|
|
340
394
|
| `save_dashboard` | checks a dashboard file, then creates or replaces it by name; a file with errors is not saved and the errors come back. Answers with the link to the project |
|
|
341
395
|
|
|
396
|
+
The server's instructions give an agent the order of work for building a
|
|
397
|
+
project from nothing: `get_model_guide` → write the model → `check_model` →
|
|
398
|
+
`create_project` → `get_metrics_guide` → write the metrics → `check_metrics` →
|
|
399
|
+
`save_metrics` → `metric_values` to sanity-check the numbers →
|
|
400
|
+
`get_dashboard_guide` → write a dashboard → `check_dashboard` →
|
|
401
|
+
`save_dashboard` → the studio URL (and share link) to open. Every check and save
|
|
402
|
+
is the studio's; the tools move text.
|
|
403
|
+
|
|
342
404
|
`preview_data` is the one worth having. An agent that can *read* generated rows
|
|
343
405
|
can act on them — write an assertion against them, notice that `shipped_at`
|
|
344
406
|
precedes `ordered_at`, spot a column coming out as placeholder sentences. It
|
|
@@ -382,7 +444,7 @@ workflow refuses a tag that disagrees with any of them), merge, then tag the
|
|
|
382
444
|
merge commit on `main` and push the tag:
|
|
383
445
|
|
|
384
446
|
```bash
|
|
385
|
-
git tag cli-v0.
|
|
447
|
+
git tag cli-v0.4.0 && git push origin cli-v0.4.0
|
|
386
448
|
```
|
|
387
449
|
|
|
388
450
|
`.github/workflows/release-cli.yml` runs the tests, builds, publishes to PyPI
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Building a project from files: create it from a model, then save its model and metrics.
|
|
2
|
+
|
|
3
|
+
Both the CLI (`project create`, `model push`, `metrics push`) and the MCP server
|
|
4
|
+
(`create_project`, `save_model`, `save_metrics`) go through here, so a pipeline
|
|
5
|
+
and an agent cannot come to save a project differently.
|
|
6
|
+
|
|
7
|
+
Nothing here reads a model or a metrics file. The studio checks each one first
|
|
8
|
+
(`POST /api/model/check`, `POST /api/metrics/check`), and a file with an error
|
|
9
|
+
is not saved: the error comes back as `Rejected`, each issue with its line and
|
|
10
|
+
column. A save then goes through `PUT /api/projects/{id}` the way the studio's
|
|
11
|
+
own editor saves: the whole project, read first, with only the one field
|
|
12
|
+
replaced, and the `updatedAt` it was read at, so a save that lands on someone
|
|
13
|
+
else's is refused rather than written over it (and read again, once).
|
|
14
|
+
|
|
15
|
+
DBML is saved as the model document it converts to: the studio generates from a
|
|
16
|
+
model document directly, whereas DBML needs a parse only the editor makes, so a
|
|
17
|
+
project created from DBML here would not generate until it was opened there.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .client import ChangedElsewhere, StudioClient, StudioError, dashboard_issue
|
|
29
|
+
|
|
30
|
+
# What a save keeps of the project as it is stored: everything but the text it
|
|
31
|
+
# replaces. `schema` (the editor's parse of DBML) is kept only while the text it
|
|
32
|
+
# is a parse of is.
|
|
33
|
+
KEPT_ON_SAVE = (
|
|
34
|
+
"name",
|
|
35
|
+
"positions",
|
|
36
|
+
"rows",
|
|
37
|
+
"rowOverrides",
|
|
38
|
+
"seed",
|
|
39
|
+
"asOf",
|
|
40
|
+
"locale",
|
|
41
|
+
"timeProfile",
|
|
42
|
+
"skew",
|
|
43
|
+
"adapter",
|
|
44
|
+
"defects",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# A new project's settings, as the studio's own "new project" has them: the free
|
|
48
|
+
# plan's row count, a pinned seed, dates anchored on the day it was made.
|
|
49
|
+
NEW_PROJECT_ROWS = 100
|
|
50
|
+
NEW_PROJECT_SEED = 42
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Issue:
|
|
55
|
+
"""One error or warning in a model or metrics file."""
|
|
56
|
+
|
|
57
|
+
severity: str # "error" or "warning"
|
|
58
|
+
line: int | None
|
|
59
|
+
column: int | None
|
|
60
|
+
message: str
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_error(self) -> bool:
|
|
64
|
+
return self.severity != "warning"
|
|
65
|
+
|
|
66
|
+
def text(self, where: str = "") -> str:
|
|
67
|
+
"""`where:line:column: error: message`, or `line 3, column 5: error: message`."""
|
|
68
|
+
issue = {"line": self.line, "column": self.column, "message": self.message}
|
|
69
|
+
return dashboard_issue(issue, where, self.severity)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _issue(raw: Mapping[str, Any], severity: str | None = None) -> Issue:
|
|
73
|
+
path = str(raw.get("path") or "")
|
|
74
|
+
message = str(raw.get("message") or "")
|
|
75
|
+
return Issue(
|
|
76
|
+
severity=severity or ("warning" if raw.get("severity") == "warning" else "error"),
|
|
77
|
+
line=raw.get("line"),
|
|
78
|
+
column=raw.get("column"),
|
|
79
|
+
message=f"{path}: {message}" if path else message,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def model_issues(checked: Mapping[str, Any]) -> list[Issue]:
|
|
84
|
+
"""A model check's errors, then its warnings."""
|
|
85
|
+
return [_issue(raw, "error") for raw in checked.get("errors") or []] + [
|
|
86
|
+
_issue(raw, "warning") for raw in checked.get("warnings") or []
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def metrics_issues(checked: Mapping[str, Any]) -> list[Issue]:
|
|
91
|
+
"""A metrics check's issues, errors first, as the studio sorts them."""
|
|
92
|
+
return [_issue(raw) for raw in checked.get("issues") or []]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Rejected(StudioError):
|
|
96
|
+
"""A file the studio found errors in, so nothing was saved. `which` is
|
|
97
|
+
"model" or "metrics"; `issues` are the check's, warnings included."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, which: str, issues: list[Issue]) -> None:
|
|
100
|
+
self.which = which
|
|
101
|
+
self.issues = issues
|
|
102
|
+
noun = "model" if which == "model" else "metrics file"
|
|
103
|
+
super().__init__(
|
|
104
|
+
"\n".join(
|
|
105
|
+
[f"The {noun} has errors, so nothing was saved:", *(i.text() for i in issues)]
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def describe_summary(summary: Mapping[str, Any] | None) -> str:
|
|
111
|
+
"""`3 tables (customers, orders, products), 21 columns`."""
|
|
112
|
+
if not summary:
|
|
113
|
+
return "no tables"
|
|
114
|
+
tables = int(summary.get("tables") or 0)
|
|
115
|
+
names = ", ".join(str(name) for name in summary.get("tableNames") or [])
|
|
116
|
+
columns = int(summary.get("columns") or 0)
|
|
117
|
+
return (
|
|
118
|
+
f"{tables} table{'' if tables == 1 else 's'}"
|
|
119
|
+
+ (f" ({names})" if names else "")
|
|
120
|
+
+ f", {columns} column{'' if columns == 1 else 's'}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- Checking ----------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class CheckedModel:
|
|
129
|
+
"""A model the studio read without errors: the text to save (DBML as its
|
|
130
|
+
model document), what it holds and its warnings."""
|
|
131
|
+
|
|
132
|
+
text: str
|
|
133
|
+
format: str
|
|
134
|
+
summary: dict[str, Any] | None
|
|
135
|
+
warnings: list[Issue]
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def converted(self) -> bool:
|
|
139
|
+
return self.format == "dbml"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def checked_model(client: StudioClient, text: str) -> CheckedModel:
|
|
143
|
+
"""The model, checked; raises `Rejected` when it has an error."""
|
|
144
|
+
checked = client.check_model(text)
|
|
145
|
+
issues = model_issues(checked)
|
|
146
|
+
if checked.get("ok") is False or any(issue.is_error for issue in issues):
|
|
147
|
+
raise Rejected("model", issues)
|
|
148
|
+
fmt = str(checked.get("format") or "yaml")
|
|
149
|
+
saved = str(checked.get("yaml") or "") if fmt == "dbml" else text
|
|
150
|
+
if not saved:
|
|
151
|
+
raise StudioError("The studio did not convert the DBML to a model; nothing was saved.")
|
|
152
|
+
return CheckedModel(saved, fmt, checked.get("summary"), issues)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class CheckedMetrics:
|
|
157
|
+
"""A metrics file the studio read without errors: its metric names and warnings."""
|
|
158
|
+
|
|
159
|
+
names: list[str]
|
|
160
|
+
inferred: bool
|
|
161
|
+
warnings: list[Issue]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def checked_metrics(
|
|
165
|
+
client: StudioClient,
|
|
166
|
+
text: str,
|
|
167
|
+
*,
|
|
168
|
+
model_text: str | None = None,
|
|
169
|
+
project_id: str | None = None,
|
|
170
|
+
project_name: str | None = None,
|
|
171
|
+
) -> CheckedMetrics:
|
|
172
|
+
"""The metrics file, checked against `model_text` or a saved project's model;
|
|
173
|
+
raises `Rejected` when it has an error."""
|
|
174
|
+
checked = client.check_metrics(model_text, text, project_name, project_id=project_id)
|
|
175
|
+
issues = metrics_issues(checked)
|
|
176
|
+
if any(issue.is_error for issue in issues):
|
|
177
|
+
raise Rejected("metrics", issues)
|
|
178
|
+
names = [str(metric.get("name")) for metric in checked.get("metrics") or []]
|
|
179
|
+
return CheckedMetrics(names, bool(checked.get("inferred")), issues)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- Saving -----------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _save(
|
|
186
|
+
client: StudioClient,
|
|
187
|
+
project_id: str,
|
|
188
|
+
change: Callable[[dict[str, Any]], dict[str, Any]],
|
|
189
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
190
|
+
"""Read the project, apply `change` to a body built from it, and save it,
|
|
191
|
+
reading again once if it was saved elsewhere in between. The project as it
|
|
192
|
+
was read, and as it was saved."""
|
|
193
|
+
for attempt in range(2):
|
|
194
|
+
saved = client.get_project(project_id)
|
|
195
|
+
body: dict[str, Any] = {name: saved[name] for name in KEPT_ON_SAVE if name in saved}
|
|
196
|
+
body.update(
|
|
197
|
+
{
|
|
198
|
+
"id": saved.get("id", project_id),
|
|
199
|
+
"dbml": saved.get("dbml") or "",
|
|
200
|
+
"schema": saved.get("schema"),
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
if saved.get("updatedAt"):
|
|
204
|
+
body["baseUpdatedAt"] = saved["updatedAt"]
|
|
205
|
+
if "draftRev" in saved:
|
|
206
|
+
body["draftRev"] = saved["draftRev"]
|
|
207
|
+
body.update(change(saved))
|
|
208
|
+
try:
|
|
209
|
+
return saved, client.save_project(project_id, body)
|
|
210
|
+
except ChangedElsewhere:
|
|
211
|
+
if attempt:
|
|
212
|
+
raise StudioError(
|
|
213
|
+
"The project kept changing in the studio while this was being saved, so "
|
|
214
|
+
"nothing was saved. Try again once it is quiet."
|
|
215
|
+
) from None
|
|
216
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class Created:
|
|
221
|
+
id: str
|
|
222
|
+
name: str
|
|
223
|
+
url: str
|
|
224
|
+
model: CheckedModel
|
|
225
|
+
metrics: CheckedMetrics | None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def create_project(
|
|
229
|
+
client: StudioClient,
|
|
230
|
+
name: str,
|
|
231
|
+
model_text: str,
|
|
232
|
+
metrics_text: str | None = None,
|
|
233
|
+
*,
|
|
234
|
+
new_id: Callable[[], str] = lambda: str(uuid.uuid4()),
|
|
235
|
+
today: Callable[[], dt.date] = dt.date.today,
|
|
236
|
+
) -> Created:
|
|
237
|
+
"""Check the model (and metrics file), then create a project from them.
|
|
238
|
+
|
|
239
|
+
Raises `Rejected` when either has an error, before anything is created.
|
|
240
|
+
"""
|
|
241
|
+
if not name.strip():
|
|
242
|
+
raise StudioError("A project needs a name.")
|
|
243
|
+
model = checked_model(client, model_text)
|
|
244
|
+
metrics = None
|
|
245
|
+
if metrics_text is not None and metrics_text.strip():
|
|
246
|
+
metrics = checked_metrics(
|
|
247
|
+
client, metrics_text, model_text=model.text, project_name=name.strip()
|
|
248
|
+
)
|
|
249
|
+
project_id = new_id()
|
|
250
|
+
body = {
|
|
251
|
+
"id": project_id,
|
|
252
|
+
"name": name.strip(),
|
|
253
|
+
"dbml": model.text,
|
|
254
|
+
"metrics": metrics_text or "",
|
|
255
|
+
"schema": None,
|
|
256
|
+
"positions": {},
|
|
257
|
+
"rows": NEW_PROJECT_ROWS,
|
|
258
|
+
"rowOverrides": {},
|
|
259
|
+
"seed": NEW_PROJECT_SEED,
|
|
260
|
+
"asOf": today().isoformat(),
|
|
261
|
+
"locale": None,
|
|
262
|
+
"timeProfile": None,
|
|
263
|
+
"skew": 0,
|
|
264
|
+
"adapter": "duckdb",
|
|
265
|
+
}
|
|
266
|
+
try:
|
|
267
|
+
created = client.save_project(project_id, body)
|
|
268
|
+
except StudioError as error:
|
|
269
|
+
if "No such project" in str(error):
|
|
270
|
+
# The one way a brand-new id is "not there": a key confined to one project.
|
|
271
|
+
raise StudioError(
|
|
272
|
+
"This API key is limited to one project, so it cannot create another. Use a "
|
|
273
|
+
"key that is not limited to a project, with save access."
|
|
274
|
+
) from error
|
|
275
|
+
raise StudioError(f"The project was not created. {error}") from error
|
|
276
|
+
return Created(
|
|
277
|
+
id=str(created.get("id") or project_id),
|
|
278
|
+
name=str(created.get("name") or name),
|
|
279
|
+
url=client.project_url(project_id),
|
|
280
|
+
model=model,
|
|
281
|
+
metrics=metrics,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass
|
|
286
|
+
class SavedModel:
|
|
287
|
+
name: str
|
|
288
|
+
url: str
|
|
289
|
+
model: CheckedModel
|
|
290
|
+
before: dict[str, Any] | None
|
|
291
|
+
# The project's metrics file, read against the new model: its errors and
|
|
292
|
+
# warnings (the file is kept as it is either way).
|
|
293
|
+
metrics_issues: list[Issue] = field(default_factory=list)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def save_model(client: StudioClient, project_id: str, text: str) -> SavedModel:
|
|
297
|
+
"""Check a model, then save it as the project's model, keeping everything else.
|
|
298
|
+
|
|
299
|
+
Raises `Rejected` when the model has an error. The project's metrics file
|
|
300
|
+
is kept; when it no longer reads against the new model, that is reported.
|
|
301
|
+
"""
|
|
302
|
+
model = checked_model(client, text)
|
|
303
|
+
current = client.get_project(project_id)
|
|
304
|
+
before = None
|
|
305
|
+
if (current.get("dbml") or "").strip():
|
|
306
|
+
try:
|
|
307
|
+
before = client.check_model(str(current["dbml"])).get("summary")
|
|
308
|
+
except StudioError:
|
|
309
|
+
before = None
|
|
310
|
+
metrics_text = str(current.get("metrics") or "")
|
|
311
|
+
issues: list[Issue] = []
|
|
312
|
+
if metrics_text.strip():
|
|
313
|
+
checked = client.check_metrics(model.text, metrics_text, current.get("name") or None)
|
|
314
|
+
issues = metrics_issues(checked)
|
|
315
|
+
saved, _ = _save(client, project_id, lambda _saved: {"dbml": model.text, "schema": None})
|
|
316
|
+
return SavedModel(
|
|
317
|
+
name=str(saved.get("name") or project_id),
|
|
318
|
+
url=client.project_url(project_id),
|
|
319
|
+
model=model,
|
|
320
|
+
before=before,
|
|
321
|
+
metrics_issues=issues,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class SavedMetrics:
|
|
327
|
+
name: str
|
|
328
|
+
url: str
|
|
329
|
+
metrics: CheckedMetrics
|
|
330
|
+
before: list[str] | None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def save_metrics(client: StudioClient, project_id: str, text: str) -> SavedMetrics:
|
|
334
|
+
"""Check a metrics file against the project's model, then save it as the
|
|
335
|
+
project's metrics file, keeping everything else. Raises `Rejected` when it
|
|
336
|
+
has an error."""
|
|
337
|
+
metrics = checked_metrics(client, text, project_id=project_id)
|
|
338
|
+
current = client.get_project(project_id)
|
|
339
|
+
before = None
|
|
340
|
+
try:
|
|
341
|
+
before = checked_metrics(
|
|
342
|
+
client, str(current.get("metrics") or ""), project_id=project_id
|
|
343
|
+
).names
|
|
344
|
+
except StudioError:
|
|
345
|
+
before = None
|
|
346
|
+
saved, _ = _save(client, project_id, lambda _saved: {"metrics": text})
|
|
347
|
+
return SavedMetrics(
|
|
348
|
+
name=str(saved.get("name") or project_id),
|
|
349
|
+
url=client.project_url(project_id),
|
|
350
|
+
metrics=metrics,
|
|
351
|
+
before=before,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def metrics_change(before: list[str] | None, after: list[str]) -> str:
|
|
356
|
+
"""`5 metrics (was 3): added aov, revenue; removed gmv`."""
|
|
357
|
+
head = f"{len(after)} metric{'' if len(after) == 1 else 's'}"
|
|
358
|
+
if before is None:
|
|
359
|
+
return head
|
|
360
|
+
added = [name for name in after if name not in before]
|
|
361
|
+
removed = [name for name in before if name not in after]
|
|
362
|
+
parts = []
|
|
363
|
+
if added:
|
|
364
|
+
parts.append(f"added {', '.join(added)}")
|
|
365
|
+
if removed:
|
|
366
|
+
parts.append(f"removed {', '.join(removed)}")
|
|
367
|
+
return f"{head} (was {len(before)})" + (f": {'; '.join(parts)}" if parts else "")
|