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/_env.py ADDED
@@ -0,0 +1,7 @@
1
+ # Generated from .env by setup.py at build time. Do not edit or commit.
2
+ # Ships publicly inside the package: never secrets.
3
+
4
+ VALUES = {
5
+ 'SAGEX_API_URL': 'https://autosagex-api.duckdns.org',
6
+ 'SAGEX_WEB_URL': 'https://autosagex.web.app',
7
+ }
sagex/api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Networking layer: the API client and secure key storage."""
2
+
3
+ from sagex.api.client import ApiClient, ApiError, build_client # noqa: F401
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())