sagex-cli 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.
- sagex/__init__.py +3 -0
- sagex/__main__.py +2907 -0
- sagex/_env.py +7 -0
- sagex/api/__init__.py +3 -0
- sagex/api/client.py +195 -0
- sagex/api/resources.py +763 -0
- sagex/api/store.py +52 -0
- sagex/app.py +378 -0
- sagex/app.tcss +63 -0
- sagex/config.py +88 -0
- sagex/copier.py +277 -0
- sagex/formatting.py +126 -0
- sagex/pusher.py +350 -0
- sagex/render.py +1046 -0
- sagex/runner.py +198 -0
- sagex/runview.py +247 -0
- sagex/shell.py +231 -0
- sagex/widgets/__init__.py +1 -0
- sagex/widgets/activity.py +56 -0
- sagex/widgets/message.py +78 -0
- sagex/widgets/prompt_input.py +54 -0
- sagex/widgets/run_graph.py +58 -0
- sagex_cli-1.0.0.dist-info/METADATA +210 -0
- sagex_cli-1.0.0.dist-info/RECORD +28 -0
- sagex_cli-1.0.0.dist-info/WHEEL +5 -0
- sagex_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sagex_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- sagex_cli-1.0.0.dist-info/top_level.txt +1 -0
sagex/_env.py
ADDED
sagex/api/__init__.py
ADDED
sagex/api/client.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""HTTP client for the Autosage backend.
|
|
2
|
+
|
|
3
|
+
Talks to the base URL from config, sends the API key as `X-API-Key`, unwraps the
|
|
4
|
+
`{success, message, data, errors}` envelope, and turns failures into a single
|
|
5
|
+
`ApiError` with a human-friendly message. Synchronous on purpose — callers run it
|
|
6
|
+
from a background worker (like shell commands) so the UI never blocks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from contextlib import nullcontext
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from sagex import config
|
|
16
|
+
from sagex.api import store
|
|
17
|
+
|
|
18
|
+
_TIMEOUT = 15.0
|
|
19
|
+
_WRITE_TIMEOUT = 60.0
|
|
20
|
+
|
|
21
|
+
# Gateway errors come from nginx/the platform with an HTML body — no envelope message.
|
|
22
|
+
_UNAVAILABLE_STATUSES = (502, 503, 504)
|
|
23
|
+
|
|
24
|
+
_UNREACHABLE = "Unable to reach the Autosage server. Check your connection and try again later."
|
|
25
|
+
|
|
26
|
+
# Optional `factory(method) -> context manager` wrapped around each request; the
|
|
27
|
+
# CLI installs one to show its activity line. None (e.g. in the TUI) = no indicator.
|
|
28
|
+
_wait_indicator = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_wait_indicator(factory) -> None:
|
|
32
|
+
"""Install (or clear, with None) the indicator shown while a request is in flight."""
|
|
33
|
+
global _wait_indicator
|
|
34
|
+
_wait_indicator = factory
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def waiting(method: str):
|
|
38
|
+
"""The installed wait indicator for one request, or a no-op."""
|
|
39
|
+
return _wait_indicator(method) if _wait_indicator else nullcontext()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApiError(Exception):
|
|
43
|
+
"""A failed API call, carrying a message suitable for showing the user."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, *, status: int | None = None, errors=None) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.message = message
|
|
48
|
+
self.status = status
|
|
49
|
+
self.errors = errors # the envelope's `errors` (e.g. a code, candidates)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _connection_error(message: str, base_url: str, exc: Exception) -> ApiError:
|
|
53
|
+
"""A user-facing network error. Deliberately generic: end users shouldn't see
|
|
54
|
+
internal URLs or be told to start a server they don't run. SAGEX_DEBUG=1 appends
|
|
55
|
+
the URL and the underlying error, for troubleshooting a custom SAGEX_API_URL."""
|
|
56
|
+
if os.environ.get("SAGEX_DEBUG") == "1":
|
|
57
|
+
message = f"{message} [{base_url}: {type(exc).__name__}: {exc}]"
|
|
58
|
+
return ApiError(message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApiClient:
|
|
62
|
+
"""Thin wrapper over httpx for the Autosage REST API."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, base_url: str, api_key: str | None) -> None:
|
|
65
|
+
self.base_url = base_url.rstrip("/")
|
|
66
|
+
self.api_key = api_key
|
|
67
|
+
|
|
68
|
+
def get(self, path: str, params: dict | None = None, headers: dict | None = None):
|
|
69
|
+
"""GET `path` (e.g. '/api/workflows/') and return the envelope's `data`."""
|
|
70
|
+
return self._request("GET", path, params=params, headers=headers)
|
|
71
|
+
|
|
72
|
+
def post(self, path: str, json=None, headers: dict | None = None):
|
|
73
|
+
"""POST `json` to `path` (create) and return the envelope's `data`."""
|
|
74
|
+
return self._request("POST", path, json=json, headers=headers)
|
|
75
|
+
|
|
76
|
+
def put(self, path: str, json=None):
|
|
77
|
+
"""PUT `json` to `path` (full update) and return the envelope's `data`."""
|
|
78
|
+
return self._request("PUT", path, json=json)
|
|
79
|
+
|
|
80
|
+
def patch(self, path: str, json=None):
|
|
81
|
+
"""PATCH `json` to `path` (partial update) and return the envelope's `data`."""
|
|
82
|
+
return self._request("PATCH", path, json=json)
|
|
83
|
+
|
|
84
|
+
def delete(self, path: str):
|
|
85
|
+
"""DELETE `path` and return the envelope's `data` (usually None on success)."""
|
|
86
|
+
return self._request("DELETE", path)
|
|
87
|
+
|
|
88
|
+
def stream(self, method: str, path: str, json=None):
|
|
89
|
+
"""Open a Server-Sent Events endpoint and yield (event, data) frames live.
|
|
90
|
+
|
|
91
|
+
No read timeout: a run can stay silent for minutes and the server sends no
|
|
92
|
+
heartbeats. Raises ApiError on an error response or a dropped connection.
|
|
93
|
+
"""
|
|
94
|
+
url = f"{self.base_url}{path}"
|
|
95
|
+
headers = {**self._headers(), "Accept": "text/event-stream"}
|
|
96
|
+
timeout = httpx.Timeout(_TIMEOUT, read=None)
|
|
97
|
+
started = False
|
|
98
|
+
try:
|
|
99
|
+
with httpx.stream(method, url, headers=headers, json=json, timeout=timeout) as resp:
|
|
100
|
+
if resp.status_code >= 400:
|
|
101
|
+
resp.read()
|
|
102
|
+
self._unwrap(resp)
|
|
103
|
+
for frame in _sse_frames(resp.iter_lines()):
|
|
104
|
+
started = True
|
|
105
|
+
yield frame
|
|
106
|
+
except httpx.RequestError as exc:
|
|
107
|
+
if started:
|
|
108
|
+
raise _connection_error(
|
|
109
|
+
"Lost the connection to the Autosage server mid-run.", self.base_url, exc
|
|
110
|
+
) from exc
|
|
111
|
+
raise _connection_error(_UNREACHABLE, self.base_url, exc) from exc
|
|
112
|
+
|
|
113
|
+
# --- internals -----------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _headers(self) -> dict:
|
|
116
|
+
headers = {"Accept": "application/json"}
|
|
117
|
+
if self.api_key:
|
|
118
|
+
headers["X-API-Key"] = self.api_key
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
def _request(self, method: str, path: str, params: dict | None = None, json=None,
|
|
122
|
+
headers: dict | None = None):
|
|
123
|
+
url = f"{self.base_url}{path}"
|
|
124
|
+
is_read = method.upper() == "GET"
|
|
125
|
+
try:
|
|
126
|
+
with waiting(method):
|
|
127
|
+
resp = httpx.request(
|
|
128
|
+
method, url, headers={**self._headers(), **(headers or {})},
|
|
129
|
+
params=params, json=json,
|
|
130
|
+
# Writes can be slow server-side (e.g. a script create uploads to storage).
|
|
131
|
+
timeout=_TIMEOUT if is_read else _WRITE_TIMEOUT,
|
|
132
|
+
)
|
|
133
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
134
|
+
raise _connection_error(_UNREACHABLE, self.base_url, exc) from exc
|
|
135
|
+
except httpx.TimeoutException as exc:
|
|
136
|
+
# The request reached the server, so a change may still have been applied.
|
|
137
|
+
raise ApiError(
|
|
138
|
+
"The server took too long to respond." if is_read else
|
|
139
|
+
"The server took too long to respond — the change may still have gone "
|
|
140
|
+
"through. Check before retrying."
|
|
141
|
+
) from exc
|
|
142
|
+
except httpx.RequestError as exc:
|
|
143
|
+
raise _connection_error(_UNREACHABLE, self.base_url, exc) from exc
|
|
144
|
+
return self._unwrap(resp)
|
|
145
|
+
|
|
146
|
+
def _unwrap(self, resp: httpx.Response):
|
|
147
|
+
"""Return the envelope's `data` on success; raise ApiError otherwise."""
|
|
148
|
+
try:
|
|
149
|
+
body = resp.json()
|
|
150
|
+
except Exception:
|
|
151
|
+
body = None
|
|
152
|
+
|
|
153
|
+
if resp.status_code == 401 and self.api_key:
|
|
154
|
+
raise ApiError(
|
|
155
|
+
"Not authenticated — your API key is invalid or was revoked. "
|
|
156
|
+
"Run: sagex auth login",
|
|
157
|
+
status=401,
|
|
158
|
+
)
|
|
159
|
+
if resp.status_code in _UNAVAILABLE_STATUSES:
|
|
160
|
+
raise ApiError(
|
|
161
|
+
"The Autosage server is temporarily unavailable. Please try again later.",
|
|
162
|
+
status=resp.status_code,
|
|
163
|
+
)
|
|
164
|
+
if resp.status_code >= 400:
|
|
165
|
+
message = body.get("message") if isinstance(body, dict) else None
|
|
166
|
+
errors = body.get("errors") if isinstance(body, dict) else None
|
|
167
|
+
raise ApiError(message or f"Request failed ({resp.status_code}).",
|
|
168
|
+
status=resp.status_code, errors=errors)
|
|
169
|
+
|
|
170
|
+
if isinstance(body, dict) and "data" in body:
|
|
171
|
+
return body["data"]
|
|
172
|
+
return body
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _sse_frames(lines):
|
|
176
|
+
"""Group SSE lines into (event, data) frames; data is JSON-decoded when possible."""
|
|
177
|
+
event, data = "message", []
|
|
178
|
+
for line in lines:
|
|
179
|
+
if line.startswith("event:"):
|
|
180
|
+
event = line[len("event:"):].strip()
|
|
181
|
+
elif line.startswith("data:"):
|
|
182
|
+
data.append(line[len("data:"):].removeprefix(" "))
|
|
183
|
+
elif not line:
|
|
184
|
+
if data:
|
|
185
|
+
raw = "\n".join(data)
|
|
186
|
+
try:
|
|
187
|
+
yield event, json.loads(raw)
|
|
188
|
+
except ValueError:
|
|
189
|
+
yield event, raw
|
|
190
|
+
event, data = "message", []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_client() -> ApiClient:
|
|
194
|
+
"""Build a client from the configured base URL and the stored key."""
|
|
195
|
+
return ApiClient(config.api_url(), store.get_key())
|