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/__init__.py ADDED
@@ -0,0 +1,105 @@
1
+ """Predictabl from Python.
2
+
3
+ import predictabl as pb
4
+
5
+ pb.login("pk_...") # or set PREDICTABL_API_KEY
6
+ data = pb.upload("orders.csv")
7
+ model = data.predict("Will an order arrive late?")
8
+ model.report()
9
+ model.predict(rows=new_orders)
10
+
11
+ Module-level functions use one shared client, configured by `login()` or the
12
+ PREDICTABL_API_KEY / PREDICTABL_BASE_URL environment variables. For several
13
+ accounts or instances at once, create `predictabl.Client` objects instead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from ._progress import Update
19
+ from ._version import __version__
20
+ from .client import Client, Data, Model, Plan, Predictions
21
+ from .errors import (
22
+ ApiError,
23
+ AuthenticationError,
24
+ BuildFailed,
25
+ InvalidRequest,
26
+ LimitReached,
27
+ NotFound,
28
+ PermissionDenied,
29
+ PredictablError,
30
+ )
31
+
32
+ __all__ = [
33
+ "__version__",
34
+ "login",
35
+ "client",
36
+ "account",
37
+ "upload",
38
+ "data",
39
+ "datasets",
40
+ "plan",
41
+ "model",
42
+ "models",
43
+ "Client",
44
+ "Data",
45
+ "Plan",
46
+ "Model",
47
+ "Predictions",
48
+ "Update",
49
+ "PredictablError",
50
+ "AuthenticationError",
51
+ "PermissionDenied",
52
+ "NotFound",
53
+ "LimitReached",
54
+ "InvalidRequest",
55
+ "BuildFailed",
56
+ "ApiError",
57
+ ]
58
+
59
+ _default: Client | None = None
60
+
61
+
62
+ def login(api_key: str | None = None, base_url: str | None = None, **kw) -> Client:
63
+ """Configure the shared client. Without arguments, reads
64
+ PREDICTABL_API_KEY (and PREDICTABL_BASE_URL) from the environment."""
65
+ global _default
66
+ if _default is not None:
67
+ _default.close()
68
+ _default = Client(api_key=api_key, base_url=base_url, **kw)
69
+ return _default
70
+
71
+
72
+ def client() -> Client:
73
+ """The shared client, created from the environment on first use."""
74
+ global _default
75
+ if _default is None:
76
+ _default = Client()
77
+ return _default
78
+
79
+
80
+ def account() -> dict:
81
+ return client().account()
82
+
83
+
84
+ def upload(source, name: str | None = None, **kw) -> Data:
85
+ return client().upload(source, name, **kw)
86
+
87
+
88
+ def data(data_id: str) -> Data:
89
+ return client().data(data_id)
90
+
91
+
92
+ def datasets() -> list[Data]:
93
+ return client().datasets()
94
+
95
+
96
+ def plan(question_id: str) -> Plan:
97
+ return client().plan(question_id)
98
+
99
+
100
+ def model(model_id: str) -> Model:
101
+ return client().model(model_id)
102
+
103
+
104
+ def models(limit: int = 50) -> list[Model]:
105
+ return client().models(limit)
predictabl/__main__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Command line: `predictabl whoami`, `predictabl models`, `predictabl mcp`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import errors
10
+ from ._version import __version__
11
+ from .client import Client
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(
16
+ prog="predictabl", description="Predictabl from the shell."
17
+ )
18
+ parser.add_argument(
19
+ "--version", action="version", version=f"predictabl {__version__}"
20
+ )
21
+ parser.add_argument(
22
+ "--base-url", help="default: PREDICTABL_BASE_URL or the hosted app"
23
+ )
24
+ sub = parser.add_subparsers(dest="cmd", required=True)
25
+ sub.add_parser("whoami", help="your account, plan and builds left")
26
+ sub.add_parser("models", help="your models")
27
+ sub.add_parser(
28
+ "mcp", help="run the MCP server over stdio (for Claude and other agents)"
29
+ )
30
+ args = parser.parse_args(argv)
31
+
32
+ if args.cmd == "mcp":
33
+ from .mcp import serve
34
+
35
+ serve(base_url=args.base_url)
36
+ return 0
37
+
38
+ try:
39
+ with Client(base_url=args.base_url) as c:
40
+ if args.cmd == "whoami":
41
+ print(json.dumps(c.account(), indent=2))
42
+ elif args.cmd == "models":
43
+ for m in c.models():
44
+ print(f"{m.id} {m.state:<10} {m.title or ''}")
45
+ except errors.PredictablError as exc:
46
+ print(f"predictabl: {exc}", file=sys.stderr)
47
+ return 1
48
+ return 0
49
+
50
+
51
+ if __name__ == "__main__":
52
+ sys.exit(main())
predictabl/_http.py ADDED
@@ -0,0 +1,129 @@
1
+ """The one place that talks HTTP.
2
+
3
+ Unwraps the API's `{data, error}` envelope, turns refusals into typed errors,
4
+ and retries what is safe to retry (connection failures and 502/503/504 on
5
+ reads). Everything else in the package goes through `Transport`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from . import errors
17
+ from ._version import __version__
18
+
19
+ HOSTED_URL = "https://app.predictabl.tech/pub/v1"
20
+ LOCAL_URL = "http://localhost:8000/api/v1"
21
+
22
+ _RETRY_STATUS = {502, 503, 504}
23
+
24
+
25
+ def resolve_base_url(base_url: str | None) -> str:
26
+ url = base_url or os.environ.get("PREDICTABL_BASE_URL") or HOSTED_URL
27
+ return url.rstrip("/")
28
+
29
+
30
+ class Transport:
31
+ def __init__(
32
+ self,
33
+ api_key: str | None,
34
+ base_url: str,
35
+ *,
36
+ timeout: float = 120.0,
37
+ client_tag: str | None = None,
38
+ http: httpx.Client | None = None,
39
+ ):
40
+ self.base_url = base_url.rstrip("/")
41
+ self.api_key = api_key
42
+ agent = f"predictabl-python/{__version__}"
43
+ if client_tag:
44
+ agent += f" ({client_tag})"
45
+ headers = {"User-Agent": agent, "Accept": "application/json"}
46
+ if api_key:
47
+ headers["Authorization"] = f"Bearer {api_key}"
48
+ self._http = http or httpx.Client(timeout=timeout)
49
+ self._headers = headers
50
+
51
+ def close(self) -> None:
52
+ self._http.close()
53
+
54
+ # ------------------------------------------------------------------ calls
55
+
56
+ def get(self, path: str, **params: Any) -> Any:
57
+ params = {k: v for k, v in params.items() if v is not None}
58
+ return self._request("GET", path, params=params or None, retry=True)
59
+
60
+ def post(self, path: str, json: Any = None, **kw: Any) -> Any:
61
+ return self._request("POST", path, json=json, **kw)
62
+
63
+ def put(self, path: str, json: Any = None) -> Any:
64
+ return self._request("PUT", path, json=json)
65
+
66
+ def patch(self, path: str, json: Any = None) -> Any:
67
+ return self._request("PATCH", path, json=json)
68
+
69
+ def delete(self, path: str) -> Any:
70
+ return self._request("DELETE", path)
71
+
72
+ def _request(self, method: str, path: str, *, retry: bool = False, **kw: Any) -> Any:
73
+ url = f"{self.base_url}{path}"
74
+ attempts = 4 if retry else 1
75
+ last: Exception | None = None
76
+ for i in range(attempts):
77
+ try:
78
+ resp = self._http.request(method, url, headers=self._headers, **kw)
79
+ except httpx.TransportError as exc:
80
+ last = exc
81
+ if i + 1 < attempts:
82
+ time.sleep(min(2**i, 8))
83
+ continue
84
+ raise errors.ApiError(
85
+ f"Could not reach Predictabl at {self.base_url} ({exc})."
86
+ ) from exc
87
+ if resp.status_code in _RETRY_STATUS and i + 1 < attempts:
88
+ time.sleep(min(2**i, 8))
89
+ continue
90
+ return _unwrap(resp)
91
+ raise errors.ApiError(str(last)) # pragma: no cover - loop always returns
92
+
93
+
94
+ def _unwrap(resp: httpx.Response) -> Any:
95
+ try:
96
+ body = resp.json()
97
+ except ValueError:
98
+ body = None
99
+ if resp.is_success and isinstance(body, dict) and body.get("error") is None:
100
+ return body.get("data") if "data" in body else body
101
+ code, message = _error_of(body, resp)
102
+ status = resp.status_code
103
+ cls = {
104
+ 401: errors.AuthenticationError,
105
+ 402: errors.LimitReached,
106
+ 403: errors.PermissionDenied,
107
+ 404: errors.NotFound,
108
+ }.get(status)
109
+ if cls is None:
110
+ cls = errors.InvalidRequest if 400 <= status < 500 else errors.ApiError
111
+ if status == 401 and not message:
112
+ message = "Missing or invalid API key. Set PREDICTABL_API_KEY or call login()."
113
+ raise cls(message or f"HTTP {status}", code=code, status=status)
114
+
115
+
116
+ def _error_of(body: Any, resp: httpx.Response) -> tuple[str | None, str]:
117
+ """The API answers `{data: null, error: {code, message}}`; FastAPI's own
118
+ refusals (auth, scope) answer `{detail: ...}`. Accept both."""
119
+ if isinstance(body, dict):
120
+ err = body.get("error")
121
+ if isinstance(err, dict):
122
+ return err.get("code"), str(err.get("message") or "")
123
+ detail = body.get("detail")
124
+ if isinstance(detail, str):
125
+ return None, detail
126
+ if detail is not None:
127
+ return None, str(detail)
128
+ text = (resp.text or "").strip()
129
+ return None, text[:500]
@@ -0,0 +1,103 @@
1
+ """Step-by-step progress for long operations (reading data, planning, training).
2
+
3
+ In a terminal the current step updates in place with a spinner; elsewhere
4
+ (notebooks, logs, CI) each change is printed once on its own line, so output
5
+ stays readable when it is not a TTY. `quiet=True` prints nothing; a callback
6
+ receives every update either way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import sys
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+
17
+
18
+ @dataclass
19
+ class Update:
20
+ step: str # e.g. "Training"
21
+ status: str # "running" | "done" | "failed"
22
+ detail: str = ""
23
+
24
+
25
+ Callback = Callable[[Update], None]
26
+
27
+ _SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
28
+
29
+
30
+ class Progress:
31
+ def __init__(
32
+ self, *, quiet: bool = False, on_update: Callback | None = None, stream=None
33
+ ):
34
+ self.quiet = quiet
35
+ self.on_update = on_update
36
+ self.stream = stream or sys.stderr
37
+ self.tty = bool(getattr(self.stream, "isatty", lambda: False)())
38
+ self._spin = itertools.cycle(_SPIN)
39
+ self._current: str | None = None
40
+ self._last_line = ""
41
+ self._last_printed: tuple[str, str] | None = None
42
+ self._started = time.monotonic()
43
+
44
+ # ------------------------------------------------------------- reporting
45
+
46
+ def running(self, step: str, detail: str = "") -> None:
47
+ if self._current and self._current != step:
48
+ self.done(self._current)
49
+ self._current = step
50
+ self._emit(Update(step, "running", detail))
51
+
52
+ def done(self, step: str | None = None, detail: str = "") -> None:
53
+ step = step or self._current
54
+ if step is None:
55
+ return
56
+ self._emit(Update(step, "done", detail))
57
+ if step == self._current:
58
+ self._current = None
59
+
60
+ def failed(self, step: str | None = None, detail: str = "") -> None:
61
+ step = step or self._current or "Error"
62
+ self._emit(Update(step, "failed", detail))
63
+ self._current = None
64
+
65
+ def tick(self) -> None:
66
+ """Advance the spinner while waiting (terminal only)."""
67
+ if self.tty and not self.quiet and self._last_line:
68
+ self._write(self._last_line, final=False)
69
+
70
+ # ---------------------------------------------------------------- output
71
+
72
+ def _emit(self, u: Update) -> None:
73
+ if self.on_update is not None:
74
+ try:
75
+ self.on_update(u)
76
+ except Exception: # noqa: BLE001 - a user callback must not break a build
77
+ pass
78
+ if self.quiet:
79
+ return
80
+ mark = {"done": "✓", "failed": "✗"}.get(u.status)
81
+ line = f"{u.step:<14} {u.detail}".rstrip()
82
+ if self.tty:
83
+ if mark:
84
+ self._write(f"{mark} {line}", final=True)
85
+ self._last_line = ""
86
+ else:
87
+ self._last_line = line
88
+ self._write(line, final=False)
89
+ return
90
+ # Not a terminal: one line per distinct change, never per poll.
91
+ key = (u.status, line)
92
+ if key == self._last_printed:
93
+ return
94
+ self._last_printed = key
95
+ print(f"{mark or '…'} {line}", file=self.stream, flush=True)
96
+
97
+ def _write(self, line: str, *, final: bool) -> None:
98
+ if final:
99
+ text = f"\r\033[2K{line}\n"
100
+ else:
101
+ text = f"\r\033[2K{next(self._spin)} {line}"
102
+ self.stream.write(text)
103
+ self.stream.flush()
predictabl/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
Binary file