langsat 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: langsat
3
+ Version: 0.1.0
4
+ Summary: Langsat SDK — the same user, the same rights, the same prices, with no screen.
5
+ Author-email: Langsat <team@langsat.ai>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://langsat.ai
8
+ Project-URL: Documentation, https://langsat.ai/resources/learn/getting-started/sdk
9
+ Project-URL: API contract, https://langsat.ai/sdk/langsat-v1.json
10
+ Project-URL: Changelog, https://langsat.ai/resources/learn/getting-started/sdk#changelog
11
+ Keywords: langsat,sdk,relational deep learning,analytics,dashboards,forecasting
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: Other/Proprietary License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: httpx<1,>=0.27
27
+ Provides-Extra: pandas
28
+ Requires-Dist: pandas>=1.5; extra == "pandas"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Requires-Dist: respx>=0.21; extra == "dev"
32
+ Requires-Dist: pandas>=1.5; extra == "dev"
33
+ Requires-Dist: ruff>=0.5; extra == "dev"
34
+
35
+ # Langsat SDK (Python)
36
+
37
+ The same user, the same rights, the same prices — with no screen. Everything the Langsat web app
38
+ does goes through the same REST API; this package wraps the public part of it.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install langsat # Python 3.9+
44
+ pip install "langsat[pandas]" # optional: .to_pandas()
45
+ ```
46
+
47
+ ## Login — the Hugging Face way
48
+
49
+ 1. In the web app: **Settings → API keys → Generate new key**. Pick its **permissions** (presets:
50
+ *Predict only*, *Read only*, *Full SDK*), optionally the projects it may touch and an expiry.
51
+ The key (`rdl_…`) is shown **once**.
52
+ 2. Then:
53
+
54
+ ```bash
55
+ langsat login # paste the key → verified → saved to ~/.langsat/token (0600)
56
+ langsat whoami # who it acts as, its scopes, its projects
57
+ ```
58
+
59
+ or `export LANGSAT_API_KEY=rdl_…` (the environment wins over the saved file), or `Langsat(token="rdl_…")`.
60
+ There is no password login: a script should never hold one, and a Google-SSO account has none.
61
+
62
+ A key acts **as you**: your tier limits, credits, quotas and team role apply exactly as in the app.
63
+ Billing, account changes and hand-written SQL are never available to a key (`NeedsUserSession`).
64
+
65
+ ## 15-minute quickstart
66
+
67
+ ```python
68
+ from langsat import Langsat
69
+
70
+ ls = Langsat()
71
+ me = ls.me() # tier, teams, the key's scopes
72
+
73
+ p = ls.projects.create("churn-q3") # data_analysis; kind="data_science" adds modeling
74
+ p.sources.upload("orders.csv", "customers.csv") # presign → S3 → confirm; the tier row cap applies
75
+ p.schema.detect().wait()
76
+ print(p.schema.result()["schema_data"]["foreign_keys"]) # orders.customer_id → customers
77
+
78
+ print(p.estimates()) # what clean / refresh / train / ask / report would cost
79
+ p.cleaning.clean().wait() # ≤500K rows: 0 credits
80
+
81
+ paid = p.table("orders").rows(filters=[("status", "eq", "paid")], sort=[("amount", "desc")], page_size=100)
82
+ df = p.table("customers").to_pandas()
83
+
84
+ tab = p.dashboards.create("Sales")
85
+ chart = tab.add_chart({"tables": ["orders", "customers"], "chart_type": "bar",
86
+ "group_by": [{"column": "region"}], "measure": {"agg": "sum", "column": "amount"},
87
+ "joins": [{"table": "customers", "left_column": "customer_id", "right_column": "customer_id"}]},
88
+ title="Amount by region") # deterministic, free, no AI
89
+ tab.generate_cards() # 6 AI charts (dashboard quota)
90
+ view = tab.save_view("Paid only", {"filters": [{"column": "status", "op": "eq", "value": "paid"}]})
91
+ link = tab.share(mode="published", view_id=view["view_id"]) # a frozen link; numbers follow refreshes
92
+
93
+ a = p.ask("Which region's revenue fell most last month, and why?") # 1 AI question
94
+ print(a.text, a.chart_specs, a.execution_kind)
95
+ tab.add_chart_spec(a.chart_specs[0])
96
+
97
+ # the data-model workspace: Save is free and pending; Refresh applies and re-cleans at the lane price
98
+ p.data_model.save_pending(foreign_keys={"orders": {"customer_id": "customers"}},
99
+ cell_overrides=[{"table": "customers", "key_column": "customer_id",
100
+ "key_value": "1", "column": "segment", "value": "VIP"}])
101
+ p.data_model.refresh().wait()
102
+
103
+ # modeling (data_science projects)
104
+ p.tasks.define("predict which customers churn in the next 30 days").wait()
105
+ job = p.train(max_training_min=60).wait() # credits reserved from the estimate
106
+ model_id = p.models.list()[0]["model_id"]
107
+ ls.predict.predict(model_id=model_id, entity_id=4711) # 50 credits
108
+ ls.predict.forecast(project_id=p.id, target="amount", window="day", length=14)
109
+ ```
110
+
111
+ ## Conventions
112
+
113
+ - **Jobs.** Long work returns a `Job`; `job.wait(poll=3, timeout=…)` raises `JobFailed` / `JobTimeout`.
114
+ `project.jobs()` lists recent jobs in one shape; `job.result()` fetches the lane's rich result.
115
+ - **Money.** Nothing charges without an API call that the web app would also charge. Ask first:
116
+ `project.estimates()`. Set a monthly spend cap in Settings before letting a cron job train.
117
+ - **Idempotency.** The replayable lanes (clean, refresh, train, generate-cards, report export,
118
+ predict, ask, add chart…) get an `Idempotency-Key` automatically: a retry after a timeout replays
119
+ the first response, it never re-runs or re-charges. Pass `idempotency_key=` to control it.
120
+ - **Retries.** 429 (rate / quota) and 503 back off and retry; 409 *project busy* is raised unless you
121
+ pass `wait_if_busy=<seconds>`.
122
+ - **Errors** are typed by the API's `X-Error-Code`: `AuthError`, `MissingScope` (`.scope`),
123
+ `NeedsUserSession`, `Forbidden`, `NotFound`, `InsufficientCredits`, `QuotaExceeded`, `RateLimited`,
124
+ `ProjectBusy`, `Conflict`, `RowCapExceeded`, `Invalid`, `Refused`, `JobFailed`, `JobTimeout`.
125
+ - **Scopes** (what a key may do): `projects:read|write`, `sources:write`, `data:read|write`,
126
+ `dashboards:read|write`, `chat`, `train`, `predict`, `monitoring:read|write`, `hosting:write`
127
+ (owner/admin — a cost lever), `credits:read`, `teams:read`, `keys:read`.
128
+
129
+ ## Contract and versioning
130
+
131
+ The public surface is the OpenAPI document at <https://langsat.ai/sdk/langsat-v1.json> (generated
132
+ from the server, `x-scope` per operation). `/api/v1` is frozen for that set: fields are only added;
133
+ a removal means `v2` with six months of `Deprecation` headers.
134
+
135
+ ## Docs and support
136
+
137
+ Guide: <https://langsat.ai/resources/learn/getting-started/sdk> · questions: team@langsat.ai
@@ -0,0 +1,103 @@
1
+ # Langsat SDK (Python)
2
+
3
+ The same user, the same rights, the same prices — with no screen. Everything the Langsat web app
4
+ does goes through the same REST API; this package wraps the public part of it.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install langsat # Python 3.9+
10
+ pip install "langsat[pandas]" # optional: .to_pandas()
11
+ ```
12
+
13
+ ## Login — the Hugging Face way
14
+
15
+ 1. In the web app: **Settings → API keys → Generate new key**. Pick its **permissions** (presets:
16
+ *Predict only*, *Read only*, *Full SDK*), optionally the projects it may touch and an expiry.
17
+ The key (`rdl_…`) is shown **once**.
18
+ 2. Then:
19
+
20
+ ```bash
21
+ langsat login # paste the key → verified → saved to ~/.langsat/token (0600)
22
+ langsat whoami # who it acts as, its scopes, its projects
23
+ ```
24
+
25
+ or `export LANGSAT_API_KEY=rdl_…` (the environment wins over the saved file), or `Langsat(token="rdl_…")`.
26
+ There is no password login: a script should never hold one, and a Google-SSO account has none.
27
+
28
+ A key acts **as you**: your tier limits, credits, quotas and team role apply exactly as in the app.
29
+ Billing, account changes and hand-written SQL are never available to a key (`NeedsUserSession`).
30
+
31
+ ## 15-minute quickstart
32
+
33
+ ```python
34
+ from langsat import Langsat
35
+
36
+ ls = Langsat()
37
+ me = ls.me() # tier, teams, the key's scopes
38
+
39
+ p = ls.projects.create("churn-q3") # data_analysis; kind="data_science" adds modeling
40
+ p.sources.upload("orders.csv", "customers.csv") # presign → S3 → confirm; the tier row cap applies
41
+ p.schema.detect().wait()
42
+ print(p.schema.result()["schema_data"]["foreign_keys"]) # orders.customer_id → customers
43
+
44
+ print(p.estimates()) # what clean / refresh / train / ask / report would cost
45
+ p.cleaning.clean().wait() # ≤500K rows: 0 credits
46
+
47
+ paid = p.table("orders").rows(filters=[("status", "eq", "paid")], sort=[("amount", "desc")], page_size=100)
48
+ df = p.table("customers").to_pandas()
49
+
50
+ tab = p.dashboards.create("Sales")
51
+ chart = tab.add_chart({"tables": ["orders", "customers"], "chart_type": "bar",
52
+ "group_by": [{"column": "region"}], "measure": {"agg": "sum", "column": "amount"},
53
+ "joins": [{"table": "customers", "left_column": "customer_id", "right_column": "customer_id"}]},
54
+ title="Amount by region") # deterministic, free, no AI
55
+ tab.generate_cards() # 6 AI charts (dashboard quota)
56
+ view = tab.save_view("Paid only", {"filters": [{"column": "status", "op": "eq", "value": "paid"}]})
57
+ link = tab.share(mode="published", view_id=view["view_id"]) # a frozen link; numbers follow refreshes
58
+
59
+ a = p.ask("Which region's revenue fell most last month, and why?") # 1 AI question
60
+ print(a.text, a.chart_specs, a.execution_kind)
61
+ tab.add_chart_spec(a.chart_specs[0])
62
+
63
+ # the data-model workspace: Save is free and pending; Refresh applies and re-cleans at the lane price
64
+ p.data_model.save_pending(foreign_keys={"orders": {"customer_id": "customers"}},
65
+ cell_overrides=[{"table": "customers", "key_column": "customer_id",
66
+ "key_value": "1", "column": "segment", "value": "VIP"}])
67
+ p.data_model.refresh().wait()
68
+
69
+ # modeling (data_science projects)
70
+ p.tasks.define("predict which customers churn in the next 30 days").wait()
71
+ job = p.train(max_training_min=60).wait() # credits reserved from the estimate
72
+ model_id = p.models.list()[0]["model_id"]
73
+ ls.predict.predict(model_id=model_id, entity_id=4711) # 50 credits
74
+ ls.predict.forecast(project_id=p.id, target="amount", window="day", length=14)
75
+ ```
76
+
77
+ ## Conventions
78
+
79
+ - **Jobs.** Long work returns a `Job`; `job.wait(poll=3, timeout=…)` raises `JobFailed` / `JobTimeout`.
80
+ `project.jobs()` lists recent jobs in one shape; `job.result()` fetches the lane's rich result.
81
+ - **Money.** Nothing charges without an API call that the web app would also charge. Ask first:
82
+ `project.estimates()`. Set a monthly spend cap in Settings before letting a cron job train.
83
+ - **Idempotency.** The replayable lanes (clean, refresh, train, generate-cards, report export,
84
+ predict, ask, add chart…) get an `Idempotency-Key` automatically: a retry after a timeout replays
85
+ the first response, it never re-runs or re-charges. Pass `idempotency_key=` to control it.
86
+ - **Retries.** 429 (rate / quota) and 503 back off and retry; 409 *project busy* is raised unless you
87
+ pass `wait_if_busy=<seconds>`.
88
+ - **Errors** are typed by the API's `X-Error-Code`: `AuthError`, `MissingScope` (`.scope`),
89
+ `NeedsUserSession`, `Forbidden`, `NotFound`, `InsufficientCredits`, `QuotaExceeded`, `RateLimited`,
90
+ `ProjectBusy`, `Conflict`, `RowCapExceeded`, `Invalid`, `Refused`, `JobFailed`, `JobTimeout`.
91
+ - **Scopes** (what a key may do): `projects:read|write`, `sources:write`, `data:read|write`,
92
+ `dashboards:read|write`, `chat`, `train`, `predict`, `monitoring:read|write`, `hosting:write`
93
+ (owner/admin — a cost lever), `credits:read`, `teams:read`, `keys:read`.
94
+
95
+ ## Contract and versioning
96
+
97
+ The public surface is the OpenAPI document at <https://langsat.ai/sdk/langsat-v1.json> (generated
98
+ from the server, `x-scope` per operation). `/api/v1` is frozen for that set: fields are only added;
99
+ a removal means `v2` with six months of `Deprecation` headers.
100
+
101
+ ## Docs and support
102
+
103
+ Guide: <https://langsat.ai/resources/learn/getting-started/sdk> · questions: team@langsat.ai
@@ -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"]
@@ -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
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ CONTRACT_VERSION = "1.0.0"
@@ -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()
@@ -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())
@@ -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()