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.
- model2data_studio/__init__.py +17 -0
- model2data_studio/archive.py +72 -0
- model2data_studio/cli.py +452 -0
- model2data_studio/client.py +197 -0
- model2data_studio/export.py +191 -0
- model2data_studio/mcp_server.py +377 -0
- model2data_studio-0.1.0.dist-info/METADATA +298 -0
- model2data_studio-0.1.0.dist-info/RECORD +11 -0
- model2data_studio-0.1.0.dist-info/WHEEL +4 -0
- model2data_studio-0.1.0.dist-info/entry_points.txt +3 -0
- model2data_studio-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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
|
model2data_studio/cli.py
ADDED
|
@@ -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()
|