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,197 @@
|
|
|
1
|
+
"""HTTP against a studio, and its failures turned into sentences.
|
|
2
|
+
|
|
3
|
+
Every request the CLI makes goes through here, so there is one place that knows
|
|
4
|
+
what a status code means to someone standing at a terminal. A CLI failure is
|
|
5
|
+
usually read out of a CI log by whoever did not write the pipeline, hours after
|
|
6
|
+
the fact, so each message says what to change rather than what went wrong.
|
|
7
|
+
|
|
8
|
+
Authentication is a bearer token and nothing more. The studio accepts a Supabase
|
|
9
|
+
JWT and an `m2d_` API key at the same door, and this client neither knows nor
|
|
10
|
+
cares which it was handed — which is what lets the key format change without
|
|
11
|
+
touching anything here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
DEFAULT_URL = "https://studio.jbanalytica.com"
|
|
23
|
+
|
|
24
|
+
# Generation happens inside the request, and a large schema at a large row count
|
|
25
|
+
# is genuinely slow. Generous enough that a real export finishes, short enough
|
|
26
|
+
# that a wedged CI job eventually fails instead of burning its whole budget.
|
|
27
|
+
DEFAULT_TIMEOUT = 300.0
|
|
28
|
+
|
|
29
|
+
# A studio under load asks for a wait; an honest one asks for seconds. Capped so
|
|
30
|
+
# a header saying "an hour" fails the run rather than parking a CI job on it.
|
|
31
|
+
MAX_RETRY_WAIT = 60.0
|
|
32
|
+
DEFAULT_RETRY_WAIT = 5.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StudioError(Exception):
|
|
36
|
+
"""A failure phrased for the person reading it, not for a stack trace.
|
|
37
|
+
|
|
38
|
+
Raised for everything the caller could act on — a refused key, a plan
|
|
39
|
+
boundary, an unreachable host — so `cli.py` can print it and exit without
|
|
40
|
+
knowing anything about HTTP.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StudioClient:
|
|
45
|
+
"""The studio's API, as the four calls this CLI needs.
|
|
46
|
+
|
|
47
|
+
`transport` exists for the tests: an `httpx.MockTransport` here means every
|
|
48
|
+
command can be exercised end to end without a studio to talk to, which is
|
|
49
|
+
the only way error translation gets covered at all.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
url: str = DEFAULT_URL,
|
|
55
|
+
key: str = "",
|
|
56
|
+
*,
|
|
57
|
+
transport: httpx.BaseTransport | None = None,
|
|
58
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
59
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.url = url.rstrip("/")
|
|
62
|
+
self._sleep = sleep
|
|
63
|
+
self._http = httpx.Client(
|
|
64
|
+
base_url=f"{self.url}/api",
|
|
65
|
+
# The one place the key is used. It is never logged, never echoed
|
|
66
|
+
# and never included in an error message: a CI log is a public
|
|
67
|
+
# artifact in more organisations than not.
|
|
68
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
69
|
+
transport=transport,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self._http.close()
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> StudioClient:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, *exception: object) -> None:
|
|
80
|
+
self.close()
|
|
81
|
+
|
|
82
|
+
def list_projects(self) -> list[dict[str, Any]]:
|
|
83
|
+
return list(self._request("GET", "/projects").json())
|
|
84
|
+
|
|
85
|
+
def get_project(self, project_id: str) -> dict[str, Any]:
|
|
86
|
+
return dict(self._request("GET", f"/projects/{project_id}").json())
|
|
87
|
+
|
|
88
|
+
def get_usage(self) -> dict[str, Any]:
|
|
89
|
+
return dict(self._request("GET", "/usage").json())
|
|
90
|
+
|
|
91
|
+
def get_plan(self) -> dict[str, Any] | None:
|
|
92
|
+
"""The caller's plan and its limits, or None if this studio has none.
|
|
93
|
+
|
|
94
|
+
`/api/plan` is newer than some deployments, and `whoami` is the command
|
|
95
|
+
someone runs to check their key works at all. Reporting the plan from
|
|
96
|
+
`/usage` alone beats failing the one call whose whole job is to say "you
|
|
97
|
+
are connected", so a 404 here is an absence rather than an error.
|
|
98
|
+
"""
|
|
99
|
+
response = self._send("GET", "/plan")
|
|
100
|
+
if response.status_code == httpx.codes.NOT_FOUND:
|
|
101
|
+
return None
|
|
102
|
+
return dict(self._checked(response).json())
|
|
103
|
+
|
|
104
|
+
def export(self, export_format: str, body: dict[str, Any]) -> bytes:
|
|
105
|
+
"""Generate and return the archive bytes. The server regenerates from
|
|
106
|
+
the schema in `body`, so the same seed gives back the same rows."""
|
|
107
|
+
return self._request("POST", f"/export/{export_format}", json=body).content
|
|
108
|
+
|
|
109
|
+
def _request(
|
|
110
|
+
self, method: str, path: str, *, json: dict[str, Any] | None = None
|
|
111
|
+
) -> httpx.Response:
|
|
112
|
+
return self._checked(self._send(method, path, json=json))
|
|
113
|
+
|
|
114
|
+
def _send(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
json: dict[str, Any] | None = None,
|
|
120
|
+
retried: bool = False,
|
|
121
|
+
) -> httpx.Response:
|
|
122
|
+
"""The call itself, with the one retry the API asks for. Status codes
|
|
123
|
+
are the caller's business; only an unreachable studio raises here."""
|
|
124
|
+
try:
|
|
125
|
+
response = self._http.request(method, path, json=json)
|
|
126
|
+
except httpx.HTTPError as error:
|
|
127
|
+
# Naming the URL is the whole message: nine times in ten this is a
|
|
128
|
+
# typo'd MODEL2DATA_STUDIO_URL or a self-hosted studio that is down,
|
|
129
|
+
# and neither is obvious from a connection error alone.
|
|
130
|
+
raise StudioError(f"Could not reach the studio at {self.url}: {error}") from error
|
|
131
|
+
|
|
132
|
+
if response.status_code == httpx.codes.TOO_MANY_REQUESTS and not retried:
|
|
133
|
+
# Honoured exactly once. Retrying until it works would turn a
|
|
134
|
+
# throttled key into a job that hangs; one polite wait covers the
|
|
135
|
+
# burst of a pipeline that regenerates several projects in a row.
|
|
136
|
+
self._sleep(_retry_after(response))
|
|
137
|
+
return self._send(method, path, json=json, retried=True)
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _checked(response: httpx.Response) -> httpx.Response:
|
|
142
|
+
if response.is_success:
|
|
143
|
+
return response
|
|
144
|
+
raise StudioError(_message(response))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _retry_after(response: httpx.Response) -> float:
|
|
148
|
+
"""Seconds to wait, from the header, clamped to something a CI job survives."""
|
|
149
|
+
try:
|
|
150
|
+
wait = float(response.headers.get("Retry-After", ""))
|
|
151
|
+
except ValueError:
|
|
152
|
+
# An HTTP-date is legal here and rare in practice; a fixed short wait is
|
|
153
|
+
# a better answer than parsing dates for it.
|
|
154
|
+
wait = DEFAULT_RETRY_WAIT
|
|
155
|
+
return min(max(wait, 0.0), MAX_RETRY_WAIT)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _detail(response: httpx.Response) -> str:
|
|
159
|
+
"""The API's own `detail`, which is written to be read by a person."""
|
|
160
|
+
try:
|
|
161
|
+
body = response.json()
|
|
162
|
+
except ValueError:
|
|
163
|
+
return response.text.strip()[:500]
|
|
164
|
+
if isinstance(body, dict):
|
|
165
|
+
detail = body.get("detail")
|
|
166
|
+
if isinstance(detail, str):
|
|
167
|
+
return detail
|
|
168
|
+
if isinstance(detail, list):
|
|
169
|
+
# FastAPI's validation errors. Flattened rather than dumped, so a
|
|
170
|
+
# rejected field reads as a sentence.
|
|
171
|
+
return "; ".join(str(item.get("msg", item)) for item in detail)
|
|
172
|
+
return str(body)[:500]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _message(response: httpx.Response) -> str:
|
|
176
|
+
status = response.status_code
|
|
177
|
+
if status == httpx.codes.UNAUTHORIZED:
|
|
178
|
+
# Not the API's own wording: signed out of a browser is a different
|
|
179
|
+
# situation from a key in an environment variable, and the fix here is
|
|
180
|
+
# always the variable.
|
|
181
|
+
return "The API key was refused. Check MODEL2DATA_STUDIO_KEY."
|
|
182
|
+
detail = _detail(response)
|
|
183
|
+
if status == httpx.codes.PAYMENT_REQUIRED:
|
|
184
|
+
# Shown as-is, deliberately: the API writes these to name the plan
|
|
185
|
+
# boundary that was hit, and rephrasing them here would mean two places
|
|
186
|
+
# describing the same limit and eventually disagreeing.
|
|
187
|
+
return detail
|
|
188
|
+
if status == httpx.codes.FORBIDDEN:
|
|
189
|
+
# Shown as-is, like a 402: the studio's own wording names the access
|
|
190
|
+
# the key has and the access the request needed, and the fix — a key
|
|
191
|
+
# created with more access — is not something this side can guess at.
|
|
192
|
+
return detail or "The studio refused this key's access."
|
|
193
|
+
if status == httpx.codes.TOO_MANY_REQUESTS:
|
|
194
|
+
return f"The studio is still rate limiting this key. {detail}".strip()
|
|
195
|
+
if status == httpx.codes.NOT_FOUND:
|
|
196
|
+
return detail or "The studio has no such thing."
|
|
197
|
+
return f"The studio refused the request ({status}). {detail}".strip()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""What a saved project plus a set of overrides adds up to, as a request body.
|
|
2
|
+
|
|
3
|
+
Extracted from `cli.py` when the MCP server arrived, for one reason: both of
|
|
4
|
+
them have to resolve a generation the same way, and the rules are not obvious
|
|
5
|
+
enough to write twice. The seed falls back to the project's and then to a random
|
|
6
|
+
one that gets reported; the anchor day falls back to the project's and then to
|
|
7
|
+
today; a single time-profile flag overrides one field and leaves the other two
|
|
8
|
+
as the project saved them; `adapter` and `skew` are sent only when there is
|
|
9
|
+
something to say, so this keeps working against a studio that predates them.
|
|
10
|
+
|
|
11
|
+
Every one of those is a decision about *reproducibility*, and a second copy of
|
|
12
|
+
them that drifted would mean the CLI and an agent generating from the same
|
|
13
|
+
project, with the same arguments, getting different rows.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import random
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .client import StudioError
|
|
25
|
+
|
|
26
|
+
# Seeds are only useful if a person can read one back out of a log and retype
|
|
27
|
+
# it, so they stay inside a signed 32-bit int rather than being as wide as the
|
|
28
|
+
# engine would allow.
|
|
29
|
+
MAX_RANDOM_SEED = 2**31 - 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def anchor_from(as_of: str | None) -> dt.datetime:
|
|
33
|
+
"""The instant dates and timestamps are generated relative to.
|
|
34
|
+
|
|
35
|
+
A day rather than an instant, and midnight rather than the current time: the
|
|
36
|
+
anchor is something a person reads out of a CI log and retypes into a flag,
|
|
37
|
+
the same as a seed, and two runs on the same day should agree without
|
|
38
|
+
anyone having pinned anything yet. Today is the local date, since that is
|
|
39
|
+
the day the person reading the log calls today. The studio stores the same
|
|
40
|
+
`YYYY-MM-DD` on the project and sends the same midnight, so a day pinned
|
|
41
|
+
there and a day pinned here name the same rows.
|
|
42
|
+
"""
|
|
43
|
+
if as_of is None:
|
|
44
|
+
return dt.datetime.combine(dt.date.today(), dt.time.min)
|
|
45
|
+
try:
|
|
46
|
+
return dt.datetime.combine(dt.date.fromisoformat(as_of), dt.time.min)
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise StudioError(f"A day is wanted as YYYY-MM-DD, not {as_of!r}.") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Overrides:
|
|
53
|
+
"""What the caller said, as opposed to what the project already says.
|
|
54
|
+
|
|
55
|
+
`None` everywhere means "the project decides", which is the point: a
|
|
56
|
+
pipeline passing nothing but a project id keeps producing whatever the
|
|
57
|
+
studio last previewed.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
rows: int | None = None
|
|
61
|
+
row_overrides: Mapping[str, int] = field(default_factory=dict)
|
|
62
|
+
seed: int | None = None
|
|
63
|
+
as_of: str | None = None
|
|
64
|
+
locale: str | None = None
|
|
65
|
+
business_hours: bool | None = None
|
|
66
|
+
growth: float | None = None
|
|
67
|
+
seasonality: float | None = None
|
|
68
|
+
skew: float | None = None
|
|
69
|
+
project_name: str | None = None
|
|
70
|
+
adapter: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Resolved:
|
|
75
|
+
"""The request, and the two things about it worth telling the caller.
|
|
76
|
+
|
|
77
|
+
`picked_seed` and `picked_anchor` are true when nothing pinned them and
|
|
78
|
+
this run chose: both callers report what was chosen, because a run nobody
|
|
79
|
+
can reproduce afterwards is the failure mode this whole tool exists to
|
|
80
|
+
avoid.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
body: dict[str, Any]
|
|
84
|
+
seed: int
|
|
85
|
+
anchor: dt.datetime
|
|
86
|
+
picked_seed: bool
|
|
87
|
+
picked_anchor: bool
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve(
|
|
91
|
+
saved: Mapping[str, Any],
|
|
92
|
+
overrides: Overrides | None = None,
|
|
93
|
+
*,
|
|
94
|
+
choose_seed: Any = None,
|
|
95
|
+
) -> Resolved:
|
|
96
|
+
"""Turn a saved project into an export request body.
|
|
97
|
+
|
|
98
|
+
`choose_seed` is the random seed picker, injectable so a test can have a
|
|
99
|
+
predictable one.
|
|
100
|
+
"""
|
|
101
|
+
overrides = overrides or Overrides()
|
|
102
|
+
pick = choose_seed or (lambda: random.randrange(1, MAX_RANDOM_SEED))
|
|
103
|
+
|
|
104
|
+
schema = saved.get("schema")
|
|
105
|
+
if schema is None:
|
|
106
|
+
# Nothing on the server can turn the project's DBML into tables -- the
|
|
107
|
+
# browser is the only parser there is. Saying so plainly beats a 422
|
|
108
|
+
# about a missing field.
|
|
109
|
+
raise StudioError(
|
|
110
|
+
"Open this project in the studio once so it saves its schema, then try again."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
resolved_seed = overrides.seed if overrides.seed is not None else saved.get("seed")
|
|
114
|
+
picked_seed = resolved_seed is None
|
|
115
|
+
if picked_seed:
|
|
116
|
+
# An unpinned project generates different rows every run, which is fine
|
|
117
|
+
# in a browser and miserable in CI: a diff on every build with no way
|
|
118
|
+
# back to yesterday's data.
|
|
119
|
+
resolved_seed = pick()
|
|
120
|
+
|
|
121
|
+
# The argument, then the day the project itself is pinned on, then today --
|
|
122
|
+
# the same order as the seed. With the project's own day this run
|
|
123
|
+
# reproduces the rows the studio previewed, not merely the same rows as
|
|
124
|
+
# yesterday's run.
|
|
125
|
+
pinned = anchor_from(overrides.as_of) if overrides.as_of is not None else None
|
|
126
|
+
anchor = pinned or anchor_from(saved.get("asOf"))
|
|
127
|
+
picked_anchor = pinned is None and saved.get("asOf") is None
|
|
128
|
+
|
|
129
|
+
body: dict[str, Any] = {
|
|
130
|
+
"schema": schema,
|
|
131
|
+
"rows": overrides.rows if overrides.rows is not None else saved.get("rows", 100),
|
|
132
|
+
# Merged rather than replaced: naming one table's row count is not a
|
|
133
|
+
# request to drop the project's settings for every other table.
|
|
134
|
+
"rowOverrides": {**(saved.get("rowOverrides") or {}), **dict(overrides.row_overrides)},
|
|
135
|
+
"seed": resolved_seed,
|
|
136
|
+
"locale": overrides.locale if overrides.locale is not None else saved.get("locale"),
|
|
137
|
+
"projectName": overrides.project_name or saved.get("name") or "my_project",
|
|
138
|
+
# The day every date and timestamp is placed relative to. Left to the
|
|
139
|
+
# server, it would be the wall clock at the moment of the request, and
|
|
140
|
+
# a pinned seed would then reproduce its rows only until midnight --
|
|
141
|
+
# which is exactly when a nightly check runs.
|
|
142
|
+
"generatedAt": anchor.isoformat(),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# The argument wins; absent, the project's own saved target does. Only sent
|
|
146
|
+
# when one of the two says something, so this still works against a studio
|
|
147
|
+
# old enough to have never saved a target on the project at all.
|
|
148
|
+
adapter = overrides.adapter if overrides.adapter is not None else saved.get("adapter")
|
|
149
|
+
if adapter is not None:
|
|
150
|
+
body["adapter"] = adapter
|
|
151
|
+
|
|
152
|
+
saved_time_profile = saved.get("timeProfile") or {}
|
|
153
|
+
named = (overrides.business_hours, overrides.growth, overrides.seasonality)
|
|
154
|
+
if any(value is not None for value in named):
|
|
155
|
+
# Naming one field of the profile leaves the other two as the project
|
|
156
|
+
# saved them, the same way naming one table's row count does.
|
|
157
|
+
body["timeProfile"] = {
|
|
158
|
+
"businessHours": (
|
|
159
|
+
overrides.business_hours
|
|
160
|
+
if overrides.business_hours is not None
|
|
161
|
+
else saved_time_profile.get("businessHours", False)
|
|
162
|
+
),
|
|
163
|
+
"growth": (
|
|
164
|
+
overrides.growth
|
|
165
|
+
if overrides.growth is not None
|
|
166
|
+
else saved_time_profile.get("growth", 0.0)
|
|
167
|
+
),
|
|
168
|
+
"seasonality": (
|
|
169
|
+
overrides.seasonality
|
|
170
|
+
if overrides.seasonality is not None
|
|
171
|
+
else saved_time_profile.get("seasonality", 0.0)
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
elif saved_time_profile:
|
|
175
|
+
body["timeProfile"] = saved_time_profile
|
|
176
|
+
|
|
177
|
+
skew = overrides.skew if overrides.skew is not None else saved.get("skew")
|
|
178
|
+
if skew:
|
|
179
|
+
# Only sent when there is something to shape, like `adapter`: this keeps
|
|
180
|
+
# working against a studio that does not know the field yet, and 0 means
|
|
181
|
+
# the same thing as leaving it out.
|
|
182
|
+
body["skew"] = skew
|
|
183
|
+
|
|
184
|
+
assert resolved_seed is not None
|
|
185
|
+
return Resolved(
|
|
186
|
+
body=body,
|
|
187
|
+
seed=int(resolved_seed),
|
|
188
|
+
anchor=anchor,
|
|
189
|
+
picked_seed=picked_seed,
|
|
190
|
+
picked_anchor=picked_anchor,
|
|
191
|
+
)
|