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.
Files changed (48) hide show
  1. corethink/__init__.py +17 -0
  2. corethink/__main__.py +3 -0
  3. corethink/_version.py +3 -0
  4. corethink/account.py +470 -0
  5. corethink/cli.py +1058 -0
  6. corethink/client.py +253 -0
  7. corethink/commands/__init__.py +0 -0
  8. corethink/commands/admin.py +703 -0
  9. corethink/commands/api.py +59 -0
  10. corethink/commands/archive.py +433 -0
  11. corethink/commands/auth.py +375 -0
  12. corethink/commands/billing.py +314 -0
  13. corethink/commands/capture.py +564 -0
  14. corethink/commands/connection.py +1266 -0
  15. corethink/commands/endpoint.py +1124 -0
  16. corethink/commands/env.py +1966 -0
  17. corethink/commands/hub.py +269 -0
  18. corethink/commands/library.py +347 -0
  19. corethink/commands/models.py +258 -0
  20. corethink/commands/train.py +2655 -0
  21. corethink/commands/workspace.py +622 -0
  22. corethink/config.py +181 -0
  23. corethink/context.py +46 -0
  24. corethink/env_template.py +117 -0
  25. corethink/environment_http_store.py +96 -0
  26. corethink/experiment_cli.py +2187 -0
  27. corethink/hosted_profiles.py +44 -0
  28. corethink/hosted_rl.py +688 -0
  29. corethink/hosted_rl_archive.py +74 -0
  30. corethink/hosted_rl_attribution.py +44 -0
  31. corethink/hosted_rl_billing.py +103 -0
  32. corethink/hosted_rl_state.py +182 -0
  33. corethink/jobs_cli.py +839 -0
  34. corethink/main.py +204 -0
  35. corethink/output.py +201 -0
  36. corethink/py.typed +0 -0
  37. corethink/rl_cli.py +997 -0
  38. corethink/rl_inspect.py +521 -0
  39. corethink/rl_playground.py +129 -0
  40. corethink/rl_reassessment.py +214 -0
  41. corethink/rl_settings.py +43 -0
  42. corethink/sdk.py +331 -0
  43. corethink/settings.py +162 -0
  44. corethink-1.0.0.dist-info/METADATA +120 -0
  45. corethink-1.0.0.dist-info/RECORD +48 -0
  46. corethink-1.0.0.dist-info/WHEEL +5 -0
  47. corethink-1.0.0.dist-info/entry_points.txt +2 -0
  48. 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