methodic-research 0.1.2__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.
- methodic/__init__.py +79 -0
- methodic/assets.py +143 -0
- methodic/chronicle.py +88 -0
- methodic/errors.py +70 -0
- methodic/experiments.py +342 -0
- methodic/reports.py +294 -0
- methodic/runs.py +306 -0
- methodic/search.py +78 -0
- methodic/transport.py +91 -0
- methodic/types.py +344 -0
- methodic/upload_tracker.py +181 -0
- methodic/variations.py +166 -0
- methodic_research-0.1.2.dist-info/METADATA +19 -0
- methodic_research-0.1.2.dist-info/RECORD +16 -0
- methodic_research-0.1.2.dist-info/WHEEL +5 -0
- methodic_research-0.1.2.dist-info/top_level.txt +1 -0
methodic/experiments.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Experiments namespace and resource handle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
7
|
+
|
|
8
|
+
from methodic.reports import _BoundReports
|
|
9
|
+
from methodic.transport import Transport
|
|
10
|
+
from methodic.types import (
|
|
11
|
+
CreateExperimentResponse,
|
|
12
|
+
Experiment as ExperimentData,
|
|
13
|
+
ExperimentDetail,
|
|
14
|
+
ExperimentListPage,
|
|
15
|
+
ExperimentSummary,
|
|
16
|
+
GitStatus,
|
|
17
|
+
GitToken,
|
|
18
|
+
LineageResponse,
|
|
19
|
+
UpstreamRetractionsResponse,
|
|
20
|
+
)
|
|
21
|
+
from methodic.variations import _BoundVariations
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from methodic.chronicle import Chronicle
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExperimentsAPI:
|
|
30
|
+
"""Experiments namespace. Stateless; every method takes the experiment id explicitly."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, transport: Transport, chronicle: Chronicle) -> None:
|
|
33
|
+
self._t = transport
|
|
34
|
+
self._chronicle = chronicle
|
|
35
|
+
|
|
36
|
+
def create(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
hypothesis_summary: str,
|
|
40
|
+
config_yaml: str,
|
|
41
|
+
rationale: str | None = None,
|
|
42
|
+
description: str | None = None,
|
|
43
|
+
accelerate_config_yaml: str | None = None,
|
|
44
|
+
launch_config: dict[str, Any] | None = None,
|
|
45
|
+
parent_experiment_ids: list[str] | None = None,
|
|
46
|
+
allow_retracted_parent: bool = False,
|
|
47
|
+
) -> Experiment:
|
|
48
|
+
"""Create a new experiment. Returns a handle with the create-response cached."""
|
|
49
|
+
payload: dict[str, Any] = {
|
|
50
|
+
"hypothesis_summary": hypothesis_summary,
|
|
51
|
+
"config_yaml": config_yaml,
|
|
52
|
+
}
|
|
53
|
+
if rationale is not None:
|
|
54
|
+
payload["rationale"] = rationale
|
|
55
|
+
if description is not None:
|
|
56
|
+
payload["description"] = description
|
|
57
|
+
if accelerate_config_yaml is not None:
|
|
58
|
+
payload["accelerate_config_yaml"] = accelerate_config_yaml
|
|
59
|
+
if launch_config is not None:
|
|
60
|
+
payload["launch_config"] = launch_config
|
|
61
|
+
if parent_experiment_ids is not None:
|
|
62
|
+
payload["parent_experiment_ids"] = parent_experiment_ids
|
|
63
|
+
if allow_retracted_parent:
|
|
64
|
+
payload["allow_retracted_parent"] = True
|
|
65
|
+
|
|
66
|
+
resp = self._t.post("/experiments", json=payload)
|
|
67
|
+
result = CreateExperimentResponse.from_dict(resp)
|
|
68
|
+
return Experiment(self._chronicle, result.experiment_id, _create_response=result)
|
|
69
|
+
|
|
70
|
+
def get(self, experiment_id: str) -> ExperimentDetail:
|
|
71
|
+
return ExperimentDetail.from_dict(self._t.get(f"/experiments/{experiment_id}"))
|
|
72
|
+
|
|
73
|
+
def get_rationale(self, experiment_id: str) -> str | None:
|
|
74
|
+
resp = self._t.get(f"/experiments/{experiment_id}/rationale")
|
|
75
|
+
return resp.get("rationale") if resp else None
|
|
76
|
+
|
|
77
|
+
def list(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
status: str | None = None,
|
|
81
|
+
created_by: str | None = None,
|
|
82
|
+
page_size: int | None = None,
|
|
83
|
+
page_token: str | None = None,
|
|
84
|
+
) -> ExperimentListPage:
|
|
85
|
+
"""One page of experiments matching the filters. Cursor-aware (when server supports it)."""
|
|
86
|
+
params: dict[str, Any] = {}
|
|
87
|
+
if status is not None:
|
|
88
|
+
params["status"] = status
|
|
89
|
+
if created_by is not None:
|
|
90
|
+
params["created_by"] = created_by
|
|
91
|
+
if page_size is not None:
|
|
92
|
+
params["page_size"] = page_size
|
|
93
|
+
if page_token is not None:
|
|
94
|
+
params["page_token"] = page_token
|
|
95
|
+
return ExperimentListPage.from_dict(
|
|
96
|
+
self._t.get("/experiments", params=params or None)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def iter(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
status: str | None = None,
|
|
103
|
+
created_by: str | None = None,
|
|
104
|
+
page_size: int | None = None,
|
|
105
|
+
) -> Iterator[ExperimentSummary]:
|
|
106
|
+
"""Yield every experiment matching the filters, paging server-side as needed."""
|
|
107
|
+
token: str | None = None
|
|
108
|
+
while True:
|
|
109
|
+
page = self.list(
|
|
110
|
+
status=status,
|
|
111
|
+
created_by=created_by,
|
|
112
|
+
page_size=page_size,
|
|
113
|
+
page_token=token,
|
|
114
|
+
)
|
|
115
|
+
yield from page.results
|
|
116
|
+
if page.next_page_token is None:
|
|
117
|
+
return
|
|
118
|
+
token = page.next_page_token
|
|
119
|
+
|
|
120
|
+
def commit(self, experiment_id: str) -> dict[str, Any]:
|
|
121
|
+
return self._t.put(f"/experiments/{experiment_id}/commit")
|
|
122
|
+
|
|
123
|
+
def conclude(self, experiment_id: str) -> dict[str, Any]:
|
|
124
|
+
return self._t.put(f"/experiments/{experiment_id}/conclude")
|
|
125
|
+
|
|
126
|
+
def retract(
|
|
127
|
+
self,
|
|
128
|
+
experiment_id: str,
|
|
129
|
+
*,
|
|
130
|
+
reason: str,
|
|
131
|
+
document_asset_id: str | None = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
payload: dict[str, Any] = {"reason": reason}
|
|
134
|
+
if document_asset_id is not None:
|
|
135
|
+
payload["document_asset_id"] = document_asset_id
|
|
136
|
+
return self._t.put(f"/experiments/{experiment_id}/retract", json=payload)
|
|
137
|
+
|
|
138
|
+
def get_lineage(
|
|
139
|
+
self,
|
|
140
|
+
experiment_id: str,
|
|
141
|
+
*,
|
|
142
|
+
direction: str | None = None,
|
|
143
|
+
depth: int | None = None,
|
|
144
|
+
) -> LineageResponse:
|
|
145
|
+
params: dict[str, Any] = {}
|
|
146
|
+
if direction is not None:
|
|
147
|
+
params["direction"] = direction
|
|
148
|
+
if depth is not None:
|
|
149
|
+
params["depth"] = depth
|
|
150
|
+
return LineageResponse.from_dict(
|
|
151
|
+
self._t.get(f"/experiments/{experiment_id}/lineage", params=params or None)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def git_status(self, experiment_id: str) -> GitStatus:
|
|
155
|
+
"""Current git-integration state for the experiment.
|
|
156
|
+
|
|
157
|
+
Returns lightweight status info — `state` (pending/ready/failed/archived),
|
|
158
|
+
`repo_url` (when ready), `failure_reason` (when failed). Cheap to poll;
|
|
159
|
+
UI calls this every couple seconds while state is `pending`.
|
|
160
|
+
"""
|
|
161
|
+
return GitStatus.from_dict(self._t.get(f"/experiments/{experiment_id}/git"))
|
|
162
|
+
|
|
163
|
+
def wait_for_repo(
|
|
164
|
+
self,
|
|
165
|
+
experiment_id: str,
|
|
166
|
+
*,
|
|
167
|
+
timeout: float = 300.0,
|
|
168
|
+
poll_interval: float = 2.0,
|
|
169
|
+
) -> GitStatus:
|
|
170
|
+
"""Poll `git_status` until the repo is `ready` or `failed`, or timeout."""
|
|
171
|
+
import time
|
|
172
|
+
|
|
173
|
+
deadline = time.monotonic() + timeout
|
|
174
|
+
while True:
|
|
175
|
+
status = self.git_status(experiment_id)
|
|
176
|
+
if status.state in ("ready", "failed", "archived"):
|
|
177
|
+
return status
|
|
178
|
+
if time.monotonic() >= deadline:
|
|
179
|
+
return status # caller checks state
|
|
180
|
+
time.sleep(poll_interval)
|
|
181
|
+
|
|
182
|
+
def mint_git_token(self, experiment_id: str) -> GitToken:
|
|
183
|
+
"""Mint a 1-hour install token scoped to this experiment's repo.
|
|
184
|
+
|
|
185
|
+
The returned token has Administration permission stripped — pushes
|
|
186
|
+
to `agent/*` branches will be rejected by branch protection. Use it
|
|
187
|
+
to clone the repo and push to `user/...` branches you create.
|
|
188
|
+
|
|
189
|
+
Raises `ServerError(503)` if the server has no GitHub App configured;
|
|
190
|
+
`ConflictError(409)` if the experiment's repo isn't `ready` yet.
|
|
191
|
+
"""
|
|
192
|
+
return GitToken.from_dict(self._t.post(f"/experiments/{experiment_id}/git/token"))
|
|
193
|
+
|
|
194
|
+
def get_upstream_retractions(
|
|
195
|
+
self,
|
|
196
|
+
experiment_id: str,
|
|
197
|
+
*,
|
|
198
|
+
depth: int | None = None,
|
|
199
|
+
since: str | None = None,
|
|
200
|
+
full_chain: bool = False,
|
|
201
|
+
) -> UpstreamRetractionsResponse:
|
|
202
|
+
params: dict[str, Any] = {}
|
|
203
|
+
if depth is not None:
|
|
204
|
+
params["depth"] = depth
|
|
205
|
+
if since is not None:
|
|
206
|
+
params["since"] = since
|
|
207
|
+
if full_chain:
|
|
208
|
+
params["full_chain"] = "true"
|
|
209
|
+
return UpstreamRetractionsResponse.from_dict(
|
|
210
|
+
self._t.get(
|
|
211
|
+
f"/experiments/{experiment_id}/upstream-retractions",
|
|
212
|
+
params=params or None,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Experiment:
|
|
218
|
+
"""Handle for one experiment.
|
|
219
|
+
|
|
220
|
+
Mutators (`commit`, `conclude`, `retract`) return `self` so callers can
|
|
221
|
+
chain (`exp.commit().variations.create(...)`). Cached detail is dropped
|
|
222
|
+
after each mutation; the next attribute access re-fetches transparently.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self,
|
|
227
|
+
chronicle: Chronicle,
|
|
228
|
+
experiment_id: str,
|
|
229
|
+
*,
|
|
230
|
+
_detail: ExperimentDetail | None = None,
|
|
231
|
+
_create_response: CreateExperimentResponse | None = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
self._chronicle = chronicle
|
|
234
|
+
self.id = experiment_id
|
|
235
|
+
self._detail = _detail
|
|
236
|
+
self._create_response = _create_response
|
|
237
|
+
self.variations = _BoundVariations(chronicle.variations, experiment_id)
|
|
238
|
+
self.reports = _BoundReports(chronicle.reports, experiment_id)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def detail(self) -> ExperimentDetail:
|
|
242
|
+
if self._detail is None:
|
|
243
|
+
self._detail = self._chronicle.experiments.get(self.id)
|
|
244
|
+
return self._detail
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def data(self) -> ExperimentData:
|
|
248
|
+
return self.detail.experiment
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def state(self) -> str:
|
|
252
|
+
return self.data.state
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def hypothesis_summary(self) -> str:
|
|
256
|
+
return self.data.hypothesis_summary
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def committed_at(self) -> str | None:
|
|
260
|
+
return self.data.committed_at
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def concluded_at(self) -> str | None:
|
|
264
|
+
return self.data.concluded_at
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def retracted_at(self) -> str | None:
|
|
268
|
+
return self.data.retracted_at
|
|
269
|
+
|
|
270
|
+
def get_rationale(self) -> str | None:
|
|
271
|
+
return self._chronicle.experiments.get_rationale(self.id)
|
|
272
|
+
|
|
273
|
+
def git_status(self) -> GitStatus:
|
|
274
|
+
"""Lightweight current git-integration state for this experiment."""
|
|
275
|
+
return self._chronicle.experiments.git_status(self.id)
|
|
276
|
+
|
|
277
|
+
def wait_for_repo(
|
|
278
|
+
self, *, timeout: float = 300.0, poll_interval: float = 2.0
|
|
279
|
+
) -> GitStatus:
|
|
280
|
+
"""Poll until this experiment's repo is `ready` (or `failed`/timeout)."""
|
|
281
|
+
return self._chronicle.experiments.wait_for_repo(
|
|
282
|
+
self.id, timeout=timeout, poll_interval=poll_interval
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def mint_git_token(self) -> GitToken:
|
|
286
|
+
"""Mint a 1-hour install token scoped to this experiment's repo."""
|
|
287
|
+
return self._chronicle.experiments.mint_git_token(self.id)
|
|
288
|
+
|
|
289
|
+
def commit(self) -> Experiment:
|
|
290
|
+
self._chronicle.experiments.commit(self.id)
|
|
291
|
+
self._detail = None
|
|
292
|
+
return self
|
|
293
|
+
|
|
294
|
+
def conclude(self) -> Experiment:
|
|
295
|
+
self._chronicle.experiments.conclude(self.id)
|
|
296
|
+
self._detail = None
|
|
297
|
+
return self
|
|
298
|
+
|
|
299
|
+
def retract(
|
|
300
|
+
self, *, reason: str, document_asset_id: str | None = None
|
|
301
|
+
) -> Experiment:
|
|
302
|
+
self._chronicle.experiments.retract(
|
|
303
|
+
self.id, reason=reason, document_asset_id=document_asset_id
|
|
304
|
+
)
|
|
305
|
+
self._detail = None
|
|
306
|
+
return self
|
|
307
|
+
|
|
308
|
+
def get_lineage(
|
|
309
|
+
self, *, direction: str | None = None, depth: int | None = None
|
|
310
|
+
) -> LineageResponse:
|
|
311
|
+
return self._chronicle.experiments.get_lineage(
|
|
312
|
+
self.id, direction=direction, depth=depth
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def get_upstream_retractions(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
depth: int | None = None,
|
|
319
|
+
since: str | None = None,
|
|
320
|
+
full_chain: bool = False,
|
|
321
|
+
) -> UpstreamRetractionsResponse:
|
|
322
|
+
return self._chronicle.experiments.get_upstream_retractions(
|
|
323
|
+
self.id, depth=depth, since=since, full_chain=full_chain
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def set_report_settings(self, settings: dict[str, Any]) -> Experiment:
|
|
327
|
+
"""Replace `experiment.report_settings`. Frozen at commit (server
|
|
328
|
+
returns 409 once committed). `settings` shape matches the
|
|
329
|
+
`ReportSettings` server type:
|
|
330
|
+
|
|
331
|
+
{
|
|
332
|
+
"hypothesis": {"mode": "freeform", "freeform_prompt": "..."},
|
|
333
|
+
"takeaways": {"mode": "template", "template_asset_id": "...", "per_variation": true},
|
|
334
|
+
"research": {...}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
Returns `self` for chaining. Drops cached `_detail` so the next
|
|
338
|
+
access re-fetches the updated row.
|
|
339
|
+
"""
|
|
340
|
+
self._chronicle.reports.update_settings(self.id, settings=settings)
|
|
341
|
+
self._detail = None
|
|
342
|
+
return self
|
methodic/reports.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Reports namespace: render hypothesis / takeaways / research, check
|
|
2
|
+
compile status, download the rendered PDF.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
|
|
6
|
+
chronicle = Chronicle(server_url=..., api_key=...)
|
|
7
|
+
exp = chronicle.experiments.create(hypothesis_summary="...", config_yaml="...")
|
|
8
|
+
|
|
9
|
+
# Template mode (default).
|
|
10
|
+
report = exp.reports.hypothesis.render(payload={
|
|
11
|
+
"title": "Ripple study",
|
|
12
|
+
"abstract": "...",
|
|
13
|
+
"hypothesis": "...",
|
|
14
|
+
"motivation": "...",
|
|
15
|
+
"plan": ["sweep coefficient", "measure convergence"],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
# The render endpoint compiles synchronously when the chronicle-tex
|
|
19
|
+
# service is configured; check the result.
|
|
20
|
+
if report.compile_status == "compiled":
|
|
21
|
+
report.download_pdf("hypothesis.pdf")
|
|
22
|
+
|
|
23
|
+
# Free-form mode (per-experiment opt-in via report_settings).
|
|
24
|
+
exp.set_report_settings(hypothesis={
|
|
25
|
+
"mode": "freeform",
|
|
26
|
+
"freeform_prompt": "Write a one-page hypothesis focusing on...",
|
|
27
|
+
})
|
|
28
|
+
report = exp.reports.hypothesis.render(tex_body=open("draft.tex").read())
|
|
29
|
+
|
|
30
|
+
See `runes/chronicle/designs/reports.md` for the full design.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import base64
|
|
36
|
+
import logging
|
|
37
|
+
import time
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
from methodic.transport import Transport
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from methodic.chronicle import Chronicle
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Outcomes the gates accept; everything else is in-flight or terminal-fail.
|
|
48
|
+
_TERMINAL_COMPILE_STATUSES = ("compiled", "failed", "stale")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ReportsAPI:
|
|
52
|
+
"""Reports namespace. Stateless; every method takes the experiment id
|
|
53
|
+
+ report kind explicitly. Most users access via `exp.reports` instead
|
|
54
|
+
of calling these methods directly."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, transport: Transport, chronicle: Chronicle) -> None:
|
|
57
|
+
self._t = transport
|
|
58
|
+
self._chronicle = chronicle
|
|
59
|
+
|
|
60
|
+
def render(
|
|
61
|
+
self,
|
|
62
|
+
experiment_id: str,
|
|
63
|
+
kind: str,
|
|
64
|
+
*,
|
|
65
|
+
payload: dict[str, Any] | None = None,
|
|
66
|
+
tex_body: str | None = None,
|
|
67
|
+
template_asset_id: str | None = None,
|
|
68
|
+
) -> Report:
|
|
69
|
+
"""Render (and compile, when chronicle-tex is configured) a report.
|
|
70
|
+
|
|
71
|
+
Pass `payload` for template mode; `tex_body` for freeform mode.
|
|
72
|
+
Mode is selected by the experiment's `report_settings.{kind}.mode`
|
|
73
|
+
— these args feed the chosen mode and are ignored otherwise.
|
|
74
|
+
"""
|
|
75
|
+
body: dict[str, Any] = {}
|
|
76
|
+
if payload is not None:
|
|
77
|
+
body["payload"] = payload
|
|
78
|
+
if tex_body is not None:
|
|
79
|
+
body["tex_body"] = tex_body
|
|
80
|
+
if template_asset_id is not None:
|
|
81
|
+
body["template_asset_id"] = template_asset_id
|
|
82
|
+
|
|
83
|
+
resp = self._t.post(
|
|
84
|
+
f"/experiments/{experiment_id}/reports/{kind}/render", json=body
|
|
85
|
+
)
|
|
86
|
+
return Report(self._chronicle, asset_dict=resp)
|
|
87
|
+
|
|
88
|
+
def get(self, asset_id: str) -> Report:
|
|
89
|
+
"""Fetch a report by its asset id. Returns the asset metadata
|
|
90
|
+
plus content (compile_status, log, pdf_b64 if compiled)."""
|
|
91
|
+
resp = self._t.get(f"/v1/reports/{asset_id}")
|
|
92
|
+
return Report(self._chronicle, full_dict=resp)
|
|
93
|
+
|
|
94
|
+
def update_settings(
|
|
95
|
+
self,
|
|
96
|
+
experiment_id: str,
|
|
97
|
+
*,
|
|
98
|
+
settings: dict[str, Any],
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Replace `experiment.report_settings` with `settings`. Frozen at
|
|
101
|
+
commit — server returns 409 once the experiment is committed."""
|
|
102
|
+
return self._t.put(
|
|
103
|
+
f"/v1/experiments/{experiment_id}/report-settings",
|
|
104
|
+
json={"settings": settings},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Report:
|
|
109
|
+
"""Handle for one rendered report asset.
|
|
110
|
+
|
|
111
|
+
Wraps the asset metadata plus (when fetched via `reports.get` or
|
|
112
|
+
`refresh`) the json_asset_store content with `compile_status`, `log`,
|
|
113
|
+
and `pdf_b64`. Mutators return `self` for chaining.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
chronicle: Chronicle,
|
|
119
|
+
*,
|
|
120
|
+
asset_dict: dict[str, Any] | None = None,
|
|
121
|
+
full_dict: dict[str, Any] | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._chronicle = chronicle
|
|
124
|
+
if full_dict is not None:
|
|
125
|
+
self._asset = full_dict["asset"]
|
|
126
|
+
self._content = full_dict.get("content")
|
|
127
|
+
else:
|
|
128
|
+
assert asset_dict is not None
|
|
129
|
+
self._asset = asset_dict
|
|
130
|
+
self._content = None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def id(self) -> str:
|
|
134
|
+
return self._asset["id"]
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def asset_type(self) -> str:
|
|
138
|
+
return self._asset["asset_type"]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def name(self) -> str:
|
|
142
|
+
return self._asset["name"]
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def asset_config(self) -> dict[str, Any]:
|
|
146
|
+
return self._asset.get("asset_config") or {}
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def compile_status(self) -> str:
|
|
150
|
+
"""Current `compile_status`: pending / compiled / failed / stale.
|
|
151
|
+
|
|
152
|
+
Reads from the asset's `asset_config.compile_status` (always
|
|
153
|
+
present on render-pipeline-produced assets); for content-fetched
|
|
154
|
+
reports also cross-references the json_asset_store body.
|
|
155
|
+
"""
|
|
156
|
+
if self._content is not None:
|
|
157
|
+
cs = self._content.get("compile_status")
|
|
158
|
+
if isinstance(cs, str):
|
|
159
|
+
return cs
|
|
160
|
+
return str(self.asset_config.get("compile_status", "unknown"))
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def template_asset_id(self) -> str | None:
|
|
164
|
+
v = self.asset_config.get("template_asset_id")
|
|
165
|
+
return v if isinstance(v, str) else None
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def mode(self) -> str:
|
|
169
|
+
return str(self.asset_config.get("mode", "unknown"))
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def compile_request_id(self) -> str | None:
|
|
173
|
+
v = self.asset_config.get("compile_request_id")
|
|
174
|
+
return v if isinstance(v, str) else None
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def compile_log(self) -> str | None:
|
|
178
|
+
"""Tectonic stdout/stderr — only available when content has been
|
|
179
|
+
fetched (via `refresh()` or `chronicle.reports.get(...)`)."""
|
|
180
|
+
if self._content is None:
|
|
181
|
+
return None
|
|
182
|
+
v = self._content.get("compile_log")
|
|
183
|
+
return v if isinstance(v, str) else None
|
|
184
|
+
|
|
185
|
+
def refresh(self) -> Report:
|
|
186
|
+
"""Re-fetch the report's asset + content. Returns `self` for
|
|
187
|
+
chaining (e.g. `report.refresh().compile_status`)."""
|
|
188
|
+
latest = self._chronicle.reports.get(self.id)
|
|
189
|
+
self._asset = latest._asset
|
|
190
|
+
self._content = latest._content
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def wait_for_compile(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
timeout: float = 60.0,
|
|
197
|
+
poll_interval: float = 1.0,
|
|
198
|
+
) -> Report:
|
|
199
|
+
"""Poll `refresh` until `compile_status` is terminal (compiled,
|
|
200
|
+
failed, stale) or `timeout` elapses. Returns `self`; check
|
|
201
|
+
`.compile_status` on the result.
|
|
202
|
+
|
|
203
|
+
On most deployments the render endpoint compiles synchronously, so
|
|
204
|
+
this returns immediately on the first refresh. Useful for
|
|
205
|
+
deployments where the compile worker is configured to run
|
|
206
|
+
out-of-band (or for the M4-style stub where compile_status starts
|
|
207
|
+
at `pending` and never advances — in that case this times out)."""
|
|
208
|
+
deadline = time.monotonic() + timeout
|
|
209
|
+
while True:
|
|
210
|
+
self.refresh()
|
|
211
|
+
if self.compile_status in _TERMINAL_COMPILE_STATUSES:
|
|
212
|
+
return self
|
|
213
|
+
if time.monotonic() >= deadline:
|
|
214
|
+
logger.warning(
|
|
215
|
+
"wait_for_compile timed out after %.1fs (status=%s)",
|
|
216
|
+
timeout,
|
|
217
|
+
self.compile_status,
|
|
218
|
+
)
|
|
219
|
+
return self
|
|
220
|
+
time.sleep(poll_interval)
|
|
221
|
+
|
|
222
|
+
def download_pdf(self, path: str | None = None) -> bytes:
|
|
223
|
+
"""Download the compiled PDF. If `path` is given, write it there.
|
|
224
|
+
Returns the PDF bytes regardless. Raises `RuntimeError` if the
|
|
225
|
+
compile hasn't succeeded or no PDF is available.
|
|
226
|
+
|
|
227
|
+
Will fetch content if not already loaded (i.e. one extra round
|
|
228
|
+
trip when called on a Report obtained from `render` rather than
|
|
229
|
+
`get`)."""
|
|
230
|
+
if self._content is None:
|
|
231
|
+
self.refresh()
|
|
232
|
+
assert self._content is not None
|
|
233
|
+
if self.compile_status != "compiled":
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
f"cannot download PDF: compile_status is {self.compile_status!r}"
|
|
236
|
+
)
|
|
237
|
+
pdf_b64 = self._content.get("pdf_b64")
|
|
238
|
+
if not isinstance(pdf_b64, str) or not pdf_b64:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
"report has no pdf_b64 content — was it compiled by this server?"
|
|
241
|
+
)
|
|
242
|
+
pdf_bytes = base64.b64decode(pdf_b64)
|
|
243
|
+
if path is not None:
|
|
244
|
+
with open(path, "wb") as f:
|
|
245
|
+
f.write(pdf_bytes)
|
|
246
|
+
logger.debug("Wrote %d bytes of PDF to %s", len(pdf_bytes), path)
|
|
247
|
+
return pdf_bytes
|
|
248
|
+
|
|
249
|
+
def download_source(self) -> str:
|
|
250
|
+
"""Return the rendered `.tex` source. Loads content on first call."""
|
|
251
|
+
if self._content is None:
|
|
252
|
+
self.refresh()
|
|
253
|
+
assert self._content is not None
|
|
254
|
+
rendered = self._content.get("rendered_tex") or self._content.get("tex_body")
|
|
255
|
+
if not isinstance(rendered, str):
|
|
256
|
+
raise RuntimeError("report has no rendered_tex / tex_body content")
|
|
257
|
+
return rendered
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _BoundReportKind:
|
|
261
|
+
"""Per-(experiment, kind) report shim — what `exp.reports.hypothesis`
|
|
262
|
+
returns. Provides `.render(...)` without re-passing the experiment id
|
|
263
|
+
or kind every call."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, api: ReportsAPI, experiment_id: str, kind: str) -> None:
|
|
266
|
+
self._api = api
|
|
267
|
+
self._experiment_id = experiment_id
|
|
268
|
+
self._kind = kind
|
|
269
|
+
|
|
270
|
+
def render(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
payload: dict[str, Any] | None = None,
|
|
274
|
+
tex_body: str | None = None,
|
|
275
|
+
template_asset_id: str | None = None,
|
|
276
|
+
) -> Report:
|
|
277
|
+
return self._api.render(
|
|
278
|
+
self._experiment_id,
|
|
279
|
+
self._kind,
|
|
280
|
+
payload=payload,
|
|
281
|
+
tex_body=tex_body,
|
|
282
|
+
template_asset_id=template_asset_id,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _BoundReports:
|
|
287
|
+
"""Per-experiment reports shim — what `exp.reports` returns. Exposes
|
|
288
|
+
one `_BoundReportKind` per kind: `.hypothesis`, `.takeaways`,
|
|
289
|
+
`.research`."""
|
|
290
|
+
|
|
291
|
+
def __init__(self, api: ReportsAPI, experiment_id: str) -> None:
|
|
292
|
+
self.hypothesis = _BoundReportKind(api, experiment_id, "hypothesis")
|
|
293
|
+
self.takeaways = _BoundReportKind(api, experiment_id, "takeaways")
|
|
294
|
+
self.research = _BoundReportKind(api, experiment_id, "research")
|