predictabl 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.
predictabl/client.py ADDED
@@ -0,0 +1,831 @@
1
+ """Client and the objects it returns: Data, Plan, Model, Predictions.
2
+
3
+ The server does the work (understanding the data, planning features, training,
4
+ scoring); this module turns its endpoints into a few calls with progress, and
5
+ waits on background jobs for you.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import os
12
+ import time
13
+ from collections.abc import Iterable, Mapping
14
+ from dataclasses import dataclass, field
15
+ from datetime import date, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import errors
20
+ from ._http import Transport, resolve_base_url
21
+ from ._progress import Callback, Progress
22
+
23
+ # Run states, in order, with the name people see.
24
+ _STEPS = {
25
+ "QUEUED": "Queued",
26
+ "PLAN": "Preparing",
27
+ "BUILD_DATASET": "Building data",
28
+ "VALIDATE_DATASET": "Checking data",
29
+ "TRAIN": "Training",
30
+ "EVALUATE": "Testing",
31
+ "SCORE": "Final model",
32
+ }
33
+ _TERMINAL = {"SUCCEEDED", "FAILED", "CANCELLED"}
34
+ _RECORDS_PER_CALL = 1000 # the server's cap for predict-records
35
+ _MAX_LIMIT = 2000 # the server's cap for `top`
36
+
37
+
38
+ class Client:
39
+ """A connection to Predictabl.
40
+
41
+ `api_key` defaults to the PREDICTABL_API_KEY environment variable and
42
+ `base_url` to PREDICTABL_BASE_URL, else the hosted app. For a local
43
+ instance use `base_url="http://localhost:8000/api/v1"`.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ api_key: str | None = None,
49
+ base_url: str | None = None,
50
+ *,
51
+ timeout: float = 120.0,
52
+ poll_interval: float = 2.0,
53
+ client_tag: str | None = None,
54
+ _http=None,
55
+ ):
56
+ self.api_key = api_key or os.environ.get("PREDICTABL_API_KEY")
57
+ self.base_url = resolve_base_url(base_url)
58
+ self.poll_interval = poll_interval
59
+ self._t = Transport(
60
+ self.api_key,
61
+ self.base_url,
62
+ timeout=timeout,
63
+ client_tag=client_tag,
64
+ http=_http,
65
+ )
66
+
67
+ def close(self) -> None:
68
+ """Close the HTTP connection. `with pb.Client() as c:` does it for you."""
69
+ self._t.close()
70
+
71
+ def __enter__(self) -> Client:
72
+ return self
73
+
74
+ def __exit__(self, *exc) -> None:
75
+ self.close()
76
+
77
+ def __repr__(self) -> str:
78
+ return f"<predictabl.Client {self.base_url}>"
79
+
80
+ # --------------------------------------------------------------- account
81
+
82
+ def account(self) -> dict:
83
+ """Who you are, your plan, and builds used/left."""
84
+ return self._t.get("/account")
85
+
86
+ # ------------------------------------------------------------------ data
87
+
88
+ def datasets(self) -> list[Data]:
89
+ """Every dataset on this account: uploads and connected databases."""
90
+ return [Data._from(self, row) for row in self._t.get("/connections") or []]
91
+
92
+ def data(self, data_id: str) -> Data:
93
+ """One dataset by id (from `datasets()` or the web app)."""
94
+ for row in self._t.get("/connections") or []:
95
+ if row["id"] == data_id:
96
+ return Data._from(self, row)
97
+ raise errors.NotFound(f"No dataset {data_id!r} on this account.")
98
+
99
+ def upload(
100
+ self,
101
+ source: str | Path | Iterable[str | Path] | Any,
102
+ name: str | None = None,
103
+ *,
104
+ wait: bool = True,
105
+ quiet: bool = False,
106
+ on_progress: Callback | None = None,
107
+ timeout: float | None = 1800,
108
+ ) -> Data:
109
+ """Upload CSV, Excel or Parquet files (or a pandas DataFrame) as a new
110
+ dataset. Several files become several tables Predictabl can join.
111
+
112
+ With `wait` (default) returns once Predictabl has read the tables."""
113
+ files, label = _files_of(source)
114
+ progress = Progress(quiet=quiet, on_update=on_progress)
115
+ size = sum(len(content) for _, content, _ in files)
116
+ progress.running("Uploading", f"{label} · {_human_bytes(size)}")
117
+ try:
118
+ row = self._t.post(
119
+ "/connections/from-csv",
120
+ data={"name": name or label},
121
+ files=[
122
+ ("files", (fname, content, ctype)) for fname, content, ctype in files
123
+ ],
124
+ )
125
+ except errors.PredictablError:
126
+ progress.failed("Uploading")
127
+ raise
128
+ progress.done("Uploading", f"{label} · {_human_bytes(size)}")
129
+ data = Data._from(self, row)
130
+ if wait:
131
+ data.wait_ready(progress=progress, timeout=timeout)
132
+ return data
133
+
134
+ def plan(self, question_id: str) -> Plan:
135
+ """The latest plan of a question (opportunity) planned earlier, e.g.
136
+ in the web app, to retrain it from code without planning again."""
137
+ got = self._t.get(f"/opportunities/{question_id}/spec") or {}
138
+ spec = got.get("spec")
139
+ if not spec:
140
+ raise errors.NotFound(f"Question {question_id!r} has no plan yet.")
141
+ return Plan(
142
+ id=spec["id"],
143
+ version=int(got.get("version") or 1),
144
+ opportunity_id=question_id,
145
+ spec=spec,
146
+ _client=self,
147
+ )
148
+
149
+ # ---------------------------------------------------------------- models
150
+
151
+ def models(self, limit: int = 50) -> list[Model]:
152
+ """Your models, newest first, trained or not (check `model.ready`)."""
153
+ rows = self._t.get("/runs", limit=limit) or []
154
+ return [Model._from(self, row) for row in rows[:limit]]
155
+
156
+ def model(self, model_id: str) -> Model:
157
+ """One model by id (from `models()` or the web app address)."""
158
+ return Model._from(self, self._t.get(f"/runs/{model_id}"))
159
+
160
+ # --------------------------------------------------------------- helpers
161
+
162
+ def _sleep(self, progress: Progress | None) -> None:
163
+ end = time.monotonic() + self.poll_interval
164
+ while True:
165
+ left = end - time.monotonic()
166
+ if left <= 0:
167
+ return
168
+ if progress is not None:
169
+ progress.tick()
170
+ time.sleep(min(0.2, left))
171
+
172
+ @property
173
+ def web_url(self) -> str:
174
+ """Where the web app for this instance lives (best effort)."""
175
+ base = self.base_url
176
+ for suffix in ("/pub/v1", "/api/v1"):
177
+ if base.endswith(suffix):
178
+ base = base[: -len(suffix)]
179
+ if base.startswith(("http://localhost:8000", "http://127.0.0.1:8000")):
180
+ return base.replace(":8000", ":3000")
181
+ return base
182
+
183
+
184
+ # ============================================================================
185
+
186
+
187
+ @dataclass
188
+ class Data:
189
+ """A dataset on Predictabl: uploaded files or a connected database."""
190
+
191
+ id: str
192
+ name: str
193
+ status: str
194
+ kind: str | None = None
195
+ table_count: int | None = None
196
+ error: str | None = None
197
+ _client: Client = field(repr=False, default=None) # type: ignore[assignment]
198
+
199
+ @classmethod
200
+ def _from(cls, client: Client, row: Mapping) -> Data:
201
+ return cls(
202
+ id=row["id"],
203
+ name=row.get("name") or "",
204
+ status=row.get("status") or "",
205
+ kind=row.get("kind"),
206
+ table_count=row.get("table_count"),
207
+ error=row.get("error"),
208
+ _client=client,
209
+ )
210
+
211
+ def refresh(self) -> Data:
212
+ """Reload status and table count from the server."""
213
+ fresh = self._client.data(self.id)
214
+ self.status, self.table_count, self.error = (
215
+ fresh.status,
216
+ fresh.table_count,
217
+ fresh.error,
218
+ )
219
+ return self
220
+
221
+ def wait_ready(
222
+ self, *, progress: Progress | None = None, timeout: float | None = 1800
223
+ ) -> Data:
224
+ """Wait until Predictabl has read the tables (status "ready")."""
225
+ progress = progress or Progress(quiet=True)
226
+ started = time.monotonic()
227
+ progress.running("Reading data", "understanding the tables")
228
+ while True:
229
+ self.refresh()
230
+ if self.status == "ready":
231
+ n = self.table_count
232
+ progress.done(
233
+ "Reading data", f"{n} table{'s' if n != 1 else ''}" if n else ""
234
+ )
235
+ return self
236
+ if self.status in ("error", "failed"):
237
+ progress.failed("Reading data", self.error or "")
238
+ reason = self.error or "unknown error"
239
+ raise errors.BuildFailed(f"Could not read this dataset: {reason}")
240
+ _check_timeout(started, timeout, "reading the data")
241
+ self._client._sleep(progress)
242
+
243
+ def questions(self) -> list[dict]:
244
+ """Questions (prediction opportunities) found or asked on this data."""
245
+ return self._client._t.get(f"/connections/{self.id}/opportunities") or []
246
+
247
+ def plan(
248
+ self,
249
+ question: str,
250
+ *,
251
+ quiet: bool = False,
252
+ on_progress: Callback | None = None,
253
+ timeout: float | None = 1800,
254
+ ) -> Plan:
255
+ """Turn a question into a reviewable plan: target, features and what
256
+ was excluded as leakage. Spends one build on Predictabl's AI key."""
257
+ progress = Progress(quiet=quiet, on_update=on_progress)
258
+ opp = self._ask(question, progress, timeout)
259
+ return Plan._generate(self._client, opp, progress, timeout)
260
+
261
+ def predict(
262
+ self,
263
+ question: str,
264
+ *,
265
+ preset: str = "full_quality",
266
+ engine: str = "auto",
267
+ quiet: bool = False,
268
+ on_progress: Callback | None = None,
269
+ timeout: float | None = None,
270
+ ) -> Model:
271
+ """The whole path: question -> plan -> trained, tested model."""
272
+ progress = Progress(quiet=quiet, on_update=on_progress)
273
+ opp = self._ask(question, progress, 1800)
274
+ plan = Plan._generate(self._client, opp, progress, 1800)
275
+ return plan._train(preset, engine, progress, timeout)
276
+
277
+ def delete(self) -> None:
278
+ """Delete this dataset and its uploaded files. Models trained on it can
279
+ no longer read it, so delete only data you no longer score from."""
280
+ self._client._t.delete(f"/connections/{self.id}")
281
+
282
+ # ------------------------------------------------------------- internals
283
+
284
+ def _ask(self, question: str, progress: Progress, timeout: float | None) -> dict:
285
+ t = self._client._t
286
+ if self.status != "ready":
287
+ self.wait_ready(progress=progress)
288
+ progress.running("Understanding", question)
289
+ before = {o["id"] for o in self.questions()}
290
+ queued = (
291
+ t.post(
292
+ f"/connections/{self.id}/opportunities",
293
+ json={"mode": "single", "user_context": question},
294
+ )
295
+ or {}
296
+ )
297
+ job_id = queued.get("job_id")
298
+ started = time.monotonic()
299
+ while True:
300
+ job = t.get(f"/connections/{self.id}/opportunities/job") or {}
301
+ state = job.get("state")
302
+ # Only OUR job counts: an earlier one on this data may be "done".
303
+ mine = job_id is None or job.get("id") == job_id
304
+ if mine and state in ("done", "failed"):
305
+ break
306
+ _check_timeout(started, timeout, "understanding the question")
307
+ self._client._sleep(progress)
308
+ new = [o for o in self.questions() if o["id"] not in before]
309
+ if state == "failed" or not new:
310
+ msg = job.get("message") or "Predictabl could not frame this question."
311
+ progress.failed("Understanding", msg)
312
+ raise errors.BuildFailed(msg)
313
+ opp = max(new, key=lambda o: o.get("created_at") or "")
314
+ progress.done("Understanding", opp.get("title") or question)
315
+ return opp
316
+
317
+
318
+ # ============================================================================
319
+
320
+
321
+ @dataclass
322
+ class Plan:
323
+ """What Predictabl will train: the target, the features, and what it
324
+ excluded as leakage. Review it, refine it in plain words, then train."""
325
+
326
+ id: str
327
+ version: int
328
+ opportunity_id: str
329
+ spec: dict = field(repr=False)
330
+ _client: Client = field(repr=False, default=None) # type: ignore[assignment]
331
+
332
+ @property
333
+ def task(self) -> str | None:
334
+ return self.spec.get("task")
335
+
336
+ @property
337
+ def target(self) -> str:
338
+ t = self.spec.get("target") or {}
339
+ return t.get("definition") or t.get("name") or ""
340
+
341
+ @property
342
+ def features(self) -> list[str]:
343
+ return [
344
+ f["name"] for f in self.spec.get("features") or [] if f.get("enabled", True)
345
+ ]
346
+
347
+ @property
348
+ def excluded(self) -> list[dict]:
349
+ """Columns kept out of the model, with the reason (usually leakage)."""
350
+ return list(self.spec.get("leakage_exclusions") or [])
351
+
352
+ def summary(self) -> str:
353
+ """The plan in a few readable lines; also what `print(plan)` shows."""
354
+ lines = [f"Predicting: {self.target}", f"Task: {self.task}"]
355
+ lines.append(f"Features ({len(self.features)}): " + ", ".join(self.features[:12]))
356
+ if len(self.features) > 12:
357
+ lines[-1] += f", … (+{len(self.features) - 12})"
358
+ for ex in self.excluded:
359
+ col = ex.get("column") or ex.get("name") or "?"
360
+ lines.append(f"Excluded {col}: {ex.get('reason') or ''}".rstrip(": "))
361
+ return "\n".join(lines)
362
+
363
+ def __str__(self) -> str:
364
+ return self.summary()
365
+
366
+ def refine(
367
+ self,
368
+ message: str,
369
+ *,
370
+ quiet: bool = False,
371
+ on_progress: Callback | None = None,
372
+ timeout: float | None = 1800,
373
+ ) -> Plan:
374
+ """Change the plan in plain words ("don't use the courier column").
375
+ Returns the new version; this object stays the version it was."""
376
+ t = self._client._t
377
+ progress = Progress(quiet=quiet, on_update=on_progress)
378
+ progress.running("Refining", message)
379
+ t.post(
380
+ f"/opportunities/{self.opportunity_id}/spec/refine", json={"message": message}
381
+ )
382
+ new = _wait_for_spec(
383
+ self._client, self.opportunity_id, progress, timeout, newer_than=self.version
384
+ )
385
+ progress.done("Refining", f"version {new.version}")
386
+ return new
387
+
388
+ def train(
389
+ self,
390
+ *,
391
+ preset: str = "full_quality",
392
+ engine: str = "auto",
393
+ wait: bool = True,
394
+ quiet: bool = False,
395
+ on_progress: Callback | None = None,
396
+ timeout: float | None = None,
397
+ ) -> Model:
398
+ """Train and test this plan. `preset="fast_preview"` is a ~15-second
399
+ sanity check on a sample; the default is the real model."""
400
+ progress = Progress(quiet=quiet, on_update=on_progress)
401
+ return self._train(preset, engine, progress, timeout, wait=wait)
402
+
403
+ # ------------------------------------------------------------- internals
404
+
405
+ @classmethod
406
+ def _generate(
407
+ cls, client: Client, opp: dict, progress: Progress, timeout: float | None
408
+ ) -> Plan:
409
+ progress.running("Planning", "target, features, leakage checks")
410
+ client._t.post(f"/opportunities/{opp['id']}/spec", json={})
411
+ plan = _wait_for_spec(client, opp["id"], progress, timeout)
412
+ progress.done(
413
+ "Planning",
414
+ f"{len(plan.features)} features · {len(plan.excluded)} excluded",
415
+ )
416
+ return plan
417
+
418
+ def _train(
419
+ self,
420
+ preset: str,
421
+ engine: str,
422
+ progress: Progress,
423
+ timeout: float | None,
424
+ *,
425
+ wait: bool = True,
426
+ ) -> Model:
427
+ row = self._client._t.post(
428
+ "/runs", json={"spec_id": self.id, "preset": preset, "engine": engine}
429
+ )
430
+ model = Model._from(self._client, row)
431
+ if wait:
432
+ model._wait(progress, timeout)
433
+ return model
434
+
435
+
436
+ def _wait_for_spec(
437
+ client: Client,
438
+ opportunity_id: str,
439
+ progress: Progress,
440
+ timeout: float | None,
441
+ *,
442
+ newer_than: int | None = None,
443
+ ) -> Plan:
444
+ t = client._t
445
+ started = time.monotonic()
446
+ while True:
447
+ got = t.get(f"/opportunities/{opportunity_id}/spec") or {}
448
+ spec = got.get("spec")
449
+ if spec and (newer_than is None or int(got.get("version") or 0) > newer_than):
450
+ return Plan(
451
+ id=spec["id"],
452
+ version=int(got.get("version") or spec.get("version") or 1),
453
+ opportunity_id=opportunity_id,
454
+ spec=spec,
455
+ _client=client,
456
+ )
457
+ opp = t.get(f"/opportunities/{opportunity_id}") or {}
458
+ failure = opp.get("last_generation_error")
459
+ if failure:
460
+ msg = failure.get("message") if isinstance(failure, dict) else str(failure)
461
+ progress.failed(detail=msg or "")
462
+ raise errors.BuildFailed(msg or "Planning failed.")
463
+ _check_timeout(started, timeout, "planning")
464
+ client._sleep(progress)
465
+
466
+
467
+ # ============================================================================
468
+
469
+
470
+ @dataclass
471
+ class Model:
472
+ """A trained (or training) model. `predict()` scores with it."""
473
+
474
+ id: str
475
+ state: str
476
+ title: str | None = None
477
+ kind: str | None = None # forecast | event | flat
478
+ meaning: str | None = None
479
+ created_via: str | None = None
480
+ error_code: str | None = None
481
+ error_message: str | None = None
482
+ suggestions: list = field(default_factory=list)
483
+ _client: Client = field(repr=False, default=None) # type: ignore[assignment]
484
+
485
+ @classmethod
486
+ def _from(cls, client: Client, row: Mapping) -> Model:
487
+ return cls(
488
+ id=row["id"],
489
+ state=row.get("state") or "",
490
+ title=row.get("opportunity_title"),
491
+ kind=row.get("kind"),
492
+ meaning=row.get("meaning"),
493
+ created_via=row.get("created_via"),
494
+ error_code=row.get("error_code"),
495
+ error_message=row.get("error_message"),
496
+ suggestions=row.get("suggestions") or [],
497
+ _client=client,
498
+ )
499
+
500
+ @property
501
+ def url(self) -> str:
502
+ """This model in the web app."""
503
+ return f"{self._client.web_url}/runs/{self.id}"
504
+
505
+ @property
506
+ def ready(self) -> bool:
507
+ return self.state == "SUCCEEDED"
508
+
509
+ def refresh(self) -> Model:
510
+ """Reload state, kind and errors from the server."""
511
+ fresh = self._client.model(self.id)
512
+ self.__dict__.update({k: v for k, v in fresh.__dict__.items() if k != "_client"})
513
+ return self
514
+
515
+ def wait(
516
+ self,
517
+ *,
518
+ quiet: bool = False,
519
+ on_progress: Callback | None = None,
520
+ timeout: float | None = None,
521
+ ) -> Model:
522
+ """Block until training ends. Raises BuildFailed if it failed."""
523
+ return self._wait(Progress(quiet=quiet, on_update=on_progress), timeout)
524
+
525
+ def events(self) -> list[dict]:
526
+ """Everything the run reported, step by step."""
527
+ return self._client._t.get(f"/runs/{self.id}/events.json") or []
528
+
529
+ def cancel(self) -> None:
530
+ """Stop training. The model ends CANCELLED."""
531
+ self._client._t.post(f"/runs/{self.id}/cancel")
532
+
533
+ def report(self) -> dict:
534
+ """The tested result: metric, rows, top features, what it predicts."""
535
+ res = self._client._t.get(f"/runs/{self.id}/results") or {}
536
+ winner = res.get("winner")
537
+ scores = (res.get("engines") or {}).get(winner) or {}
538
+ metric = res.get("primary_metric") or _default_metric(res.get("task"))
539
+ imp = res.get("feature_importance") or []
540
+ if isinstance(imp, dict):
541
+ imp = imp.get(winner) or next(iter(imp.values()), [])
542
+ top = [
543
+ (f.get("feature") or f.get("name")) if isinstance(f, dict) else f
544
+ for f in (imp or [])[:10]
545
+ ]
546
+ if self.kind is None:
547
+ self.refresh()
548
+ return {
549
+ "model": self.id,
550
+ "predicts": self.title,
551
+ "meaning": self.meaning,
552
+ "kind": self.kind,
553
+ "task": res.get("task"),
554
+ "engine": winner,
555
+ "metric": metric,
556
+ "score": scores.get(metric) if metric else None,
557
+ "score_std": (scores.get("metric_std") or {}).get(metric) if metric else None,
558
+ "rows_trained": res.get("n_train"),
559
+ "rows_tested": res.get("n_test"),
560
+ "top_features": [t for t in top if t],
561
+ "url": self.url,
562
+ }
563
+
564
+ def predict(
565
+ self,
566
+ rows: Any = None,
567
+ *,
568
+ ids: Iterable[str] | str | None = None,
569
+ since: str | date | datetime | None = None,
570
+ until: str | date | datetime | None = None,
571
+ since_days: int | None = None,
572
+ as_of: str | date | datetime | None = None,
573
+ limit: int | None = None,
574
+ filters: list[dict] | None = None,
575
+ include: list[str] | None = None,
576
+ calibrated: bool = False,
577
+ ) -> Predictions:
578
+ """Score with this model.
579
+
580
+ * `rows` (DataFrame, list of dicts or one dict): score these records.
581
+ Predictabl builds the features from them with the model's own SQL.
582
+ * otherwise Predictabl scores from the data it was trained on:
583
+ forecasts from the latest data (or `as_of`), event models for rows
584
+ dated today (or `since`/`until`/`since_days`), optionally only
585
+ `ids`, highest first, at most `limit` (server cap 2000).
586
+ """
587
+ if rows is not None:
588
+ return self._predict_rows(rows, calibrated)
589
+ body: dict[str, Any] = {
590
+ "top": min(int(limit), _MAX_LIMIT) if limit else _MAX_LIMIT,
591
+ "calibrated": calibrated,
592
+ }
593
+ if since is not None:
594
+ body["since"] = _iso(since)
595
+ if until is not None:
596
+ body["until"] = _iso(until)
597
+ if since_days is not None:
598
+ body["since_days"] = int(since_days)
599
+ if as_of is not None:
600
+ body["cutoff"] = _iso(as_of)
601
+ if filters:
602
+ body["filters"] = filters
603
+ if include:
604
+ body["include"] = include
605
+ if ids is None:
606
+ return Predictions._from(
607
+ self._client._t.post(f"/runs/{self.id}/predict", json=body)
608
+ )
609
+ wanted = [ids] if isinstance(ids, str) else [str(i) for i in ids]
610
+ merged: Predictions | None = None
611
+ for one in wanted:
612
+ got = self._client._t.post(
613
+ f"/runs/{self.id}/predict", json={**body, "entity_contains": one}
614
+ )
615
+ part = Predictions._from(got)
616
+ # entity_contains is a substring match; keep exact ids only.
617
+ keep = [p for p in part if _is_entity(p.get("entity_id"), one)]
618
+ if merged is None:
619
+ merged = part
620
+ merged[:] = keep
621
+ else:
622
+ merged.extend(keep)
623
+ assert merged is not None
624
+ merged.count = len(merged)
625
+ return merged
626
+
627
+ # ------------------------------------------------------------- internals
628
+
629
+ def _predict_rows(self, rows: Any, calibrated: bool) -> Predictions:
630
+ records = _records_of(rows)
631
+ if not records:
632
+ raise errors.InvalidRequest("No rows to score.")
633
+ out: Predictions | None = None
634
+ for i in range(0, len(records), _RECORDS_PER_CALL):
635
+ got = self._client._t.post(
636
+ f"/runs/{self.id}/predict-records",
637
+ json={
638
+ "rows": records[i : i + _RECORDS_PER_CALL],
639
+ "calibrated": calibrated,
640
+ },
641
+ )
642
+ part = Predictions._from(got)
643
+ if out is None:
644
+ out = part
645
+ else:
646
+ out.extend(part)
647
+ assert out is not None
648
+ out.count = len(out)
649
+ return out
650
+
651
+ def _wait(self, progress: Progress, timeout: float | None) -> Model:
652
+ started = time.monotonic()
653
+ seen = 0
654
+ while True:
655
+ self.refresh()
656
+ if self.state in _STEPS:
657
+ detail = ""
658
+ events = self.events()
659
+ for ev in events[seen:]:
660
+ if ev.get("state") == self.state and ev.get("message"):
661
+ detail = _short(ev["message"])
662
+ seen = len(events)
663
+ progress.running(_STEPS[self.state], detail)
664
+ if self.state in _TERMINAL:
665
+ break
666
+ _check_timeout(started, timeout, "training")
667
+ self._client._sleep(progress)
668
+ if self.state == "SUCCEEDED":
669
+ rep = self.report()
670
+ score = rep.get("score")
671
+ detail = (
672
+ f"{rep['metric']} {score:.4f}" if isinstance(score, float) else "done"
673
+ )
674
+ progress.done("Final model", detail)
675
+ return self
676
+ progress.failed(detail=self.error_message or self.state.lower())
677
+ raise errors.BuildFailed(
678
+ self.error_message or f"Run {self.state.lower()}.",
679
+ code=self.error_code,
680
+ suggestions=self.suggestions,
681
+ )
682
+
683
+
684
+ # ============================================================================
685
+
686
+
687
+ class Predictions(list):
688
+ """A list of predictions (dicts with `entity_id`, `prediction`, ...),
689
+ plus what they mean: `meaning`, `kind`, `as_of`, and any `note` or
690
+ `warning` the server attached."""
691
+
692
+ meaning: str | None
693
+ kind: str | None
694
+ as_of: str | None
695
+ note: str | None
696
+ warning: str | None
697
+ where_used: str | None
698
+ count: int
699
+ raw: dict
700
+
701
+ @classmethod
702
+ def _from(cls, data: Mapping) -> Predictions:
703
+ p = cls(data.get("predictions") or [])
704
+ p.meaning = data.get("meaning")
705
+ p.kind = data.get("kind")
706
+ p.as_of = data.get("as_of")
707
+ p.note = data.get("note")
708
+ p.warning = data.get("warning")
709
+ p.where_used = data.get("where_used")
710
+ p.count = int(data.get("count") or len(p))
711
+ p.raw = {k: v for k, v in data.items() if k != "predictions"}
712
+ return p
713
+
714
+ def to_pandas(self):
715
+ """The predictions as a DataFrame (needs pandas). Context columns
716
+ requested with `include` become their own columns."""
717
+ import pandas as pd
718
+
719
+ flat = []
720
+ for p in self:
721
+ row = {k: v for k, v in p.items() if k not in ("context", "probabilities")}
722
+ for k, v in (p.get("context") or {}).items():
723
+ row[k] = v
724
+ for k, v in (p.get("probabilities") or {}).items():
725
+ row[f"p_{k}"] = v
726
+ flat.append(row)
727
+ return pd.DataFrame(flat)
728
+
729
+ def __repr__(self) -> str:
730
+ head = f"<Predictions {len(self)}"
731
+ if self.count and self.count > len(self):
732
+ head += f" of {self.count}"
733
+ if self.meaning:
734
+ head += f" · {self.meaning}"
735
+ if self.as_of:
736
+ head += f" · as of {self.as_of}"
737
+ return head + ">"
738
+
739
+
740
+ # ============================================================================
741
+
742
+
743
+ def _files_of(source: Any) -> tuple[list[tuple[str, bytes, str]], str]:
744
+ """(files, label) for an upload: paths, a list of paths, or a DataFrame."""
745
+ if hasattr(source, "to_csv") and hasattr(source, "columns"): # DataFrame
746
+ buf = io.StringIO()
747
+ source.to_csv(buf, index=False)
748
+ return [("data.csv", buf.getvalue().encode(), "text/csv")], "data.csv"
749
+ paths = [source] if isinstance(source, str | Path) else list(source)
750
+ files = []
751
+ for p in paths:
752
+ path = Path(p)
753
+ if not path.exists():
754
+ raise errors.InvalidRequest(f"No such file: {path}")
755
+ files.append((path.name, path.read_bytes(), _content_type(path)))
756
+ label = files[0][0] if len(files) == 1 else f"{len(files)} files"
757
+ return files, label
758
+
759
+
760
+ def _content_type(path: Path) -> str:
761
+ return {
762
+ ".csv": "text/csv",
763
+ ".parquet": "application/octet-stream",
764
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
765
+ ".xls": "application/vnd.ms-excel",
766
+ }.get(path.suffix.lower(), "application/octet-stream")
767
+
768
+
769
+ def _records_of(rows: Any) -> list[dict]:
770
+ if isinstance(rows, Mapping):
771
+ return [_jsonable(dict(rows))]
772
+ if hasattr(rows, "to_dict") and hasattr(rows, "columns"): # DataFrame
773
+ import json
774
+
775
+ return json.loads(rows.to_json(orient="records", date_format="iso"))
776
+ return [_jsonable(dict(r)) for r in rows]
777
+
778
+
779
+ def _jsonable(row: dict) -> dict:
780
+ out = {}
781
+ for k, v in row.items():
782
+ if isinstance(v, datetime | date):
783
+ out[k] = v.isoformat()
784
+ elif hasattr(v, "item") and callable(v.item): # numpy scalar
785
+ out[k] = v.item()
786
+ else:
787
+ out[k] = v
788
+ return out
789
+
790
+
791
+ def _iso(v: str | date | datetime) -> str:
792
+ if isinstance(v, datetime):
793
+ return v.isoformat(sep=" ", timespec="seconds")
794
+ if isinstance(v, date):
795
+ return v.isoformat()
796
+ return str(v)
797
+
798
+
799
+ def _is_entity(entity_id: str | None, wanted: str) -> bool:
800
+ if entity_id is None:
801
+ return False
802
+ return entity_id == wanted or entity_id.split("·")[0] == wanted
803
+
804
+
805
+ def _default_metric(task: str | None) -> str | None:
806
+ return {
807
+ "binary_classification": "auc",
808
+ "multiclass": "accuracy",
809
+ "regression": "rmse",
810
+ }.get(task or "")
811
+
812
+
813
+ def _short(message: str, width: int = 70) -> str:
814
+ message = " ".join(message.split())
815
+ return message if len(message) <= width else message[: width - 1] + "…"
816
+
817
+
818
+ def _human_bytes(n: int) -> str:
819
+ for unit in ("B", "KB", "MB", "GB"):
820
+ if n < 1024 or unit == "GB":
821
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
822
+ n /= 1024
823
+ return f"{n} B" # pragma: no cover
824
+
825
+
826
+ def _check_timeout(started: float, timeout: float | None, what: str) -> None:
827
+ if timeout is not None and time.monotonic() - started > timeout:
828
+ raise errors.PredictablError(
829
+ f"Timed out after {timeout:.0f}s while {what}. It may still be running "
830
+ "on the server; check the web app."
831
+ )