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 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
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ CONTRACT_VERSION = "1.0.0"
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"), {})