corethink 1.0.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.
- corethink/__init__.py +17 -0
- corethink/__main__.py +3 -0
- corethink/_version.py +3 -0
- corethink/account.py +470 -0
- corethink/cli.py +1058 -0
- corethink/client.py +253 -0
- corethink/commands/__init__.py +0 -0
- corethink/commands/admin.py +703 -0
- corethink/commands/api.py +59 -0
- corethink/commands/archive.py +433 -0
- corethink/commands/auth.py +375 -0
- corethink/commands/billing.py +314 -0
- corethink/commands/capture.py +564 -0
- corethink/commands/connection.py +1266 -0
- corethink/commands/endpoint.py +1124 -0
- corethink/commands/env.py +1966 -0
- corethink/commands/hub.py +269 -0
- corethink/commands/library.py +347 -0
- corethink/commands/models.py +258 -0
- corethink/commands/train.py +2655 -0
- corethink/commands/workspace.py +622 -0
- corethink/config.py +181 -0
- corethink/context.py +46 -0
- corethink/env_template.py +117 -0
- corethink/environment_http_store.py +96 -0
- corethink/experiment_cli.py +2187 -0
- corethink/hosted_profiles.py +44 -0
- corethink/hosted_rl.py +688 -0
- corethink/hosted_rl_archive.py +74 -0
- corethink/hosted_rl_attribution.py +44 -0
- corethink/hosted_rl_billing.py +103 -0
- corethink/hosted_rl_state.py +182 -0
- corethink/jobs_cli.py +839 -0
- corethink/main.py +204 -0
- corethink/output.py +201 -0
- corethink/py.typed +0 -0
- corethink/rl_cli.py +997 -0
- corethink/rl_inspect.py +521 -0
- corethink/rl_playground.py +129 -0
- corethink/rl_reassessment.py +214 -0
- corethink/rl_settings.py +43 -0
- corethink/sdk.py +331 -0
- corethink/settings.py +162 -0
- corethink-1.0.0.dist-info/METADATA +120 -0
- corethink-1.0.0.dist-info/RECORD +48 -0
- corethink-1.0.0.dist-info/WHEEL +5 -0
- corethink-1.0.0.dist-info/entry_points.txt +2 -0
- corethink-1.0.0.dist-info/top_level.txt +1 -0
corethink/client.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The Workbench's HTTP API, as the CLI calls it.
|
|
2
|
+
|
|
3
|
+
Every console page is a client of the same API (`/app/api/v1/…`); the CLI signs in with a token of its own
|
|
4
|
+
(`Authorization: Bearer ctcli_…`) and gets the same answers, with the same workspace and role checks. Errors come back as
|
|
5
|
+
`{"error": {"code", "message"}}` and are raised as `ApiError` with that message, which is written for people.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator, Mapping
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, BinaryIO
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from corethink._version import __version__
|
|
21
|
+
|
|
22
|
+
API_PREFIX = "/app"
|
|
23
|
+
RETRY_STATUSES = {429, 502, 503, 504}
|
|
24
|
+
USER_AGENT = f"corethink-cli/{__version__} (python {platform.python_version()}; {platform.system().lower()})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApiError(Exception):
|
|
28
|
+
"""The Workbench refused or failed a request."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
status: int | None = None,
|
|
35
|
+
code: str | None = None,
|
|
36
|
+
payload: Any = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.message = message
|
|
40
|
+
self.status = status
|
|
41
|
+
self.code = code
|
|
42
|
+
self.payload = payload
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def exit_code(self) -> int:
|
|
46
|
+
"""1 any failure, 3 signed out, 4 not allowed, 5 not found, 6 conflict, 7 rate limited / unavailable."""
|
|
47
|
+
return {401: 3, 403: 4, 404: 5, 409: 6, 429: 7, 502: 7, 503: 7, 504: 7}.get(self.status or 0, 1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotSignedIn(ApiError):
|
|
51
|
+
"""No token, or the Workbench no longer accepts it."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _error_of(response: httpx.Response) -> ApiError:
|
|
55
|
+
try:
|
|
56
|
+
payload = response.json()
|
|
57
|
+
except ValueError:
|
|
58
|
+
payload = None
|
|
59
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
60
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
61
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
62
|
+
if not message and isinstance(payload, dict) and isinstance(payload.get("message"), str):
|
|
63
|
+
message = payload["message"]
|
|
64
|
+
if not message and isinstance(payload, dict) and ("check" in payload or "report" in payload):
|
|
65
|
+
message = "The Workbench refused the files: see the check below (or --json for the full report)."
|
|
66
|
+
if not message:
|
|
67
|
+
message = {
|
|
68
|
+
401: "Not signed in.",
|
|
69
|
+
403: "Not allowed.",
|
|
70
|
+
404: "Not found.",
|
|
71
|
+
429: "Too many requests; wait a moment and try again.",
|
|
72
|
+
502: "The Workbench is unavailable right now; try again.",
|
|
73
|
+
503: "The Workbench is unavailable right now; try again.",
|
|
74
|
+
504: "The Workbench took too long to answer; try again.",
|
|
75
|
+
}.get(response.status_code, f"The Workbench answered HTTP {response.status_code}.")
|
|
76
|
+
kind = NotSignedIn if response.status_code == 401 else ApiError
|
|
77
|
+
if response.status_code == 401 and code in (None, "unauthorized", "signed_out"):
|
|
78
|
+
message = "Not signed in. Run `corethink login` (or set CORETHINK_TOKEN)."
|
|
79
|
+
return kind(str(message), status=response.status_code, code=code, payload=payload)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Client:
|
|
83
|
+
"""A signed-in (or anonymous) connection to one Workbench."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
url: str,
|
|
88
|
+
token: str | None = None,
|
|
89
|
+
*,
|
|
90
|
+
timeout: float = 60.0,
|
|
91
|
+
transport: httpx.BaseTransport | None = None,
|
|
92
|
+
retries: int = 3,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.url = url.rstrip("/")
|
|
95
|
+
self.token = token
|
|
96
|
+
self.retries = retries
|
|
97
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
98
|
+
if token:
|
|
99
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
100
|
+
self._http = httpx.Client(
|
|
101
|
+
base_url=self.url + API_PREFIX,
|
|
102
|
+
headers=headers,
|
|
103
|
+
timeout=httpx.Timeout(timeout, connect=15.0),
|
|
104
|
+
transport=transport,
|
|
105
|
+
follow_redirects=False,
|
|
106
|
+
)
|
|
107
|
+
# uploads go straight to a signed Cloud Storage URL: never with our token
|
|
108
|
+
self._bare = httpx.Client(
|
|
109
|
+
headers={"User-Agent": USER_AGENT},
|
|
110
|
+
timeout=httpx.Timeout(600.0, connect=15.0),
|
|
111
|
+
transport=transport,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
self._http.close()
|
|
116
|
+
self._bare.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> Client:
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *_: object) -> None:
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
# ---- requests -------------------------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
json: Any = None,
|
|
132
|
+
params: Mapping[str, Any] | None = None,
|
|
133
|
+
headers: Mapping[str, str] | None = None,
|
|
134
|
+
retry: bool | None = None,
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""One API call; the parsed JSON answer (None when empty). GETs are retried on network errors and 429/5xx."""
|
|
137
|
+
method = method.upper()
|
|
138
|
+
if not path.startswith("/"):
|
|
139
|
+
path = "/" + path
|
|
140
|
+
retry = method in {"GET", "HEAD"} if retry is None else retry
|
|
141
|
+
clean = {k: v for k, v in (params or {}).items() if v is not None}
|
|
142
|
+
attempt = 0
|
|
143
|
+
while True:
|
|
144
|
+
try:
|
|
145
|
+
response = self._http.request(
|
|
146
|
+
method,
|
|
147
|
+
path,
|
|
148
|
+
json=json,
|
|
149
|
+
params=clean or None,
|
|
150
|
+
headers=dict(headers or {}),
|
|
151
|
+
)
|
|
152
|
+
except httpx.TransportError as error:
|
|
153
|
+
if retry and attempt < self.retries:
|
|
154
|
+
attempt += 1
|
|
155
|
+
_pause(attempt)
|
|
156
|
+
continue
|
|
157
|
+
raise ApiError(
|
|
158
|
+
f"Could not reach {self.url} ({error.__class__.__name__}). Check your connection, or --url."
|
|
159
|
+
) from error
|
|
160
|
+
if (
|
|
161
|
+
response.status_code in RETRY_STATUSES
|
|
162
|
+
and attempt < self.retries
|
|
163
|
+
and (retry or response.status_code == 429)
|
|
164
|
+
):
|
|
165
|
+
attempt += 1
|
|
166
|
+
_pause(attempt, response.headers.get("Retry-After"))
|
|
167
|
+
continue
|
|
168
|
+
if response.status_code >= 400:
|
|
169
|
+
raise _error_of(response)
|
|
170
|
+
if not response.content:
|
|
171
|
+
return None
|
|
172
|
+
content_type = response.headers.get("Content-Type", "")
|
|
173
|
+
if "json" in content_type:
|
|
174
|
+
return response.json()
|
|
175
|
+
return response.text
|
|
176
|
+
|
|
177
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
178
|
+
return self.request("GET", path, params=params)
|
|
179
|
+
|
|
180
|
+
def post(self, path: str, body: Any = None, **kwargs: Any) -> Any:
|
|
181
|
+
return self.request("POST", path, json={} if body is None else body, **kwargs)
|
|
182
|
+
|
|
183
|
+
def put(self, path: str, body: Any = None, **kwargs: Any) -> Any:
|
|
184
|
+
return self.request("PUT", path, json={} if body is None else body, **kwargs)
|
|
185
|
+
|
|
186
|
+
def delete(self, path: str, body: Any = None, **kwargs: Any) -> Any:
|
|
187
|
+
return self.request("DELETE", path, json=body, **kwargs)
|
|
188
|
+
|
|
189
|
+
@contextmanager
|
|
190
|
+
def stream(self, method: str, path: str, **kwargs: Any) -> Iterator[httpx.Response]:
|
|
191
|
+
"""A streamed answer (downloads); errors raised as ApiError."""
|
|
192
|
+
with self._http.stream(method.upper(), path, **kwargs) as response:
|
|
193
|
+
if response.status_code >= 400:
|
|
194
|
+
response.read()
|
|
195
|
+
raise _error_of(response)
|
|
196
|
+
yield response
|
|
197
|
+
|
|
198
|
+
def download(self, path: str, target: Path, params: Mapping[str, Any] | None = None) -> Path:
|
|
199
|
+
"""Save a file the API serves; returns where it went."""
|
|
200
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
partial = target.with_name(target.name + ".part")
|
|
202
|
+
with self.stream("GET", path, params=params) as response, partial.open("wb") as out:
|
|
203
|
+
for chunk in response.iter_bytes():
|
|
204
|
+
out.write(chunk)
|
|
205
|
+
partial.replace(target)
|
|
206
|
+
return target
|
|
207
|
+
|
|
208
|
+
def put_signed(
|
|
209
|
+
self,
|
|
210
|
+
url: str,
|
|
211
|
+
data: bytes | BinaryIO,
|
|
212
|
+
*,
|
|
213
|
+
headers: Mapping[str, str] | None = None,
|
|
214
|
+
method: str = "PUT",
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Send a file to a URL the Workbench signed (Cloud Storage), without our token."""
|
|
217
|
+
try:
|
|
218
|
+
response = self._bare.request(method, url, content=data, headers=dict(headers or {}))
|
|
219
|
+
except httpx.TransportError as error:
|
|
220
|
+
raise ApiError(f"The upload could not reach storage ({error.__class__.__name__}).") from error
|
|
221
|
+
if response.status_code >= 400:
|
|
222
|
+
raise ApiError(
|
|
223
|
+
f"Storage refused the upload (HTTP {response.status_code}): {response.text[:200]}",
|
|
224
|
+
status=response.status_code,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def storage(self) -> httpx.Client:
|
|
229
|
+
"""An httpx client for URLs the Workbench signed (Cloud Storage): it never carries our token."""
|
|
230
|
+
return self._bare
|
|
231
|
+
|
|
232
|
+
def signed_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
233
|
+
"""A request to a URL the Workbench signed (Cloud Storage), without our token; the response as it is (resumable
|
|
234
|
+
uploads read its Location and Range). Network failures are raised as ApiError; HTTP statuses are the caller's."""
|
|
235
|
+
try:
|
|
236
|
+
return self._bare.request(method.upper(), url, **kwargs)
|
|
237
|
+
except httpx.TransportError as error:
|
|
238
|
+
raise ApiError(f"Could not reach storage ({error.__class__.__name__}).") from error
|
|
239
|
+
|
|
240
|
+
def fetch_url(self, url: str) -> bytes:
|
|
241
|
+
"""GET a signed URL (a download link the Workbench gave), without our token."""
|
|
242
|
+
response = self._bare.get(url)
|
|
243
|
+
if response.status_code >= 400:
|
|
244
|
+
raise ApiError(f"The download failed (HTTP {response.status_code}).", status=response.status_code)
|
|
245
|
+
return response.content
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _pause(attempt: int, retry_after: str | None = None) -> None:
|
|
249
|
+
try:
|
|
250
|
+
wait = float(retry_after) if retry_after else 0.0
|
|
251
|
+
except ValueError:
|
|
252
|
+
wait = 0.0
|
|
253
|
+
time.sleep(min(30.0, max(wait, 0.5 * 2 ** (attempt - 1)) + random.uniform(0, 0.25)))
|
|
File without changes
|