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 +105 -0
- predictabl/__main__.py +52 -0
- predictabl/_http.py +129 -0
- predictabl/_progress.py +103 -0
- predictabl/_version.py +1 -0
- predictabl/assets/logo-128.png +0 -0
- predictabl/client.py +831 -0
- predictabl/errors.py +61 -0
- predictabl/mcp.py +262 -0
- predictabl-0.1.0.dist-info/METADATA +160 -0
- predictabl-0.1.0.dist-info/RECORD +14 -0
- predictabl-0.1.0.dist-info/WHEEL +4 -0
- predictabl-0.1.0.dist-info/entry_points.txt +2 -0
- predictabl-0.1.0.dist-info/licenses/LICENSE +21 -0
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]
|
predictabl/_progress.py
ADDED
|
@@ -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
|