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,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
+ )