model2data-studio 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,17 @@
1
+ """The model2data studio command line client.
2
+
3
+ A schema is designed once, so the studio itself is a place you visit and leave.
4
+ This is the part that stays: it pulls a saved project and writes its data, which
5
+ is what turns "I made some fixtures" into "CI rebuilds the fixtures every run"
6
+ and a committed, quietly rotting CSV into something regenerated from the model
7
+ it came from.
8
+
9
+ Deliberately thin. It holds no parser, no generator and no schema knowledge —
10
+ the studio's API does all of that, and this only knows how to ask.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = ["__version__"]
@@ -0,0 +1,72 @@
1
+ """Unpacking what the studio sends back.
2
+
3
+ Its own module because two callers write these files: the CLI (`generate
4
+ --out`) and the MCP server (`write_data`). The path check below is the reason
5
+ it is worth sharing rather than copying -- an archive is the classic way to
6
+ write outside the directory you were pointed at, and the directory is somebody
7
+ repository.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import shutil
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+ from .client import StudioError
18
+
19
+
20
+ def unpack(
21
+ archive: bytes, out: Path, *, force: bool, force_hint: str = "Pass --force to replace them."
22
+ ) -> list[Path]:
23
+ """Write the archive's files into `out`, or refuse before writing any.
24
+
25
+ Every target is resolved and checked first. A refusal halfway through would
26
+ leave a directory that is neither the previous export nor the new one, and
27
+ in CI that is a build that half-succeeds against half-old fixtures.
28
+
29
+ `force_hint` ends the refusal, because the way to say "do it anyway" is the
30
+ caller's word: a flag to the CLI, an argument to an MCP tool.
31
+ """
32
+ try:
33
+ bundle = zipfile.ZipFile(io.BytesIO(archive))
34
+ except zipfile.BadZipFile as error:
35
+ raise StudioError(
36
+ f"The studio returned something that isn't an archive: {error}"
37
+ ) from error
38
+
39
+ with bundle:
40
+ targets = [
41
+ (member, target_for(out, member.filename))
42
+ for member in bundle.infolist()
43
+ if not member.is_dir()
44
+ ]
45
+ if not force:
46
+ existing = [str(target) for _, target in targets if target.exists()]
47
+ if existing:
48
+ raise StudioError(
49
+ "Refusing to overwrite "
50
+ + ", ".join(existing[:5])
51
+ + (f" and {len(existing) - 5} more" if len(existing) > 5 else "")
52
+ + f". {force_hint}"
53
+ )
54
+ for member, target in targets:
55
+ target.parent.mkdir(parents=True, exist_ok=True)
56
+ with bundle.open(member) as source, target.open("wb") as sink:
57
+ shutil.copyfileobj(source, sink)
58
+ return sorted(target for _, target in targets)
59
+
60
+
61
+ def target_for(out: Path, member: str) -> Path:
62
+ """Where one archive entry lands, refusing anything that escapes `out`.
63
+
64
+ The archive comes from the studio, but an archive is still the classic way
65
+ to write outside the directory you were pointed at, and `--out` in CI is a
66
+ checkout of somebody's repository.
67
+ """
68
+ root = out.resolve()
69
+ target = (root / member).resolve()
70
+ if target != root and root not in target.parents:
71
+ raise StudioError(f"The archive contains a path outside {out}: {member!r}")
72
+ return target
@@ -0,0 +1,452 @@
1
+ """The commands themselves.
2
+
3
+ Three, and only one of them matters: `generate` is the reason this exists. It
4
+ pulls a saved project, asks the studio to regenerate it, and unpacks the archive
5
+ where a build expects to find seeds. `projects` and `whoami` are there so the
6
+ first five minutes of setting that up are not guesswork about ids and keys.
7
+
8
+ Everything is resolved from the saved project first and overridden by a flag
9
+ second, so a pipeline that passes nothing but `--project` and `--out` keeps
10
+ producing whatever the studio last previewed — the settings live with the model,
11
+ not scattered across a YAML file that drifts away from it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import csv
17
+ import datetime as dt
18
+ import enum
19
+ import tempfile
20
+ from collections.abc import Callable, Iterator, Sequence
21
+ from contextlib import contextmanager
22
+ from pathlib import Path
23
+ from typing import Any, NoReturn
24
+
25
+ import typer
26
+
27
+ from . import __version__, export
28
+ from .archive import unpack as _unpack
29
+ from .client import DEFAULT_URL, StudioClient, StudioError
30
+
31
+ KEY_ENV = "MODEL2DATA_STUDIO_KEY"
32
+ URL_ENV = "MODEL2DATA_STUDIO_URL"
33
+
34
+ app = typer.Typer(
35
+ add_completion=False,
36
+ no_args_is_help=True,
37
+ help="Generate a model2data studio project's data from the command line.",
38
+ )
39
+
40
+ # Swapped in the tests for one that hands the client an `httpx.MockTransport`.
41
+ # It is the only seam: every command below goes through it, so all of them can
42
+ # be run end to end without a studio to talk to.
43
+ client_factory: Callable[[str, str], StudioClient] = StudioClient
44
+
45
+
46
+ def _print_version(value: bool) -> None:
47
+ if value:
48
+ typer.echo(f"model2data-studio {__version__}")
49
+ raise typer.Exit()
50
+
51
+
52
+ @app.callback()
53
+ def _root(
54
+ version: bool = typer.Option(
55
+ False,
56
+ "--version",
57
+ callback=_print_version,
58
+ is_eager=True,
59
+ help="Print the version and exit.",
60
+ ),
61
+ ) -> None:
62
+ """Generate a model2data studio project's data from the command line."""
63
+
64
+
65
+ class Format(str, enum.Enum):
66
+ csv = "csv"
67
+ dbt = "dbt"
68
+
69
+
70
+ class Adapter(str, enum.Enum):
71
+ duckdb = "duckdb"
72
+ postgres = "postgres"
73
+
74
+
75
+ KeyOption = typer.Option(
76
+ None,
77
+ "--key",
78
+ envvar=KEY_ENV,
79
+ help="Studio API key. Create one in the studio's account menu.",
80
+ )
81
+ UrlOption = typer.Option(
82
+ DEFAULT_URL,
83
+ "--url",
84
+ envvar=URL_ENV,
85
+ help="The studio to talk to. Set this for a self-hosted one.",
86
+ )
87
+
88
+
89
+ def _die(message: str) -> NoReturn:
90
+ typer.secho(message, fg=typer.colors.RED, err=True)
91
+ raise typer.Exit(1)
92
+
93
+
94
+ @contextmanager
95
+ def _studio(url: str, key: str | None) -> Iterator[StudioClient]:
96
+ """A client for the duration of one command, with its failures printed.
97
+
98
+ Every `StudioError` raised inside the block — by the client or by the
99
+ command's own checks — leaves the process with a message and a non-zero
100
+ exit, which is the only behaviour a CI step reacts to.
101
+ """
102
+ if not key:
103
+ _die(
104
+ f"No API key. Pass --key, or set {KEY_ENV} to one created in the studio's account menu."
105
+ )
106
+ client = client_factory(url, key)
107
+ try:
108
+ yield client
109
+ except StudioError as error:
110
+ _die(str(error))
111
+ finally:
112
+ client.close()
113
+
114
+
115
+ def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
116
+ """Columns padded to their widest cell. Deliberately not `rich`: the output
117
+ of a CI step is a log file, and box drawing in one helps nobody."""
118
+ widths = (
119
+ [
120
+ max(len(header), *(len(row[index]) for row in rows))
121
+ for index, header in enumerate(headers)
122
+ ]
123
+ if rows
124
+ else [len(header) for header in headers]
125
+ )
126
+ lines = [
127
+ " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)).rstrip()
128
+ ]
129
+ lines.append(" ".join("-" * width for width in widths))
130
+ lines.extend(
131
+ " ".join(str(cell).ljust(widths[index]) for index, cell in enumerate(row)).rstrip()
132
+ for row in rows
133
+ )
134
+ return "\n".join(lines)
135
+
136
+
137
+ def _day(timestamp: str | None) -> str:
138
+ """The date out of an ISO instant. A saved project's clock time is noise in
139
+ a listing whose only question is "which one was I last working on"."""
140
+ return (timestamp or "")[:10] or "-"
141
+
142
+
143
+ def _words(key: str) -> str:
144
+ """`maxRows` or `max_rows` as "max rows", so a plan's limits read as prose
145
+ whatever the API decides to call its fields."""
146
+ spaced = "".join(
147
+ f" {character.lower()}" if character.isupper() else character for character in key
148
+ )
149
+ return spaced.replace("_", " ").strip()
150
+
151
+
152
+ def _limit(value: Any) -> str:
153
+ # null means unlimited everywhere in this API — the plan table, the project
154
+ # allowance, all of it.
155
+ if value is None:
156
+ return "unlimited"
157
+ if isinstance(value, bool):
158
+ return "yes" if value else "no"
159
+ return f"{value:,}" if isinstance(value, int) else str(value)
160
+
161
+
162
+ @app.command()
163
+ def projects(key: str | None = KeyOption, url: str = UrlOption) -> None:
164
+ """List the saved projects this key can see."""
165
+ with _studio(url, key) as client:
166
+ saved = client.list_projects()
167
+
168
+ if not saved:
169
+ typer.echo("No saved projects. Create one in the studio first.")
170
+ return
171
+ typer.echo(
172
+ _table(
173
+ ("id", "name", "rows", "adapter", "updated"),
174
+ [
175
+ (
176
+ str(project.get("id", "")),
177
+ str(project.get("name", "")),
178
+ str(project.get("rows", "")),
179
+ # Absent on a studio old enough to predate the column.
180
+ str(project.get("adapter", "-")),
181
+ _day(project.get("updatedAt")),
182
+ )
183
+ for project in saved
184
+ ],
185
+ )
186
+ )
187
+
188
+
189
+ @app.command()
190
+ def whoami(key: str | None = KeyOption, url: str = UrlOption) -> None:
191
+ """Show which plan this key is on, and what the account has used."""
192
+ with _studio(url, key) as client:
193
+ plan = client.get_plan()
194
+ usage = client.get_usage()
195
+
196
+ typer.echo(f"Plan: {plan.get('name') if plan else usage.get('plan', 'unknown')}")
197
+ for field, value in (plan or {}).items():
198
+ if field != "name":
199
+ typer.echo(f" {_words(field)}: {_limit(value)}")
200
+
201
+ allowance = usage.get("projects") or {}
202
+ typer.echo(f"Projects: {allowance.get('used', 0)} of {_limit(allowance.get('limit'))}")
203
+ typer.echo(f"Generations: {usage.get('generations', 0)}")
204
+
205
+
206
+ @app.command()
207
+ def mcp() -> None:
208
+ """Run the MCP server over stdio, for an editor's agent (needs the mcp extra).
209
+
210
+ The same server as `model2data-studio-mcp`, reachable through the package's
211
+ own name: a client that starts a PyPI server runs `uvx <package> ...`, which
212
+ can only call the command named after the package.
213
+ """
214
+ from . import mcp_server
215
+
216
+ try:
217
+ server = mcp_server.build()
218
+ except StudioError as error:
219
+ _die(str(error))
220
+ server.run()
221
+
222
+
223
+ @app.command()
224
+ def generate(
225
+ project_id: str = typer.Option(..., "--project", help="Project id, from `projects`."),
226
+ out: Path = typer.Option(..., "--out", help="Directory to unpack the archive into."),
227
+ export_format: Format = typer.Option(
228
+ Format.csv, "--format", help="csv seeds, or a dbt project."
229
+ ),
230
+ rows: int | None = typer.Option(
231
+ None, "--rows", min=1, help="Override the project's row count."
232
+ ),
233
+ rows_for: list[str] | None = typer.Option(
234
+ None, "--rows-for", metavar="TABLE=N", help="Override one table's row count. Repeatable."
235
+ ),
236
+ seed: int | None = typer.Option(
237
+ None, "--seed", help="Pin the data. Defaults to the project's."
238
+ ),
239
+ as_of: str | None = typer.Option(
240
+ None,
241
+ "--as-of",
242
+ metavar="YYYY-MM-DD",
243
+ help="Pin the day dates and timestamps are anchored on. Defaults to the project's, "
244
+ "then today.",
245
+ ),
246
+ locale: str | None = typer.Option(None, "--locale", help="Override the project's locale."),
247
+ business_hours: bool | None = typer.Option(
248
+ None,
249
+ "--business-hours/--no-business-hours",
250
+ help="Override the project's business-hours weighting. Pro only.",
251
+ ),
252
+ growth: float | None = typer.Option(
253
+ None,
254
+ "--growth",
255
+ min=-1.0,
256
+ help="Override the project's growth trend across the window. Pro only.",
257
+ ),
258
+ seasonality: float | None = typer.Option(
259
+ None,
260
+ "--seasonality",
261
+ min=0.0,
262
+ max=1.0,
263
+ help="Override the project's seasonal peak, 0.0-1.0. Pro only.",
264
+ ),
265
+ skew: float | None = typer.Option(
266
+ None,
267
+ "--skew",
268
+ min=0.0,
269
+ max=1.0,
270
+ help="Override how unevenly child rows spread over their parents, 0.0-1.0. Pro only.",
271
+ ),
272
+ name: str | None = typer.Option(None, "--name", help="Override the dbt project name."),
273
+ adapter: Adapter | None = typer.Option(
274
+ None, "--adapter", help="Warehouse the dbt project targets."
275
+ ),
276
+ force: bool = typer.Option(False, "--force", help="Overwrite files already in --out."),
277
+ check: bool = typer.Option(
278
+ False,
279
+ "--check",
280
+ help="Compare against --out instead of writing to it. Exits 1 if they differ.",
281
+ ),
282
+ key: str | None = KeyOption,
283
+ url: str = UrlOption,
284
+ ) -> None:
285
+ """Regenerate a saved project's data and write it to a directory."""
286
+ with _studio(url, key) as client:
287
+ # Parsed before anything is fetched, so a typo in a flag costs a
288
+ # message rather than a generation.
289
+ overrides = _row_overrides(rows_for)
290
+ # Checked before the fetch, like `--rows-for`, so a typo costs a
291
+ # message rather than a request -- and with the flag's own name in it,
292
+ # which is the only reason this is not left to `export.resolve`.
293
+ if as_of is not None:
294
+ _anchor(as_of)
295
+ saved = client.get_project(project_id)
296
+ # Every fallback the resolution makes -- the seed, the anchor day, one
297
+ # time-profile field against the project's other two -- lives in
298
+ # `export.resolve`, so the MCP server generating from the same project
299
+ # with the same arguments gets the same rows.
300
+ resolved = export.resolve(
301
+ saved,
302
+ export.Overrides(
303
+ rows=rows,
304
+ row_overrides=overrides,
305
+ seed=seed,
306
+ as_of=as_of,
307
+ locale=locale,
308
+ business_hours=business_hours,
309
+ growth=growth,
310
+ seasonality=seasonality,
311
+ skew=skew,
312
+ project_name=name,
313
+ adapter=adapter.value if adapter is not None else None,
314
+ ),
315
+ )
316
+ body = resolved.body
317
+ resolved_seed = resolved.seed
318
+ picked_seed = resolved.picked_seed
319
+ anchor = resolved.anchor
320
+ picked_anchor = resolved.picked_anchor
321
+
322
+ archive = client.export(export_format.value, body)
323
+
324
+ if check:
325
+ # Unpacked into a scratch directory rather than --out: --check
326
+ # reports on the committed files, it never touches them. Still
327
+ # inside `_studio`'s block: `_unpack` raises `StudioError` too, and
328
+ # only that block turns one into a clean message instead of a
329
+ # traceback.
330
+ with tempfile.TemporaryDirectory() as scratch:
331
+ fresh = Path(scratch)
332
+ unpacked = _unpack(archive, fresh, force=True)
333
+ _print_row_summary(fresh, export_format)
334
+ changed, missing, extra = _diff_trees(fresh, out)
335
+
336
+ if not (changed or missing or extra):
337
+ typer.echo(f"Up to date: {len(unpacked)} files")
338
+ return
339
+ for label, paths in (("Changed", changed), ("Missing", missing), ("Extra", extra)):
340
+ for path in paths:
341
+ typer.echo(f"{label}: {path}")
342
+ if picked_anchor:
343
+ # The likeliest reason a check that passed yesterday fails
344
+ # today, so it is said next to the diff rather than left for
345
+ # someone to work out from a column of dates.
346
+ typer.echo(
347
+ f"Dates were anchored on today, {anchor:%Y-%m-%d}. If the committed files "
348
+ f"were generated on another day, pass --as-of that day to compare fairly."
349
+ )
350
+ raise typer.Exit(1)
351
+
352
+ written = _unpack(archive, out, force=force)
353
+
354
+ typer.echo(f"Wrote {len(written)} file{'' if len(written) == 1 else 's'} to {out}:")
355
+ for path in written:
356
+ typer.echo(f" {path}")
357
+ _print_row_summary(out, export_format)
358
+ if picked_seed:
359
+ typer.echo(
360
+ f"This project has no saved seed, so seed {resolved_seed} was picked at "
361
+ f"random. Pass --seed {resolved_seed} to generate these same rows again."
362
+ )
363
+ if picked_anchor:
364
+ typer.echo(
365
+ f"Dates were anchored on today, {anchor:%Y-%m-%d}. Pass --as-of {anchor:%Y-%m-%d} "
366
+ f"to generate these same rows again on another day, or pin the day in the "
367
+ f"studio's generation settings."
368
+ )
369
+
370
+
371
+ def _anchor(as_of: str | None) -> dt.datetime:
372
+ """`export.anchor_from`, with the flag's name in the error.
373
+
374
+ The rule itself is shared with the MCP server; only the wording is the
375
+ CLI's, because "--as-of wants a day" is a sentence about a terminal.
376
+ """
377
+ try:
378
+ return export.anchor_from(as_of)
379
+ except StudioError:
380
+ raise StudioError(f"--as-of wants a day as YYYY-MM-DD, not {as_of!r}.") from None
381
+
382
+
383
+ def _row_overrides(values: Sequence[str] | None) -> dict[str, int]:
384
+ overrides: dict[str, int] = {}
385
+ for value in values or []:
386
+ table, separator, count = value.partition("=")
387
+ if not table or not separator or not count.isdigit() or int(count) < 1:
388
+ raise StudioError(f"--rows-for wants TABLE=N with N at least 1, not {value!r}.")
389
+ overrides[table] = int(count)
390
+ return overrides
391
+
392
+
393
+ def _diff_trees(generated: Path, out: Path) -> tuple[list[str], list[str], list[str]]:
394
+ """How a freshly generated tree differs from what `--out` already holds.
395
+
396
+ Three ways two trees can disagree, each meaning something different in CI:
397
+ a file in both with different bytes is a schema change nobody regenerated
398
+ fixtures for; one only in the fresh generation is new; one only in `--out`
399
+ is what a table or column dropped from the schema leaves behind, still
400
+ committed. `out` need not exist at all — a first run — in which case
401
+ everything generated is reported missing.
402
+ """
403
+ fresh = {path.relative_to(generated) for path in generated.rglob("*") if path.is_file()}
404
+ committed = (
405
+ {path.relative_to(out) for path in out.rglob("*") if path.is_file()}
406
+ if out.exists()
407
+ else set()
408
+ )
409
+
410
+ changed = sorted(
411
+ str(rel)
412
+ for rel in fresh & committed
413
+ if (generated / rel).read_bytes() != (out / rel).read_bytes()
414
+ )
415
+ missing = sorted(str(rel) for rel in fresh - committed)
416
+ extra = sorted(str(rel) for rel in committed - fresh)
417
+ return changed, missing, extra
418
+
419
+
420
+ def _csv_row_count(path: Path) -> int:
421
+ """Rows in a CSV, minus its header.
422
+
423
+ `csv.reader` rather than counting newlines: a free-text column can carry
424
+ one inside a quoted value, which would count one row as two.
425
+ """
426
+ with path.open(newline="") as handle:
427
+ return sum(1 for _ in csv.reader(handle)) - 1
428
+
429
+
430
+ def _row_counts(root: Path, export_format: Format) -> list[tuple[str, int]]:
431
+ """Table name and row count, read back out of the generated CSVs.
432
+
433
+ A csv export writes one seed per table at the top level of `--out`; a dbt
434
+ project nests them under `seeds/raw/`, alongside the models and tests that
435
+ reference them. Read back off disk rather than carried down from
436
+ generation itself, so this reports what actually landed there.
437
+ """
438
+ pattern = "**/seeds/raw/*.csv" if export_format == Format.dbt else "*.csv"
439
+ return sorted((csv_path.stem, _csv_row_count(csv_path)) for csv_path in root.glob(pattern))
440
+
441
+
442
+ def _print_row_summary(root: Path, export_format: Format) -> None:
443
+ """A per-table row count, so a CI log shows what was generated without
444
+ anyone having to open the seeds themselves."""
445
+ counts = _row_counts(root, export_format)
446
+ if not counts:
447
+ return
448
+ typer.echo(_table(("table", "rows"), [(name, f"{count:,}") for name, count in counts]))
449
+
450
+
451
+ if __name__ == "__main__": # pragma: no cover
452
+ app()