langsat 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.
- langsat/__init__.py +33 -0
- langsat/_http.py +124 -0
- langsat/_version.py +2 -0
- langsat/auth.py +91 -0
- langsat/cli.py +47 -0
- langsat/client.py +48 -0
- langsat/errors.py +123 -0
- langsat/jobs.py +72 -0
- langsat/resources/__init__.py +0 -0
- langsat/resources/_base.py +30 -0
- langsat/resources/chat.py +79 -0
- langsat/resources/dashboards.py +286 -0
- langsat/resources/models.py +156 -0
- langsat/resources/ops.py +80 -0
- langsat/resources/projects.py +127 -0
- langsat/resources/schema.py +79 -0
- langsat/resources/sources.py +123 -0
- langsat/resources/tables.py +97 -0
- langsat-0.1.0.dist-info/METADATA +137 -0
- langsat-0.1.0.dist-info/RECORD +23 -0
- langsat-0.1.0.dist-info/WHEEL +5 -0
- langsat-0.1.0.dist-info/entry_points.txt +2 -0
- langsat-0.1.0.dist-info/top_level.txt +1 -0
langsat/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Langsat SDK.
|
|
2
|
+
|
|
3
|
+
from langsat import Langsat, login
|
|
4
|
+
login() # once: paste the key from Settings → API keys
|
|
5
|
+
ls = Langsat()
|
|
6
|
+
p = ls.projects.create("demo"); p.sources.upload("orders.csv"); p.schema.detect().wait()
|
|
7
|
+
"""
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .auth import login, logout, whoami
|
|
10
|
+
from .client import Langsat
|
|
11
|
+
from .errors import (
|
|
12
|
+
AuthError,
|
|
13
|
+
Conflict,
|
|
14
|
+
Forbidden,
|
|
15
|
+
InsufficientCredits,
|
|
16
|
+
Invalid,
|
|
17
|
+
JobFailed,
|
|
18
|
+
JobTimeout,
|
|
19
|
+
LangsatError,
|
|
20
|
+
MissingScope,
|
|
21
|
+
NeedsUserSession,
|
|
22
|
+
NotFound,
|
|
23
|
+
ProjectBusy,
|
|
24
|
+
QuotaExceeded,
|
|
25
|
+
RateLimited,
|
|
26
|
+
Refused,
|
|
27
|
+
RowCapExceeded,
|
|
28
|
+
)
|
|
29
|
+
from .jobs import Job
|
|
30
|
+
|
|
31
|
+
__all__ = ["Langsat", "login", "logout", "whoami", "Job", "__version__",
|
|
32
|
+
"LangsatError", "AuthError", "MissingScope", "NeedsUserSession", "Forbidden", "NotFound", "InsufficientCredits",
|
|
33
|
+
"QuotaExceeded", "RateLimited", "ProjectBusy", "Conflict", "RowCapExceeded", "Invalid", "Refused", "JobFailed", "JobTimeout"]
|
langsat/_http.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The one HTTP layer: auth header, User-Agent, retries with backoff, Idempotency-Key, typed errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .errors import LangsatError, ProjectBusy, RateLimited, from_response
|
|
14
|
+
|
|
15
|
+
# The lanes the API replays on a repeated Idempotency-Key (mirrors SDK/openapi x-idempotent).
|
|
16
|
+
IDEMPOTENT_SUFFIXES: tuple = (
|
|
17
|
+
"/train", "/clean", "/refresh", "/data-model/refresh", "/generate-cards", "/dashboard/generate",
|
|
18
|
+
"/reports/export", "/predict", "/predict/batch", "/predict/forecast", "/inference",
|
|
19
|
+
"/add-recipe-chart", "/measures", "/task", "/suggestions", "/sources/link",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HttpClient:
|
|
24
|
+
def __init__(self, base_url: str, token: Optional[str], *, timeout: float = 60.0, max_retries: int = 4,
|
|
25
|
+
auto_idempotency: bool = True, transport: Optional[httpx.BaseTransport] = None):
|
|
26
|
+
self.base_url = base_url.rstrip("/")
|
|
27
|
+
self.token = token
|
|
28
|
+
self.max_retries = max_retries
|
|
29
|
+
self.auto_idempotency = auto_idempotency
|
|
30
|
+
headers = {"User-Agent": f"langsat-python/{__version__}", "Accept": "application/json"}
|
|
31
|
+
if token:
|
|
32
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
33
|
+
self._client = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout, transport=transport)
|
|
34
|
+
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
self._client.close()
|
|
37
|
+
|
|
38
|
+
# ── the request ──
|
|
39
|
+
def request(self, method: str, path: str, *, json_body: Any = None, params: Optional[dict] = None,
|
|
40
|
+
idempotency_key: Optional[str] = None, files: Any = None, data: Any = None,
|
|
41
|
+
wait_if_busy: float = 0.0, raw: bool = False, headers: Optional[dict] = None) -> Any:
|
|
42
|
+
"""One call, with: automatic `Idempotency-Key` on the replayable lanes (a retry after a
|
|
43
|
+
timeout then replays, never re-runs), backoff on 429 / 503 honouring `Retry-After`, and
|
|
44
|
+
— only when `wait_if_busy` > 0 — polite waiting on 409 project_busy up to that many
|
|
45
|
+
seconds. Every non-2xx becomes a typed `LangsatError`."""
|
|
46
|
+
hdrs = dict(headers or {})
|
|
47
|
+
m = method.upper()
|
|
48
|
+
if m in ("POST", "PUT", "PATCH", "DELETE"):
|
|
49
|
+
key = idempotency_key
|
|
50
|
+
if key is None and self.auto_idempotency and any(path.rstrip("/").endswith(s) for s in IDEMPOTENT_SUFFIXES):
|
|
51
|
+
key = f"sdk-{uuid.uuid4().hex}"
|
|
52
|
+
if key:
|
|
53
|
+
hdrs["Idempotency-Key"] = key
|
|
54
|
+
attempt = 0
|
|
55
|
+
busy_deadline = time.monotonic() + wait_if_busy if wait_if_busy else None
|
|
56
|
+
while True:
|
|
57
|
+
attempt += 1
|
|
58
|
+
try:
|
|
59
|
+
resp = self._client.request(m, path, json=json_body, params=params, files=files, data=data, headers=hdrs)
|
|
60
|
+
except (httpx.TimeoutException, httpx.TransportError) as e:
|
|
61
|
+
if attempt <= self.max_retries and (m == "GET" or "Idempotency-Key" in hdrs):
|
|
62
|
+
time.sleep(self._backoff(attempt))
|
|
63
|
+
continue
|
|
64
|
+
raise LangsatError(0, "network", f"{type(e).__name__}: {e}") from e
|
|
65
|
+
if 200 <= resp.status_code < 300:
|
|
66
|
+
if raw:
|
|
67
|
+
return resp
|
|
68
|
+
if not resp.content:
|
|
69
|
+
return None
|
|
70
|
+
ctype = resp.headers.get("content-type", "")
|
|
71
|
+
return resp.json() if "json" in ctype else resp.content
|
|
72
|
+
err = from_response(resp.status_code, self._json_or_text(resp), {k.lower(): v for k, v in resp.headers.items()})
|
|
73
|
+
if isinstance(err, RateLimited) or resp.status_code == 503:
|
|
74
|
+
if attempt <= self.max_retries:
|
|
75
|
+
time.sleep(err.retry_after if isinstance(err, RateLimited) and err.retry_after else self._backoff(attempt))
|
|
76
|
+
continue
|
|
77
|
+
if isinstance(err, ProjectBusy) and busy_deadline and time.monotonic() < busy_deadline:
|
|
78
|
+
time.sleep(min(5.0, max(1.0, self._backoff(attempt))))
|
|
79
|
+
continue
|
|
80
|
+
raise err
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _json_or_text(resp: httpx.Response) -> Any:
|
|
84
|
+
try:
|
|
85
|
+
return resp.json()
|
|
86
|
+
except (json.JSONDecodeError, ValueError):
|
|
87
|
+
return resp.text
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _backoff(attempt: int) -> float:
|
|
91
|
+
return min(30.0, (2 ** (attempt - 1)) * 0.5 + random.random() * 0.25)
|
|
92
|
+
|
|
93
|
+
# ── sugar ──
|
|
94
|
+
def get(self, path: str, **kw) -> Any:
|
|
95
|
+
return self.request("GET", path, **kw)
|
|
96
|
+
|
|
97
|
+
def post(self, path: str, json_body: Any = None, **kw) -> Any:
|
|
98
|
+
return self.request("POST", path, json_body=json_body, **kw)
|
|
99
|
+
|
|
100
|
+
def put(self, path: str, json_body: Any = None, **kw) -> Any:
|
|
101
|
+
return self.request("PUT", path, json_body=json_body, **kw)
|
|
102
|
+
|
|
103
|
+
def patch(self, path: str, json_body: Any = None, **kw) -> Any:
|
|
104
|
+
return self.request("PATCH", path, json_body=json_body, **kw)
|
|
105
|
+
|
|
106
|
+
def delete(self, path: str, **kw) -> Any:
|
|
107
|
+
return self.request("DELETE", path, **kw)
|
|
108
|
+
|
|
109
|
+
def put_bytes(self, url: str, content: bytes, content_type: str) -> None:
|
|
110
|
+
"""A presigned S3 PUT — no auth header, no base_url."""
|
|
111
|
+
r = httpx.put(url, content=content, headers={"Content-Type": content_type}, timeout=600)
|
|
112
|
+
if r.status_code >= 300:
|
|
113
|
+
raise LangsatError(r.status_code, "upload_failed", f"S3 PUT failed: {r.text[:200]}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def chunked(it: Iterable, n: int):
|
|
117
|
+
buf = []
|
|
118
|
+
for x in it:
|
|
119
|
+
buf.append(x)
|
|
120
|
+
if len(buf) == n:
|
|
121
|
+
yield buf
|
|
122
|
+
buf = []
|
|
123
|
+
if buf:
|
|
124
|
+
yield buf
|
langsat/_version.py
ADDED
langsat/auth.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Login the Hugging Face way.
|
|
2
|
+
|
|
3
|
+
A token (`rdl_…`) is minted once in the web app (Settings → API keys, with the scopes and
|
|
4
|
+
projects it may touch). Then:
|
|
5
|
+
|
|
6
|
+
langsat login # paste the token; it is verified and saved to ~/.langsat/token
|
|
7
|
+
export LANGSAT_API_KEY=rdl_… # or: the environment wins over the saved file
|
|
8
|
+
Langsat(token="rdl_…") # or: pass it explicitly
|
|
9
|
+
|
|
10
|
+
Resolution order: explicit argument > `LANGSAT_API_KEY` > the token file (`LANGSAT_TOKEN_PATH`
|
|
11
|
+
or `~/.langsat/token`, mode 0600). There is no password login: a Google-SSO account has no
|
|
12
|
+
password and a script should never hold one.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
ENV_TOKEN = "LANGSAT_API_KEY"
|
|
22
|
+
ENV_TOKEN_PATH = "LANGSAT_TOKEN_PATH"
|
|
23
|
+
ENV_BASE_URL = "LANGSAT_BASE_URL"
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.langsat.ai/api/v1"
|
|
25
|
+
TOKEN_PREFIX = "rdl_"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def token_path() -> Path:
|
|
29
|
+
p = os.environ.get(ENV_TOKEN_PATH)
|
|
30
|
+
return Path(p).expanduser() if p else Path.home() / ".langsat" / "token"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_saved_token() -> Optional[str]:
|
|
34
|
+
try:
|
|
35
|
+
t = token_path().read_text().strip()
|
|
36
|
+
except OSError:
|
|
37
|
+
return None
|
|
38
|
+
return t or None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_token(token: str) -> Path:
|
|
42
|
+
p = token_path()
|
|
43
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
p.write_text(token.strip() + "\n")
|
|
45
|
+
try:
|
|
46
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0600 — the token is a credential
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def delete_saved_token() -> bool:
|
|
53
|
+
p = token_path()
|
|
54
|
+
if p.exists():
|
|
55
|
+
p.unlink()
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_token(explicit: Optional[str] = None) -> Optional[str]:
|
|
61
|
+
return (explicit or os.environ.get(ENV_TOKEN) or read_saved_token() or None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_base_url(explicit: Optional[str] = None) -> str:
|
|
65
|
+
return (explicit or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def login(token: Optional[str] = None, *, base_url: Optional[str] = None, save: bool = True) -> dict:
|
|
69
|
+
"""Verify a token against the API (`GET /auth/me`) and, when it is good, save it for every
|
|
70
|
+
later `Langsat()`. Returns the whoami payload (who the key acts as, its scopes, its projects).
|
|
71
|
+
With no `token`, prompts on the terminal (input hidden)."""
|
|
72
|
+
from .client import Langsat
|
|
73
|
+
if not token:
|
|
74
|
+
import getpass
|
|
75
|
+
token = getpass.getpass("Langsat API key (rdl_…, from Settings → API keys): ").strip()
|
|
76
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
77
|
+
raise ValueError(f"A Langsat API key starts with {TOKEN_PREFIX!r}")
|
|
78
|
+
me = Langsat(token=token, base_url=base_url).me()
|
|
79
|
+
if save:
|
|
80
|
+
save_token(token)
|
|
81
|
+
return me
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def logout() -> bool:
|
|
85
|
+
"""Forget the saved token (the key itself stays valid until revoked in Settings)."""
|
|
86
|
+
return delete_saved_token()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def whoami(*, token: Optional[str] = None, base_url: Optional[str] = None) -> dict:
|
|
90
|
+
from .client import Langsat
|
|
91
|
+
return Langsat(token=token, base_url=base_url).me()
|
langsat/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""`langsat login | whoami | logout` — the Hugging Face shape."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import auth
|
|
9
|
+
from .errors import LangsatError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None) -> int:
|
|
13
|
+
ap = argparse.ArgumentParser(prog="langsat", description="Langsat SDK")
|
|
14
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
15
|
+
p_login = sub.add_parser("login", help="verify an API key and save it to ~/.langsat/token")
|
|
16
|
+
p_login.add_argument("--token", help="the rdl_… key (prompted when omitted)")
|
|
17
|
+
p_login.add_argument("--base-url", help="API base URL (default https://api.langsat.ai/api/v1)")
|
|
18
|
+
p_login.add_argument("--no-save", action="store_true")
|
|
19
|
+
p_who = sub.add_parser("whoami", help="who the saved / env key acts as")
|
|
20
|
+
p_who.add_argument("--base-url")
|
|
21
|
+
sub.add_parser("logout", help="forget the saved token")
|
|
22
|
+
args = ap.parse_args(argv)
|
|
23
|
+
try:
|
|
24
|
+
if args.cmd == "login":
|
|
25
|
+
me = auth.login(args.token, base_url=args.base_url, save=not args.no_save)
|
|
26
|
+
key = me.get("api_key") or {}
|
|
27
|
+
print(f"Logged in as {me.get('email')} (tier {me.get('tier')})")
|
|
28
|
+
print(f" key: {key.get('name')} [{key.get('prefix')}…] scopes: {', '.join(key.get('scopes') or [])}")
|
|
29
|
+
print(f" projects: {key.get('project_ids') or 'all'} expires: {key.get('expires_at') or 'never'}")
|
|
30
|
+
if not args.no_save:
|
|
31
|
+
print(f" saved to {auth.token_path()}")
|
|
32
|
+
return 0
|
|
33
|
+
if args.cmd == "whoami":
|
|
34
|
+
print(json.dumps(auth.whoami(base_url=args.base_url), indent=2))
|
|
35
|
+
return 0
|
|
36
|
+
if args.cmd == "logout":
|
|
37
|
+
print("token removed" if auth.logout() else "no saved token")
|
|
38
|
+
return 0
|
|
39
|
+
ap.print_help()
|
|
40
|
+
return 1
|
|
41
|
+
except LangsatError as e:
|
|
42
|
+
print(f"error: {e}", file=sys.stderr)
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|
langsat/client.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""`Langsat` — the entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._http import HttpClient
|
|
7
|
+
from .auth import resolve_base_url, resolve_token
|
|
8
|
+
from .errors import AuthError
|
|
9
|
+
from .resources.models import Predict
|
|
10
|
+
from .resources.ops import Credits
|
|
11
|
+
from .resources.projects import Projects
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Langsat:
|
|
15
|
+
"""The same user, the same rights, the same prices, with no screen.
|
|
16
|
+
|
|
17
|
+
ls = Langsat() # token from LANGSAT_API_KEY or `langsat login`
|
|
18
|
+
ls = Langsat(token="rdl_…") # explicit
|
|
19
|
+
ls.me() # who the key acts as, its scopes and projects
|
|
20
|
+
p = ls.projects.create("churn-q3")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, token: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 60.0,
|
|
24
|
+
max_retries: int = 4, auto_idempotency: bool = True, transport=None):
|
|
25
|
+
tok = resolve_token(token)
|
|
26
|
+
if not tok:
|
|
27
|
+
raise AuthError(401, "no_token", "No API key: run `langsat login`, set LANGSAT_API_KEY, or pass token=…")
|
|
28
|
+
self.base_url = resolve_base_url(base_url)
|
|
29
|
+
self._http = HttpClient(self.base_url, tok, timeout=timeout, max_retries=max_retries,
|
|
30
|
+
auto_idempotency=auto_idempotency, transport=transport)
|
|
31
|
+
self.projects = Projects(self._http)
|
|
32
|
+
self.predict = Predict(self._http)
|
|
33
|
+
self.credits = Credits(self._http)
|
|
34
|
+
|
|
35
|
+
def me(self) -> dict:
|
|
36
|
+
return self._http.get("/auth/me")
|
|
37
|
+
|
|
38
|
+
def tiers(self) -> dict:
|
|
39
|
+
return self._http.get("/tiers")
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
self._http.close()
|
|
43
|
+
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, *exc):
|
|
48
|
+
self.close()
|
langsat/errors.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Typed errors — one per `X-Error-Code` the API emits, so a script branches without parsing English."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LangsatError(Exception):
|
|
8
|
+
"""Base: `status`, `code` (the API's X-Error-Code / detail.code), `message`, raw `detail`."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: int, code: Optional[str], message: str, detail: Any = None,
|
|
11
|
+
headers: Optional[dict] = None):
|
|
12
|
+
super().__init__(f"[{status}{' ' + code if code else ''}] {message}")
|
|
13
|
+
self.status = status
|
|
14
|
+
self.code = code
|
|
15
|
+
self.message = message
|
|
16
|
+
self.detail = detail
|
|
17
|
+
self.headers = headers or {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(LangsatError):
|
|
21
|
+
"""401 — no token, a revoked or expired key."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MissingScope(LangsatError):
|
|
25
|
+
"""403 missing_scope — the key does not carry the scope this call needs (`.scope`)."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def scope(self) -> Optional[str]:
|
|
29
|
+
return (self.detail or {}).get("scope") if isinstance(self.detail, dict) else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NeedsUserSession(LangsatError):
|
|
33
|
+
"""403 needs_user_session — only a signed-in person may do this (billing, account, SQL)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Forbidden(LangsatError):
|
|
37
|
+
"""403 — role / tier / frozen project."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFound(LangsatError):
|
|
41
|
+
"""404."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InsufficientCredits(LangsatError):
|
|
45
|
+
"""402 — the action costs more credits than the account has."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QuotaExceeded(LangsatError):
|
|
49
|
+
"""429 quota_exhausted / spend_limit_reached — a daily or monthly limit."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimited(LangsatError):
|
|
53
|
+
"""429 rate_limited — too many requests; `retry_after` seconds when the API says."""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def retry_after(self) -> Optional[float]:
|
|
57
|
+
v = self.headers.get("retry-after")
|
|
58
|
+
try:
|
|
59
|
+
return float(v) if v is not None else None
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProjectBusy(LangsatError):
|
|
65
|
+
"""409 project_busy — a clean / refresh / schema detection / training is in progress."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Conflict(LangsatError):
|
|
69
|
+
"""409 — another conflict (an idempotency key still in flight, a pending edit that would be dropped…)."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RowCapExceeded(LangsatError):
|
|
73
|
+
"""422 row_cap_exceeded — the tier's row limit."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Invalid(LangsatError):
|
|
77
|
+
"""422 / 400 — the request was refused; `detail` says why."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Refused(LangsatError):
|
|
81
|
+
"""200 with a refusal reason (the chart builder, a would-drop edit) — never guessed past."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class JobFailed(LangsatError):
|
|
85
|
+
"""A job ended `failed`; `.job` carries the record."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, job: dict):
|
|
88
|
+
err = job.get("error") or {}
|
|
89
|
+
super().__init__(200, err.get("code") or "job_failed", err.get("message") or "Job failed", job)
|
|
90
|
+
self.job = job
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class JobTimeout(LangsatError):
|
|
94
|
+
"""`wait()` gave up before the job ended."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_BY_CODE = {
|
|
98
|
+
"unauthorized": AuthError, "missing_scope": MissingScope, "needs_user_session": NeedsUserSession,
|
|
99
|
+
"forbidden": Forbidden, "forbidden_role": Forbidden, "project_frozen": Forbidden, "read_only": Forbidden,
|
|
100
|
+
"email_not_verified": Forbidden, "not_found": NotFound, "project_not_found": NotFound,
|
|
101
|
+
"insufficient_credits": InsufficientCredits, "quota_exhausted": QuotaExceeded,
|
|
102
|
+
"spend_limit_reached": QuotaExceeded, "rate_limited": RateLimited, "project_busy": ProjectBusy,
|
|
103
|
+
"conflict": Conflict, "idempotency_in_progress": Conflict, "pending_would_drop": Conflict,
|
|
104
|
+
"row_cap_exceeded": RowCapExceeded, "invalid": Invalid, "cell_overrides_invalid": Invalid,
|
|
105
|
+
"unknown_scope": Invalid, "idempotency_key_invalid": Invalid,
|
|
106
|
+
}
|
|
107
|
+
_BY_STATUS = {401: AuthError, 402: InsufficientCredits, 403: Forbidden, 404: NotFound, 409: Conflict,
|
|
108
|
+
422: Invalid, 400: Invalid, 429: RateLimited}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def from_response(status: int, body: Any, headers: dict) -> LangsatError:
|
|
112
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
113
|
+
code = headers.get("x-error-code")
|
|
114
|
+
if not code and isinstance(detail, dict):
|
|
115
|
+
code = detail.get("code")
|
|
116
|
+
if isinstance(detail, dict):
|
|
117
|
+
message = detail.get("message") or detail.get("code") or str(detail)
|
|
118
|
+
elif isinstance(detail, str):
|
|
119
|
+
message = detail
|
|
120
|
+
else:
|
|
121
|
+
message = f"HTTP {status}"
|
|
122
|
+
cls = _BY_CODE.get(code or "", None) or _BY_STATUS.get(status, LangsatError)
|
|
123
|
+
return cls(status, code, message, detail, headers)
|
langsat/jobs.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Every long action is a Job — `wait()` polls the one job shape (`GET /projects/{id}/jobs/{job_id}`)
|
|
2
|
+
or, for the lanes that report through the project (clean / schema / train), `GET …/status`."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from .errors import JobFailed, JobTimeout
|
|
9
|
+
|
|
10
|
+
BUSY_PROJECT_STATES = ("cleaning", "detecting_schema", "training", "running", "pending", "queued")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Job:
|
|
14
|
+
def __init__(self, http, project_id: str, job_id: Optional[str], *, kind: str = "", result_route: Optional[str] = None,
|
|
15
|
+
status: str = "queued", raw: Any = None):
|
|
16
|
+
self._http = http
|
|
17
|
+
self.project_id = project_id
|
|
18
|
+
self.job_id = job_id
|
|
19
|
+
self.kind = kind
|
|
20
|
+
self.status = status
|
|
21
|
+
self.result_route = result_route
|
|
22
|
+
self.raw = raw
|
|
23
|
+
self.record: dict = {}
|
|
24
|
+
|
|
25
|
+
def refresh(self) -> dict:
|
|
26
|
+
if self.job_id:
|
|
27
|
+
rec = self._http.get(f"/projects/{self.project_id}/jobs/{self.job_id}")
|
|
28
|
+
else: # a project-status lane (clean / schema / train)
|
|
29
|
+
st = self._http.get(f"/projects/{self.project_id}/status")
|
|
30
|
+
ps = str(st.get("status") or "")
|
|
31
|
+
status = ("running" if ps in BUSY_PROJECT_STATES else
|
|
32
|
+
"failed" if ps in ("error", "failed") else
|
|
33
|
+
"cancelled" if ps == "cancelled" else "succeeded")
|
|
34
|
+
rec = {"job_id": None, "kind": self.kind, "status": status, "stage": st.get("progress_stage"),
|
|
35
|
+
"progress_pct": st.get("progress_pct"), "error": ({"code": "job_failed", "message": st.get("error_message")} if status == "failed" else None),
|
|
36
|
+
"result_route": f"/projects/{self.project_id}/status", "raw": st}
|
|
37
|
+
self.record = rec
|
|
38
|
+
self.status = rec.get("status", self.status)
|
|
39
|
+
self.result_route = rec.get("result_route") or self.result_route
|
|
40
|
+
return rec
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def done(self) -> bool:
|
|
44
|
+
return self.status in ("succeeded", "failed", "cancelled")
|
|
45
|
+
|
|
46
|
+
def wait(self, *, poll: float = 3.0, timeout: Optional[float] = None,
|
|
47
|
+
on_progress: Optional[Callable[[dict], None]] = None) -> "Job":
|
|
48
|
+
"""Block until the job ends. Raises `JobFailed` on failure, `JobTimeout` after `timeout` s."""
|
|
49
|
+
t0 = time.monotonic()
|
|
50
|
+
while True:
|
|
51
|
+
rec = self.refresh()
|
|
52
|
+
if on_progress:
|
|
53
|
+
on_progress(rec)
|
|
54
|
+
if self.status == "succeeded":
|
|
55
|
+
return self
|
|
56
|
+
if self.status in ("failed", "cancelled"):
|
|
57
|
+
raise JobFailed(rec)
|
|
58
|
+
if timeout is not None and time.monotonic() - t0 > timeout:
|
|
59
|
+
raise JobTimeout(0, "job_timeout", f"{self.kind or 'job'} {self.job_id or ''} still {self.status} after {timeout}s", rec)
|
|
60
|
+
time.sleep(poll)
|
|
61
|
+
|
|
62
|
+
def result(self) -> Any:
|
|
63
|
+
"""The lane's own rich result (a task config, a download_url, an answer) from `result_route`."""
|
|
64
|
+
if not self.result_route:
|
|
65
|
+
return self.record
|
|
66
|
+
path = self.result_route
|
|
67
|
+
if path.startswith("/api/v1"):
|
|
68
|
+
path = path[len("/api/v1"):]
|
|
69
|
+
return self._http.get(path)
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return f"Job(kind={self.kind!r}, id={self.job_id!r}, status={self.status!r})"
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Resource:
|
|
7
|
+
def __init__(self, http, project_id: Optional[str] = None):
|
|
8
|
+
self._http = http
|
|
9
|
+
self.project_id = project_id
|
|
10
|
+
|
|
11
|
+
def _p(self, suffix: str = "") -> str:
|
|
12
|
+
return f"/projects/{self.project_id}{suffix}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _clean(d: dict) -> dict:
|
|
16
|
+
"""Drop None values so an omitted field stays 'not mentioned' on the wire."""
|
|
17
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_pandas(columns, rows):
|
|
21
|
+
"""Rows + [{name,dtype}] columns → a DataFrame (pandas is an optional extra)."""
|
|
22
|
+
try:
|
|
23
|
+
import pandas as pd
|
|
24
|
+
except ImportError as e: # pragma: no cover
|
|
25
|
+
raise ImportError("pip install 'langsat[pandas]' for to_pandas()") from e
|
|
26
|
+
names = [c["name"] if isinstance(c, dict) else c for c in (columns or [])]
|
|
27
|
+
return pd.DataFrame(rows or [], columns=names)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Any_ = Any
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Ask the data — the same engine as the app's chat."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..errors import JobFailed, JobTimeout
|
|
8
|
+
from ._base import Resource, _clean
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Answer:
|
|
12
|
+
def __init__(self, data: dict):
|
|
13
|
+
self.raw = data
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def text(self) -> str:
|
|
17
|
+
return self.raw.get("answer") or ""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def chart_specs(self) -> List[dict]:
|
|
21
|
+
return self.raw.get("chart_specs") or []
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def execution_kind(self) -> Optional[str]:
|
|
25
|
+
return self.raw.get("execution_kind")
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def conversation_id(self) -> Optional[str]:
|
|
29
|
+
return self.raw.get("conversation_id")
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"Answer({self.text[:80]!r}, charts={len(self.chart_specs)}, via={self.execution_kind})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Chat(Resource):
|
|
36
|
+
def ask(self, question: str, *, conversation_id: Optional[str] = None, force_chart: bool = False,
|
|
37
|
+
mode: str = "auto", poll: float = 2.0, timeout: Optional[float] = 600) -> Answer:
|
|
38
|
+
"""1 AI question. Big projects answer asynchronously — this waits for the answer."""
|
|
39
|
+
out = self._http.post(self._p("/inference"), _clean({"question": question, "mode": mode,
|
|
40
|
+
"conversation_id": conversation_id, "force_chart": force_chart}))
|
|
41
|
+
if out.get("status") == "processing" and out.get("inference_id"):
|
|
42
|
+
t0 = time.monotonic()
|
|
43
|
+
while True:
|
|
44
|
+
st = self._http.get(self._p(f"/inference/job/{out['inference_id']}"))
|
|
45
|
+
s = str(st.get("status", "")).upper()
|
|
46
|
+
if s == "COMPLETE" or ("answer" in st and s not in ("PROCESSING", "QUEUED")):
|
|
47
|
+
return Answer(st)
|
|
48
|
+
if s == "FAILED":
|
|
49
|
+
raise JobFailed({"job_id": out["inference_id"], "status": "failed", "error": {"code": "chat_failed", "message": st.get("error")}})
|
|
50
|
+
if timeout and time.monotonic() - t0 > timeout:
|
|
51
|
+
raise JobTimeout(0, "job_timeout", "the answer did not arrive in time", st)
|
|
52
|
+
time.sleep(poll)
|
|
53
|
+
return Answer(out)
|
|
54
|
+
|
|
55
|
+
def conversations(self) -> list:
|
|
56
|
+
out = self._http.get(self._p("/conversations"))
|
|
57
|
+
return out.get("conversations", out) if isinstance(out, dict) else out
|
|
58
|
+
|
|
59
|
+
def new_conversation(self, title: Optional[str] = None) -> dict:
|
|
60
|
+
return self._http.post(self._p("/conversations"), _clean({"title": title}))
|
|
61
|
+
|
|
62
|
+
def history(self) -> Any:
|
|
63
|
+
return self._http.get(self._p("/inference/history"))
|
|
64
|
+
|
|
65
|
+
def clear_history(self) -> Any:
|
|
66
|
+
return self._http.delete(self._p("/inference/history"))
|
|
67
|
+
|
|
68
|
+
def suggestions(self) -> Any:
|
|
69
|
+
return self._http.get(self._p("/inference/suggestions"))
|
|
70
|
+
|
|
71
|
+
def explain_chart(self, chart_title: str, chart_summary: str, **context) -> dict:
|
|
72
|
+
return self._http.post(self._p("/inference/explain"), _clean({"chart_title": chart_title, "chart_summary": chart_summary, **context}))
|
|
73
|
+
|
|
74
|
+
def session(self) -> dict:
|
|
75
|
+
"""The warm inference box for this project (Team; `{active:false}` on Lambda tiers)."""
|
|
76
|
+
return self._http.get(self._p("/inference/session"))
|
|
77
|
+
|
|
78
|
+
def end_session(self) -> dict:
|
|
79
|
+
return self._http.post(self._p("/inference/session/end"), {})
|