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 +137 -0
- langsat-0.1.0/README.md +103 -0
- langsat-0.1.0/langsat/__init__.py +33 -0
- langsat-0.1.0/langsat/_http.py +124 -0
- langsat-0.1.0/langsat/_version.py +2 -0
- langsat-0.1.0/langsat/auth.py +91 -0
- langsat-0.1.0/langsat/cli.py +47 -0
- langsat-0.1.0/langsat/client.py +48 -0
- langsat-0.1.0/langsat/errors.py +123 -0
- langsat-0.1.0/langsat/jobs.py +72 -0
- langsat-0.1.0/langsat/resources/__init__.py +0 -0
- langsat-0.1.0/langsat/resources/_base.py +30 -0
- langsat-0.1.0/langsat/resources/chat.py +79 -0
- langsat-0.1.0/langsat/resources/dashboards.py +286 -0
- langsat-0.1.0/langsat/resources/models.py +156 -0
- langsat-0.1.0/langsat/resources/ops.py +80 -0
- langsat-0.1.0/langsat/resources/projects.py +127 -0
- langsat-0.1.0/langsat/resources/schema.py +79 -0
- langsat-0.1.0/langsat/resources/sources.py +123 -0
- langsat-0.1.0/langsat/resources/tables.py +97 -0
- langsat-0.1.0/langsat.egg-info/PKG-INFO +137 -0
- langsat-0.1.0/langsat.egg-info/SOURCES.txt +26 -0
- langsat-0.1.0/langsat.egg-info/dependency_links.txt +1 -0
- langsat-0.1.0/langsat.egg-info/entry_points.txt +2 -0
- langsat-0.1.0/langsat.egg-info/requires.txt +10 -0
- langsat-0.1.0/langsat.egg-info/top_level.txt +1 -0
- langsat-0.1.0/pyproject.toml +57 -0
- langsat-0.1.0/setup.cfg +4 -0
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
|
langsat-0.1.0/README.md
ADDED
|
@@ -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,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()
|