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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: model2data-studio
3
- Version: 0.3.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` and `dashboard push` need — for a
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
- Twelve tools:
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.3.0 && git push origin cli-v0.3.0
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` and `dashboard push` need — for a
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
- Twelve tools:
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.3.0 && git push origin cli-v0.3.0
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
@@ -12,6 +12,6 @@ the studio's API does all of that, and this only knows how to ask.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- __version__ = "0.3.0"
15
+ __version__ = "0.4.0"
16
16
 
17
17
  __all__ = ["__version__"]
@@ -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 "")