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,377 @@
|
|
|
1
|
+
"""The studio as tools an agent can call, over MCP.
|
|
2
|
+
|
|
3
|
+
The same account, the same API key and the same API as the CLI -- this is the
|
|
4
|
+
CLI's work with a different mouth on it. Which is the point: the studio is
|
|
5
|
+
where a schema is *designed*, with a diagram and a preview to design it
|
|
6
|
+
against, and the agent sitting in someone's editor is where the data is
|
|
7
|
+
wanted. Asking it for "two hundred believable orders from my webshop model"
|
|
8
|
+
should not mean pasting DBML into a chat.
|
|
9
|
+
|
|
10
|
+
Five tools, in the order someone finds them:
|
|
11
|
+
|
|
12
|
+
account what this key can do, and the caps it is under
|
|
13
|
+
list_projects which projects the account has
|
|
14
|
+
describe_project one project's tables, columns and refs, as text
|
|
15
|
+
preview_data generate and return rows inline, capped
|
|
16
|
+
write_data generate and write the files, as `generate --out` does
|
|
17
|
+
|
|
18
|
+
`preview_data` is the one that makes this worth building. An agent that can
|
|
19
|
+
*read* two hundred generated rows can reason about them -- write an assertion
|
|
20
|
+
against them, spot that `shipped_at` precedes `ordered_at`, notice a column
|
|
21
|
+
coming out as placeholder text. `write_data` is for when it has decided and
|
|
22
|
+
wants the fixtures on disk.
|
|
23
|
+
|
|
24
|
+
Everything the tools resolve -- the seed, the anchor day, the row counts --
|
|
25
|
+
goes through `export.resolve`, the same function the CLI uses, so an agent and
|
|
26
|
+
a pipeline generating from one project with the same arguments get the same
|
|
27
|
+
rows.
|
|
28
|
+
|
|
29
|
+
Run it as `model2data-studio-mcp`, with MODEL2DATA_STUDIO_KEY in the
|
|
30
|
+
environment; see the README for the client configuration.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import csv
|
|
36
|
+
import functools
|
|
37
|
+
import io
|
|
38
|
+
import os
|
|
39
|
+
import zipfile
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from . import export
|
|
44
|
+
from .archive import unpack
|
|
45
|
+
from .client import DEFAULT_URL, StudioClient, StudioError
|
|
46
|
+
|
|
47
|
+
KEY_ENV = "MODEL2DATA_STUDIO_KEY"
|
|
48
|
+
URL_ENV = "MODEL2DATA_STUDIO_URL"
|
|
49
|
+
|
|
50
|
+
# How much generated data a preview hands back.
|
|
51
|
+
#
|
|
52
|
+
# A tool result is context: it is read by a model, at a cost, and a tool that
|
|
53
|
+
# answers with a megabyte of CSV crowds out the conversation it was called in.
|
|
54
|
+
# Fifty rows is enough to see the shape of a table, spot a column coming out
|
|
55
|
+
# wrong, and write an assertion against it; the row count is always reported in
|
|
56
|
+
# full, so nothing about the run is hidden by the cap. `write_data` is the way
|
|
57
|
+
# to get all of it.
|
|
58
|
+
PREVIEW_ROWS = 50
|
|
59
|
+
PREVIEW_CHARACTERS = 20_000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _client() -> StudioClient:
|
|
63
|
+
key = os.environ.get(KEY_ENV, "")
|
|
64
|
+
if not key:
|
|
65
|
+
# Raised rather than returned so every tool reports it the same way:
|
|
66
|
+
# this is the one setup mistake that makes all five useless, and the
|
|
67
|
+
# fix is the same sentence every time.
|
|
68
|
+
raise StudioError(
|
|
69
|
+
f"No API key. Set {KEY_ENV} in this server's environment -- create a key in the "
|
|
70
|
+
f"studio's account menu."
|
|
71
|
+
)
|
|
72
|
+
return StudioClient(os.environ.get(URL_ENV) or DEFAULT_URL, key)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _schema_of(saved: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
schema = saved.get("schema")
|
|
77
|
+
if schema is None:
|
|
78
|
+
raise StudioError(
|
|
79
|
+
"This project has no saved schema yet. Open it in the studio once, which is what "
|
|
80
|
+
"parses the DBML, then try again."
|
|
81
|
+
)
|
|
82
|
+
return dict(schema)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def describe(saved: dict[str, Any]) -> str:
|
|
86
|
+
"""One project as text a model can read.
|
|
87
|
+
|
|
88
|
+
Text rather than the raw IR, deliberately. The IR is a wire format with
|
|
89
|
+
`notNull` and `compositeKeys` in it; what an agent needs to answer "what is
|
|
90
|
+
in this database" is the shape, and a line per column is both smaller and
|
|
91
|
+
clearer than the JSON it came from. The DBML itself is included at the end
|
|
92
|
+
for anything this summary flattens.
|
|
93
|
+
"""
|
|
94
|
+
schema = _schema_of(saved)
|
|
95
|
+
lines = [f"# {saved.get('name') or 'Untitled'}", ""]
|
|
96
|
+
|
|
97
|
+
settings = [
|
|
98
|
+
f"rows: {saved.get('rows', 100)}",
|
|
99
|
+
f"seed: {saved.get('seed') if saved.get('seed') is not None else 'not pinned'}",
|
|
100
|
+
f"locale: {saved.get('locale') or 'engine default'}",
|
|
101
|
+
f"dates anchored on: {saved.get('asOf') or 'the day it is generated'}",
|
|
102
|
+
]
|
|
103
|
+
lines.append("Settings: " + ", ".join(settings))
|
|
104
|
+
lines.append("")
|
|
105
|
+
|
|
106
|
+
for table in schema.get("tables", []):
|
|
107
|
+
qualified = (
|
|
108
|
+
f"{table['schemaName']}.{table['name']}" if table.get("schemaName") else table["name"]
|
|
109
|
+
)
|
|
110
|
+
lines.append(f"## {qualified}")
|
|
111
|
+
if table.get("note"):
|
|
112
|
+
lines.append(f" {table['note']}")
|
|
113
|
+
for column in table.get("columns", []):
|
|
114
|
+
marks = []
|
|
115
|
+
if column.get("pk"):
|
|
116
|
+
marks.append("pk")
|
|
117
|
+
if column.get("notNull"):
|
|
118
|
+
marks.append("not null")
|
|
119
|
+
if column.get("unique"):
|
|
120
|
+
marks.append("unique")
|
|
121
|
+
# The note is where a generation hint lives (`{"min": 5.99}` and
|
|
122
|
+
# friends), so it is the most useful thing on the line after the
|
|
123
|
+
# type -- it is what decides the values that come out.
|
|
124
|
+
if column.get("note"):
|
|
125
|
+
marks.append(f"note: {column['note']}")
|
|
126
|
+
suffix = f" [{', '.join(marks)}]" if marks else ""
|
|
127
|
+
lines.append(f" {column['name']}: {column.get('type', '')}{suffix}")
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
130
|
+
refs = schema.get("refs", [])
|
|
131
|
+
if refs:
|
|
132
|
+
lines.append("## References")
|
|
133
|
+
for ref in refs:
|
|
134
|
+
lines.append(
|
|
135
|
+
f" {ref.get('fromTable')}.{ref.get('fromColumn')} "
|
|
136
|
+
f"{ref.get('relation', '>')} "
|
|
137
|
+
f"{ref.get('toTable')}.{ref.get('toColumn')}"
|
|
138
|
+
)
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
enums = schema.get("enums", [])
|
|
142
|
+
if enums:
|
|
143
|
+
lines.append("## Enums")
|
|
144
|
+
for enum in enums:
|
|
145
|
+
lines.append(f" {enum.get('name')}: {', '.join(enum.get('values', []))}")
|
|
146
|
+
lines.append("")
|
|
147
|
+
|
|
148
|
+
lines.append("## DBML")
|
|
149
|
+
lines.append(saved.get("dbml", ""))
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def preview(archive: bytes, *, rows: int = PREVIEW_ROWS, budget: int = PREVIEW_CHARACTERS) -> str:
|
|
154
|
+
"""The generated archive as CSV a model can read, capped.
|
|
155
|
+
|
|
156
|
+
Every table is represented, however many there are: truncating the *tables*
|
|
157
|
+
would hide one entirely, which is worse than showing fewer rows of each. So
|
|
158
|
+
the per-table allowance shrinks as the schema grows, and each table says how
|
|
159
|
+
many rows it really has.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
bundle = zipfile.ZipFile(io.BytesIO(archive))
|
|
163
|
+
except zipfile.BadZipFile as error:
|
|
164
|
+
raise StudioError(
|
|
165
|
+
f"The studio returned something that isn't an archive: {error}"
|
|
166
|
+
) from error
|
|
167
|
+
|
|
168
|
+
with bundle:
|
|
169
|
+
members = [member for member in bundle.infolist() if member.filename.endswith(".csv")]
|
|
170
|
+
if not members:
|
|
171
|
+
raise StudioError("The studio's archive had no CSV in it.")
|
|
172
|
+
share = max(1, budget // len(members))
|
|
173
|
+
sections = []
|
|
174
|
+
for member in sorted(members, key=lambda item: item.filename):
|
|
175
|
+
with bundle.open(member) as handle:
|
|
176
|
+
text = handle.read().decode("utf-8", errors="replace")
|
|
177
|
+
reader = list(csv.reader(io.StringIO(text)))
|
|
178
|
+
header, body = (reader[0], reader[1:]) if reader else ([], [])
|
|
179
|
+
shown = body[:rows]
|
|
180
|
+
rendered = "\n".join(",".join(row) for row in [header, *shown])
|
|
181
|
+
if len(rendered) > share:
|
|
182
|
+
rendered = rendered[:share].rsplit("\n", 1)[0]
|
|
183
|
+
shown = shown[: rendered.count("\n")]
|
|
184
|
+
name = Path(member.filename).stem
|
|
185
|
+
note = (
|
|
186
|
+
f"{len(body)} rows"
|
|
187
|
+
if len(shown) == len(body)
|
|
188
|
+
else f"first {len(shown)} of {len(body)} rows"
|
|
189
|
+
)
|
|
190
|
+
sections.append(f"## {name} ({note})\n{rendered}")
|
|
191
|
+
return "\n\n".join(sections)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build() -> Any:
|
|
195
|
+
"""The server, built on first use.
|
|
196
|
+
|
|
197
|
+
A function rather than a module-level object so that importing this module
|
|
198
|
+
-- which the tests do, to call the logic above directly -- does not require
|
|
199
|
+
the MCP SDK to be installed. It is an optional dependency: this package is
|
|
200
|
+
installed on CI runners that want the CLI and nothing else.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
# `MCPServer` since mcp 2.0, where it was renamed from `FastMCP`.
|
|
204
|
+
from mcp.server.mcpserver import MCPServer
|
|
205
|
+
except ModuleNotFoundError as error: # pragma: no cover - depends on the install
|
|
206
|
+
raise StudioError(
|
|
207
|
+
"The MCP server needs the mcp package (2.0 or newer). Install this with its extra: "
|
|
208
|
+
"`pip install 'model2data-studio[mcp]'`."
|
|
209
|
+
) from error
|
|
210
|
+
|
|
211
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
212
|
+
|
|
213
|
+
def reported(tool):
|
|
214
|
+
"""Let the studio's own sentence reach the model.
|
|
215
|
+
|
|
216
|
+
Everything in `client.py` raises `StudioError` with a message written
|
|
217
|
+
for a person to act on -- a refused key, a plan boundary, a studio that
|
|
218
|
+
cannot be reached. Left alone, the SDK treats any exception but
|
|
219
|
+
`ToolError` as a crash and the model is told only "Error executing tool
|
|
220
|
+
<name>", which is the one thing it cannot act on. So each of these is
|
|
221
|
+
an anticipated failure, with the message carried.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
@functools.wraps(tool)
|
|
225
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
226
|
+
try:
|
|
227
|
+
return tool(*args, **kwargs)
|
|
228
|
+
except StudioError as error:
|
|
229
|
+
raise ToolError(str(error)) from error
|
|
230
|
+
|
|
231
|
+
return wrapper
|
|
232
|
+
|
|
233
|
+
server = MCPServer(
|
|
234
|
+
"model2data-studio",
|
|
235
|
+
instructions=(
|
|
236
|
+
"Realistic fake data from a schema designed in model2data studio. "
|
|
237
|
+
"`list_projects` first if no project id is at hand, `describe_project` to see what "
|
|
238
|
+
"a project holds, `preview_data` to read rows, `write_data` to put them on disk."
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@server.tool()
|
|
243
|
+
@reported
|
|
244
|
+
def account() -> str:
|
|
245
|
+
"""What this API key can do, and the limits its plan is under.
|
|
246
|
+
|
|
247
|
+
Worth reading before asking for a lot of rows: the caps are per plan,
|
|
248
|
+
and a refusal is a 402 rather than a smaller answer.
|
|
249
|
+
"""
|
|
250
|
+
with _client() as client:
|
|
251
|
+
usage = client.get_usage()
|
|
252
|
+
plan = client.get_plan() or {}
|
|
253
|
+
lines = [f"Plan: {plan.get('name', usage.get('plan', 'unknown'))}"]
|
|
254
|
+
for label, key in (
|
|
255
|
+
("Rows per table", "maxRows"),
|
|
256
|
+
("Tables", "maxTables"),
|
|
257
|
+
("Projects", "maxProjects"),
|
|
258
|
+
):
|
|
259
|
+
if key in plan:
|
|
260
|
+
lines.append(f"{label}: {plan[key] if plan[key] is not None else 'unlimited'}")
|
|
261
|
+
for label, key in (("dbt export", "dbtExport"), ("Shaping", "shaping")):
|
|
262
|
+
if key in plan:
|
|
263
|
+
lines.append(f"{label}: {'yes' if plan[key] else 'no'}")
|
|
264
|
+
return "\n".join(lines)
|
|
265
|
+
|
|
266
|
+
@server.tool()
|
|
267
|
+
@reported
|
|
268
|
+
def list_projects() -> str:
|
|
269
|
+
"""The account's saved projects: the id to pass to the other tools,
|
|
270
|
+
the name, and when it was last saved."""
|
|
271
|
+
with _client() as client:
|
|
272
|
+
projects = client.list_projects()
|
|
273
|
+
if not projects:
|
|
274
|
+
return "This account has no saved projects yet."
|
|
275
|
+
return "\n".join(
|
|
276
|
+
f"{project['id']} {project.get('name') or 'Untitled'} "
|
|
277
|
+
f"(saved {project.get('updatedAt', 'at some point')})"
|
|
278
|
+
for project in projects
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@server.tool()
|
|
282
|
+
@reported
|
|
283
|
+
def describe_project(project_id: str) -> str:
|
|
284
|
+
"""One project's tables, columns, references and enums, as text.
|
|
285
|
+
|
|
286
|
+
The column notes are included: a note holding JSON is a generation hint
|
|
287
|
+
(`{"min": 5.99, "max": 999.99}`, `{"null_rate": 0.4}`), so it is what
|
|
288
|
+
decides the values a column comes out with.
|
|
289
|
+
"""
|
|
290
|
+
with _client() as client:
|
|
291
|
+
return describe(client.get_project(project_id))
|
|
292
|
+
|
|
293
|
+
@server.tool()
|
|
294
|
+
@reported
|
|
295
|
+
def preview_data(
|
|
296
|
+
project_id: str,
|
|
297
|
+
rows: int | None = None,
|
|
298
|
+
seed: int | None = None,
|
|
299
|
+
as_of: str | None = None,
|
|
300
|
+
locale: str | None = None,
|
|
301
|
+
) -> str:
|
|
302
|
+
"""Generate this project's data and return the rows themselves.
|
|
303
|
+
|
|
304
|
+
Capped at fifty rows a table so the answer stays readable; every table
|
|
305
|
+
says how many rows it really has. Use `write_data` for the whole set.
|
|
306
|
+
|
|
307
|
+
`rows` overrides the project's row count, `seed` pins the values,
|
|
308
|
+
`as_of` (YYYY-MM-DD) pins the day dates are placed around, and `locale`
|
|
309
|
+
overrides the project's. Leave them out and the project's own settings
|
|
310
|
+
are used, which are the ones the studio previewed.
|
|
311
|
+
"""
|
|
312
|
+
with _client() as client:
|
|
313
|
+
saved = client.get_project(project_id)
|
|
314
|
+
resolved = export.resolve(
|
|
315
|
+
saved,
|
|
316
|
+
export.Overrides(rows=rows, seed=seed, as_of=as_of, locale=locale),
|
|
317
|
+
)
|
|
318
|
+
archive = client.export("csv", resolved.body)
|
|
319
|
+
head = (
|
|
320
|
+
f"{saved.get('name') or 'Untitled'}, seed {resolved.seed}, "
|
|
321
|
+
f"dates anchored on {resolved.anchor:%Y-%m-%d}"
|
|
322
|
+
)
|
|
323
|
+
if resolved.picked_seed:
|
|
324
|
+
# Said every time, because the alternative is an agent reporting
|
|
325
|
+
# rows nobody can reproduce -- and the fix is one argument.
|
|
326
|
+
head += "\n(This project has no saved seed, so one was picked. Pass it back as `seed` "
|
|
327
|
+
head += "to generate these same rows again.)"
|
|
328
|
+
return f"{head}\n\n{preview(archive)}"
|
|
329
|
+
|
|
330
|
+
@server.tool()
|
|
331
|
+
@reported
|
|
332
|
+
def write_data(
|
|
333
|
+
project_id: str,
|
|
334
|
+
out_dir: str,
|
|
335
|
+
export_format: str = "csv",
|
|
336
|
+
rows: int | None = None,
|
|
337
|
+
seed: int | None = None,
|
|
338
|
+
as_of: str | None = None,
|
|
339
|
+
locale: str | None = None,
|
|
340
|
+
force: bool = False,
|
|
341
|
+
) -> str:
|
|
342
|
+
"""Generate this project's data and write the files into `out_dir`.
|
|
343
|
+
|
|
344
|
+
`export_format` is "csv" for seed files or "dbt" for a dbt project.
|
|
345
|
+
Nothing is written if any target file already exists, unless `force` is
|
|
346
|
+
true -- half-written fixtures are worse than none.
|
|
347
|
+
"""
|
|
348
|
+
if export_format not in {"csv", "dbt"}:
|
|
349
|
+
raise StudioError(f'export_format is "csv" or "dbt", not {export_format!r}.')
|
|
350
|
+
with _client() as client:
|
|
351
|
+
saved = client.get_project(project_id)
|
|
352
|
+
resolved = export.resolve(
|
|
353
|
+
saved,
|
|
354
|
+
export.Overrides(rows=rows, seed=seed, as_of=as_of, locale=locale),
|
|
355
|
+
)
|
|
356
|
+
archive = client.export(export_format, resolved.body)
|
|
357
|
+
written = unpack(
|
|
358
|
+
archive,
|
|
359
|
+
Path(out_dir),
|
|
360
|
+
force=force,
|
|
361
|
+
force_hint="Call this again with force=true to replace them.",
|
|
362
|
+
)
|
|
363
|
+
lines = [f"Wrote {len(written)} file{'' if len(written) == 1 else 's'} to {out_dir}:"]
|
|
364
|
+
lines += [f" {path}" for path in written]
|
|
365
|
+
lines.append(f"Seed {resolved.seed}, dates anchored on {resolved.anchor:%Y-%m-%d}.")
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
return server
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main() -> None:
|
|
372
|
+
"""The entry point. stdio, which is what an editor's MCP client speaks."""
|
|
373
|
+
build().run()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__": # pragma: no cover
|
|
377
|
+
main()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: model2data-studio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate a model2data studio project's data from the command line, so CI can rebuild fixtures instead of committing them.
|
|
5
|
+
Project-URL: Homepage, https://www.jbanalytica.com/model2data/
|
|
6
|
+
Project-URL: Studio, https://studio.jbanalytica.com
|
|
7
|
+
Project-URL: Pricing, https://www.jbanalytica.com/model2data/pricing/
|
|
8
|
+
Author: JB Analytica
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dbml,dbt,fixtures,mcp,synthetic data,test data
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Requires-Dist: typer>=0.12.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mcp>=2.0.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
26
|
+
Provides-Extra: mcp
|
|
27
|
+
Requires-Dist: mcp>=2.0.0; extra == 'mcp'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# model2data-studio CLI
|
|
31
|
+
|
|
32
|
+
Pull a saved [model2data studio](https://studio.jbanalytica.com) project and
|
|
33
|
+
write its data, so a build can regenerate its fixtures instead of carrying a CSV
|
|
34
|
+
that was right once.
|
|
35
|
+
|
|
36
|
+
The schema is designed in the studio, where a diagram and a preview make it
|
|
37
|
+
worth designing. This is the part that runs afterwards, unattended, from the
|
|
38
|
+
same saved project — so the seeds a test suite runs against are the ones the
|
|
39
|
+
model says they should be, rather than whatever was committed the day someone
|
|
40
|
+
last remembered to export.
|
|
41
|
+
|
|
42
|
+
It is deliberately thin: no DBML parser, no generator, no schema knowledge. The
|
|
43
|
+
studio's API does all of that; this only knows how to ask, and where to put the
|
|
44
|
+
answer.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install model2data-studio # or run it without installing:
|
|
50
|
+
uvx model2data-studio --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It needs an API key from a **Pro** account on the studio: see
|
|
54
|
+
[pricing](https://www.jbanalytica.com/model2data/pricing/). The client is MIT
|
|
55
|
+
licensed; the studio it talks to is not open source.
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Two environment variables, both overridable by a flag:
|
|
60
|
+
|
|
61
|
+
| | flag | default |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `MODEL2DATA_STUDIO_KEY` | `--key` | required |
|
|
64
|
+
| `MODEL2DATA_STUDIO_URL` | `--url` | `https://studio.jbanalytica.com` |
|
|
65
|
+
|
|
66
|
+
Create a key in the studio's account menu. The CLI sends it as a bearer token
|
|
67
|
+
and nothing else — it never prints it, and it is the only thing that identifies
|
|
68
|
+
the account, so put it in your CI provider's secrets rather than in a file.
|
|
69
|
+
|
|
70
|
+
Leave the key's access on **Generate and export**, which is the default and
|
|
71
|
+
covers everything below: reading a project, generating, exporting. A key with
|
|
72
|
+
**Read only** access is refused by `generate` with a 403 saying so. The two
|
|
73
|
+
levels above it are for things the CLI does not do — **Generate and save** adds
|
|
74
|
+
writing the project back, for a pipeline that pushes a schema up rather than
|
|
75
|
+
pulling data down, and **Full access** adds deleting a project and managing its
|
|
76
|
+
share links. A key that leaks out of a CI log can only do what it was given.
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
model2data-studio projects # id, name, rows, last edited
|
|
82
|
+
model2data-studio whoami # the plan behind the key, and what it has used
|
|
83
|
+
|
|
84
|
+
model2data-studio generate --project <id> --out seeds/
|
|
85
|
+
model2data-studio generate --project <id> --out . --format dbt --adapter duckdb
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`generate` takes every setting from the saved project — row counts, per-table
|
|
89
|
+
overrides, seed, locale, name — and each flag overrides one of them:
|
|
90
|
+
|
|
91
|
+
| | |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `--format csv\|dbt` | seed CSVs, or a whole dbt project. dbt export is a Pro feature. |
|
|
94
|
+
| `--rows N` | the default row count for every table |
|
|
95
|
+
| `--rows-for TABLE=N` | one table's count, on top of the project's. Repeatable. |
|
|
96
|
+
| `--seed N` | pin the data. Same seed, same rows, forever. |
|
|
97
|
+
| `--as-of YYYY-MM-DD` | pin the day dates and timestamps are anchored on. Defaults to the project's, then today. |
|
|
98
|
+
| `--locale CODE` | which country's people and addresses to generate |
|
|
99
|
+
| `--business-hours` / `--no-business-hours` | weight timestamps toward weekdays and working hours. Pro. |
|
|
100
|
+
| `--growth N` | relative change in activity across the window, e.g. `0.5` for the end being half again as busy as the start. Pro. |
|
|
101
|
+
| `--seasonality N` | strength of an annual cycle peaking in Q4, `0.0`-`1.0`. Pro. |
|
|
102
|
+
| `--skew N` | how unevenly child rows spread over their parents, `0.0`-`1.0`. Pro. |
|
|
103
|
+
| `--name NAME` | the dbt project's name |
|
|
104
|
+
| `--adapter duckdb\|postgres` | the warehouse the dbt project targets |
|
|
105
|
+
| `--force` | replace files already in `--out` |
|
|
106
|
+
| `--check` | compare against `--out` instead of writing to it |
|
|
107
|
+
|
|
108
|
+
Every run also prints a row count for each table it generated, read back out
|
|
109
|
+
of the CSVs — a quick way to see what actually landed without opening a seed.
|
|
110
|
+
|
|
111
|
+
**Shaping flags name one field at a time.** `--growth` on its own overrides
|
|
112
|
+
just the growth trend; business hours and seasonality still come from the
|
|
113
|
+
project, the same way `--rows-for orders=9` overrides one table without
|
|
114
|
+
resetting every other one to the base count. Shaping is a Pro feature — a free
|
|
115
|
+
plan's key gets a 402 naming the plan boundary if the project or a flag asks
|
|
116
|
+
for anything but the uniform defaults.
|
|
117
|
+
|
|
118
|
+
**Pin a seed.** A project without one generates different rows every run, which
|
|
119
|
+
means a diff on every build and no way back to yesterday's data. If the project
|
|
120
|
+
has no seed and you pass no `--seed`, the CLI picks one and prints it, so a run
|
|
121
|
+
that surprised you can at least be reproduced.
|
|
122
|
+
|
|
123
|
+
**Pin the day too.** Dates and timestamps are generated relative to an anchor
|
|
124
|
+
day, so a seed alone reproduces its rows only for as long as that day is the
|
|
125
|
+
same. The studio pins one whenever a project's seed is on — **Dates as of**, in
|
|
126
|
+
the generation settings — and the CLI reads it back, which is what makes a run
|
|
127
|
+
here reproduce the rows the studio previewed rather than merely yesterday's CLI
|
|
128
|
+
run. `--as-of` overrides it. A project with no day pinned anchors on today — at
|
|
129
|
+
midnight, so every run on the same day agrees — and the CLI prints the day it
|
|
130
|
+
used, the same way it prints a picked seed. A pipeline that `--check`s
|
|
131
|
+
committed fixtures, or that needs a failing build to be reproducible on a
|
|
132
|
+
laptop next week, wants the day pinned one way or the other; with `--seed`, the
|
|
133
|
+
two together name one dataset for good.
|
|
134
|
+
|
|
135
|
+
**The project must have been opened in the studio at least once** since the
|
|
136
|
+
studio started saving its parse. The API never parses DBML — the browser does,
|
|
137
|
+
and posts the result — so a project with no stored parse has nothing to generate
|
|
138
|
+
from, and the CLI says so rather than failing later.
|
|
139
|
+
|
|
140
|
+
**`--check` never writes into `--out`.** It regenerates into a scratch
|
|
141
|
+
directory and diffs it against what is already there, printing what changed,
|
|
142
|
+
what is missing and what is left over, then exits 1 if they differ — so a CI
|
|
143
|
+
job can fail a build whose committed fixtures nobody regenerated after a schema
|
|
144
|
+
change, instead of shipping them stale. Exits 0 and prints `Up to date: N
|
|
145
|
+
files` when they match.
|
|
146
|
+
|
|
147
|
+
## Regenerating dbt seeds in CI
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
name: Seeds
|
|
151
|
+
|
|
152
|
+
on: [push]
|
|
153
|
+
|
|
154
|
+
jobs:
|
|
155
|
+
seeds:
|
|
156
|
+
runs-on: ubuntu-latest
|
|
157
|
+
steps:
|
|
158
|
+
- uses: actions/checkout@v4
|
|
159
|
+
- uses: astral-sh/setup-uv@v5
|
|
160
|
+
|
|
161
|
+
- name: Regenerate the seeds from the model
|
|
162
|
+
env:
|
|
163
|
+
MODEL2DATA_STUDIO_KEY: ${{ secrets.MODEL2DATA_STUDIO_KEY }}
|
|
164
|
+
run: |
|
|
165
|
+
uvx model2data-studio generate \
|
|
166
|
+
--project ${{ vars.MODEL2DATA_PROJECT_ID }} \
|
|
167
|
+
--out seeds/ --force --seed 1 --as-of 2026-01-01
|
|
168
|
+
|
|
169
|
+
- name: Build against them
|
|
170
|
+
run: uv run dbt build --target ci
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`--force` because the checkout already holds the last run's files, and `--seed`
|
|
174
|
+
with `--as-of` because a pipeline that regenerates its fixtures should still be
|
|
175
|
+
able to explain a failure — with a pinned seed and a pinned day, a failing build
|
|
176
|
+
is reproducible on a laptop, however long after it ran.
|
|
177
|
+
|
|
178
|
+
Nothing is committed back: the seeds exist for the length of the job, and the
|
|
179
|
+
model in the studio is the thing under version control. If you would rather
|
|
180
|
+
commit them, add a step that opens a pull request when `git status` comes back
|
|
181
|
+
dirty — that turns a schema change into a reviewable diff of the data it
|
|
182
|
+
produces.
|
|
183
|
+
|
|
184
|
+
## Failing CI when the committed fixtures are stale
|
|
185
|
+
|
|
186
|
+
If seeds are committed rather than regenerated on every run, `--check` is the
|
|
187
|
+
step that catches someone editing the schema in the studio and forgetting to
|
|
188
|
+
pull the new fixtures into the repo:
|
|
189
|
+
|
|
190
|
+
```yaml
|
|
191
|
+
- name: Check the committed seeds match the model
|
|
192
|
+
env:
|
|
193
|
+
MODEL2DATA_STUDIO_KEY: ${{ secrets.MODEL2DATA_STUDIO_KEY }}
|
|
194
|
+
run: |
|
|
195
|
+
uvx model2data-studio generate \
|
|
196
|
+
--project ${{ vars.MODEL2DATA_PROJECT_ID }} \
|
|
197
|
+
--out seeds/ --seed 1 --as-of 2026-01-01 --check
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
It never writes into `seeds/` — only into a scratch directory it diffs against
|
|
201
|
+
what's there — so this is safe to run against the checkout as-is, no `--force`
|
|
202
|
+
needed. `--as-of` is what lets the check pass on any day: the committed seeds
|
|
203
|
+
were generated relative to one day, and without it a schema with a date column
|
|
204
|
+
would compare against rows anchored on today and fail at the first midnight.
|
|
205
|
+
|
|
206
|
+
## The MCP server: generating from an agent instead of a shell
|
|
207
|
+
|
|
208
|
+
The same account, the same key, the same API — for the editor rather than the
|
|
209
|
+
terminal. The studio is where a schema is *designed*, with a diagram and a
|
|
210
|
+
preview to design it against; the agent in your editor is where the data is
|
|
211
|
+
wanted, and asking it for "two hundred believable orders from my webshop model"
|
|
212
|
+
should not mean pasting DBML into a chat.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
uvx --from 'model2data-studio[mcp]' model2data-studio mcp # the SDK is an extra, see below
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`uvx` fetches it on first use, so there is nothing to install beforehand. With
|
|
219
|
+
the package installed (`pip install 'model2data-studio[mcp]'`),
|
|
220
|
+
`model2data-studio-mcp` starts the same server. For Claude Code:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
claude mcp add model2data-studio \
|
|
224
|
+
--env MODEL2DATA_STUDIO_KEY=m2d_... \
|
|
225
|
+
-- uvx --from 'model2data-studio[mcp]' model2data-studio mcp
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
and for anything that reads a JSON config (Claude Desktop, Cursor, Zed):
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"mcpServers": {
|
|
233
|
+
"model2data-studio": {
|
|
234
|
+
"command": "uvx",
|
|
235
|
+
"args": ["--from", "model2data-studio[mcp]", "model2data-studio", "mcp"],
|
|
236
|
+
"env": { "MODEL2DATA_STUDIO_KEY": "m2d_..." }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`MODEL2DATA_STUDIO_URL` works here too, for a self-hosted studio.
|
|
243
|
+
|
|
244
|
+
Five tools:
|
|
245
|
+
|
|
246
|
+
| | |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| `account` | the plan and its caps, worth reading before asking for a lot of rows |
|
|
249
|
+
| `list_projects` | the ids to pass to everything else |
|
|
250
|
+
| `describe_project` | one project's tables, columns, refs and enums as text, generation hints included |
|
|
251
|
+
| `preview_data` | generate and return the rows themselves, capped at fifty a table |
|
|
252
|
+
| `write_data` | generate and write the files, as `generate --out` does |
|
|
253
|
+
|
|
254
|
+
`preview_data` is the one worth having. An agent that can *read* generated rows
|
|
255
|
+
can act on them — write an assertion against them, notice that `shipped_at`
|
|
256
|
+
precedes `ordered_at`, spot a column coming out as placeholder sentences. It
|
|
257
|
+
answers with the rows inline and says how many there really are; `write_data` is
|
|
258
|
+
for when it has decided and wants the fixtures on disk.
|
|
259
|
+
|
|
260
|
+
Both go through the same `export.resolve` the CLI's `generate` does, so an agent
|
|
261
|
+
and a pipeline asking for the same project with the same arguments get the same
|
|
262
|
+
rows — including the part where an unpinned project has a seed picked for it and
|
|
263
|
+
is told which one, so the answer can be reproduced afterwards.
|
|
264
|
+
|
|
265
|
+
The MCP SDK is an optional extra rather than a dependency, on purpose: the
|
|
266
|
+
reason this package is two dependencies is that it installs on CI runners next
|
|
267
|
+
to a build that has nothing to do with the studio, and a runner regenerating
|
|
268
|
+
fixtures does not need an MCP SDK.
|
|
269
|
+
|
|
270
|
+
## Developing
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
cd cli
|
|
274
|
+
uv sync --extra dev
|
|
275
|
+
uv run pytest
|
|
276
|
+
uv run ruff check .
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The tests drive the real Typer app, and the MCP tools through the server's own
|
|
280
|
+
`call_tool`, answering their HTTP with an `httpx.MockTransport` — so there is
|
|
281
|
+
nothing to run and nothing to reach.
|
|
282
|
+
|
|
283
|
+
## Releasing
|
|
284
|
+
|
|
285
|
+
Bump `version` in `pyproject.toml`, `__version__` in
|
|
286
|
+
`model2data_studio/__init__.py` and both versions in `server.json` (the release
|
|
287
|
+
workflow refuses a tag that disagrees with any of them), merge, then tag the
|
|
288
|
+
merge commit on `main` and push the tag:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
git tag cli-v0.1.0 && git push origin cli-v0.1.0
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`.github/workflows/release-cli.yml` runs the tests, builds, publishes to PyPI
|
|
295
|
+
through trusted publishing (no token stored anywhere) and then lists the new
|
|
296
|
+
version in the MCP registry.
|
|
297
|
+
|
|
298
|
+
<!-- mcp-name: io.github.JB-Analytica/model2data-studio -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
model2data_studio/__init__.py,sha256=sA57XNDPOECx_Cabxnmbs-o_RiSf7dv3iZ9PK1-iAA8,618
|
|
2
|
+
model2data_studio/archive.py,sha256=laBoCny0jqLnOyWgJre8damSsVAQPjLq2ldf5qkaf7o,2722
|
|
3
|
+
model2data_studio/cli.py,sha256=g9vPETtPF8fZj0jwR1bnKhHzQvl6ZcarYPP7lbRNFFU,16542
|
|
4
|
+
model2data_studio/client.py,sha256=mEMAaXYLfMlnx4adETbAS6AoztnCsMWkzJWq6qO5sgQ,8152
|
|
5
|
+
model2data_studio/export.py,sha256=uj_TLG5PAIP8BKPN-R9pq65pB7l6MjoeOO68EiC9Ffs,7868
|
|
6
|
+
model2data_studio/mcp_server.py,sha256=accMUJkqZrxIiXBN6_RIAoNN6lMAyFcoZBxyEVFujKE,15110
|
|
7
|
+
model2data_studio-0.1.0.dist-info/METADATA,sha256=bVSmjRpna4nFuEd2bc6Meon1DGXXlk1XrW4U6VSy_zQ,12866
|
|
8
|
+
model2data_studio-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
model2data_studio-0.1.0.dist-info/entry_points.txt,sha256=-hhPQO5h4B5kZ_MsHyctSomr7gI6Rh0Nob-J1ak6NQY,122
|
|
10
|
+
model2data_studio-0.1.0.dist-info/licenses/LICENSE,sha256=ChXR0VUP-pJPFIvcQTtqYQD1jW4ojdqahL5qKCWlW6k,1389
|
|
11
|
+
model2data_studio-0.1.0.dist-info/RECORD,,
|