rootcause-sdk 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.
- rootcause/__init__.py +175 -0
- rootcause/_http.py +361 -0
- rootcause/direct.py +178 -0
- rootcause/errors.py +58 -0
- rootcause/graph.py +119 -0
- rootcause/interventions.py +149 -0
- rootcause/jupyter.py +237 -0
- rootcause/ontology.py +188 -0
- rootcause/results.py +266 -0
- rootcause/twin.py +428 -0
- rootcause/workspace.py +338 -0
- rootcause_sdk-1.0.0.dist-info/METADATA +148 -0
- rootcause_sdk-1.0.0.dist-info/RECORD +14 -0
- rootcause_sdk-1.0.0.dist-info/WHEEL +4 -0
rootcause/__init__.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""RootCause SDK: causal discovery, digital twins, and ontology queries from Python.
|
|
2
|
+
|
|
3
|
+
Two modes, one object model:
|
|
4
|
+
|
|
5
|
+
import rootcause as rc
|
|
6
|
+
rc.login()
|
|
7
|
+
|
|
8
|
+
ws = rc.workspace("Calix Forecasting") # platform mode
|
|
9
|
+
twin = ws.twin("C8 Temporal")
|
|
10
|
+
twin.forecast(horizon=24).to_frame()
|
|
11
|
+
|
|
12
|
+
graph = rc.discover(df, target="re78") # direct mode — no workspace ceremony
|
|
13
|
+
twin = graph.train()
|
|
14
|
+
twin.intervene({"treat": rc.set(1)}, where={"re75": ("<", 5000)})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from rootcause import direct as _direct
|
|
20
|
+
from rootcause._http import Transport, resolve_transport
|
|
21
|
+
from rootcause.errors import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
JobFailedError,
|
|
24
|
+
JobTimeoutError,
|
|
25
|
+
KindMismatchError,
|
|
26
|
+
NotFoundInWorkspaceError,
|
|
27
|
+
RootCauseApiError,
|
|
28
|
+
RootCauseError,
|
|
29
|
+
)
|
|
30
|
+
from rootcause.graph import Graph
|
|
31
|
+
from rootcause.interventions import add, adjust_prob, at, mean_metrics, members, metric, pct, prob, range, set # noqa: A004
|
|
32
|
+
from rootcause.ontology import Ontology, OntologyQueryResult
|
|
33
|
+
from rootcause.results import ForecastResult, SampleDraws, ScoreResult, SimulationResult, UpdateResult
|
|
34
|
+
from rootcause.twin import Twin
|
|
35
|
+
from rootcause.workspace import Connector, DataView, Source, Workspace
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
|
42
|
+
|
|
43
|
+
_session: dict[str, Transport | None] = {"transport": None}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def login(api_key: str | None = None, base_url: str | None = None) -> None:
|
|
47
|
+
"""Authenticate the module-level session.
|
|
48
|
+
|
|
49
|
+
Resolution order: explicit api_key → ROOTCAUSE_API_KEY (with
|
|
50
|
+
ROOTCAUSE_BASE_URL) → cached OAuth token in ~/.rootcause → interactive
|
|
51
|
+
browser login (PKCE; prints a URL to paste a code from on remote kernels).
|
|
52
|
+
"""
|
|
53
|
+
if _session["transport"] is not None:
|
|
54
|
+
_session["transport"].close()
|
|
55
|
+
_session["transport"] = resolve_transport(api_key=api_key, base_url=base_url)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _transport() -> Transport:
|
|
59
|
+
if _session["transport"] is None:
|
|
60
|
+
login()
|
|
61
|
+
transport = _session["transport"]
|
|
62
|
+
assert transport is not None
|
|
63
|
+
return transport
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def whoami() -> dict[str, "Any"]:
|
|
67
|
+
"""What the current credential is: ids, scopes, auth type, rate limit."""
|
|
68
|
+
envelope = _transport().request("GET", "/api/v1/me")
|
|
69
|
+
return envelope.get("data", envelope)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def workspaces() -> "pd.DataFrame":
|
|
73
|
+
"""All workspaces the session can see."""
|
|
74
|
+
import pandas as pd
|
|
75
|
+
|
|
76
|
+
envelope = _transport().request("GET", "/api/v1/workspaces")
|
|
77
|
+
rows = [
|
|
78
|
+
{"id": doc.get("id") or doc.get("_id"), "name": doc.get("name")}
|
|
79
|
+
for doc in envelope.get("data", [])
|
|
80
|
+
if doc.get("name") != _direct.SCRATCH_WORKSPACE_NAME
|
|
81
|
+
]
|
|
82
|
+
return pd.DataFrame(rows, columns=["id", "name"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def workspace(needle: str, *, create: bool = False) -> Workspace:
|
|
86
|
+
"""Resolve a workspace by name or id; create=True creates it when missing."""
|
|
87
|
+
transport = _transport()
|
|
88
|
+
envelope = transport.request("GET", "/api/v1/workspaces")
|
|
89
|
+
docs = list(envelope.get("data", []))
|
|
90
|
+
for doc in docs:
|
|
91
|
+
if needle in (doc.get("id"), doc.get("_id"), doc.get("name")):
|
|
92
|
+
return Workspace(transport, doc)
|
|
93
|
+
lowered = needle.lower()
|
|
94
|
+
for doc in docs:
|
|
95
|
+
if str(doc.get("name", "")).lower() == lowered:
|
|
96
|
+
return Workspace(transport, doc)
|
|
97
|
+
if create:
|
|
98
|
+
created = transport.request("POST", "/api/v1/workspaces", json_body={"name": needle})
|
|
99
|
+
return Workspace(transport, created.get("data", created))
|
|
100
|
+
import difflib
|
|
101
|
+
|
|
102
|
+
names = [str(doc.get("name")) for doc in docs if doc.get("name") and doc.get("name") != _direct.SCRATCH_WORKSPACE_NAME]
|
|
103
|
+
raise NotFoundInWorkspaceError("workspace", needle, difflib.get_close_matches(needle, names, n=5))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discover(
|
|
107
|
+
frame: "pd.DataFrame",
|
|
108
|
+
target: str | None = None,
|
|
109
|
+
time: str | None = None,
|
|
110
|
+
entity: str | None = None,
|
|
111
|
+
kind: str | None = None,
|
|
112
|
+
name: str | None = None,
|
|
113
|
+
force: bool = False,
|
|
114
|
+
timeout: float = 3600.0,
|
|
115
|
+
) -> Graph:
|
|
116
|
+
"""Causal discovery on a DataFrame. Compute runs on the platform; nothing user-visible persists.
|
|
117
|
+
|
|
118
|
+
Identical data reuses the previously discovered twin; force=True re-runs
|
|
119
|
+
discovery from scratch (the recovery path for corrupt or outdated models).
|
|
120
|
+
"""
|
|
121
|
+
return _direct.discover(
|
|
122
|
+
frame, target=target, time=time, entity=entity, kind=kind, name=name,
|
|
123
|
+
force=force, transport=_transport(), timeout=timeout,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_twin(path: "str | Path", timeout: float = 3600.0) -> Twin:
|
|
128
|
+
"""Load a .rctwin export zip back into a runnable twin."""
|
|
129
|
+
return _direct.load_twin(path, transport=_transport(), timeout=timeout)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_widget(widget: dict[str, Any], theme: str = "light") -> str:
|
|
133
|
+
"""Render a widget payload to a self-contained HTML fragment via the platform renderer."""
|
|
134
|
+
envelope = _transport().request(
|
|
135
|
+
"POST", "/api/v1/render/widget", json_body={"widget": widget, "theme": theme}
|
|
136
|
+
)
|
|
137
|
+
return str(envelope.get("data", {}).get("html", ""))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"AuthenticationError",
|
|
142
|
+
"Connector",
|
|
143
|
+
"DataView",
|
|
144
|
+
"ForecastResult",
|
|
145
|
+
"Graph",
|
|
146
|
+
"JobFailedError",
|
|
147
|
+
"JobTimeoutError",
|
|
148
|
+
"KindMismatchError",
|
|
149
|
+
"NotFoundInWorkspaceError",
|
|
150
|
+
"Ontology",
|
|
151
|
+
"OntologyQueryResult",
|
|
152
|
+
"RootCauseApiError",
|
|
153
|
+
"RootCauseError",
|
|
154
|
+
"SampleDraws",
|
|
155
|
+
"SimulationResult",
|
|
156
|
+
"Source",
|
|
157
|
+
"Twin",
|
|
158
|
+
"Workspace",
|
|
159
|
+
"add",
|
|
160
|
+
"adjust_prob",
|
|
161
|
+
"at",
|
|
162
|
+
"discover",
|
|
163
|
+
"load_twin",
|
|
164
|
+
"login",
|
|
165
|
+
"mean_metrics",
|
|
166
|
+
"members",
|
|
167
|
+
"metric",
|
|
168
|
+
"pct",
|
|
169
|
+
"prob",
|
|
170
|
+
"render_widget",
|
|
171
|
+
"set",
|
|
172
|
+
"workspace",
|
|
173
|
+
"workspaces",
|
|
174
|
+
"__version__",
|
|
175
|
+
]
|
rootcause/_http.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from rootcause.errors import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
JobFailedError,
|
|
18
|
+
JobTimeoutError,
|
|
19
|
+
RootCauseApiError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://platform.rootcause.ai"
|
|
23
|
+
CREDENTIALS_PATH = Path.home() / ".rootcause" / "credentials.json"
|
|
24
|
+
RETRYABLE_STATUSES = {429, 502, 503, 504}
|
|
25
|
+
JOB_TERMINAL = {"completed", "failed", "cancelled"}
|
|
26
|
+
RUN_TERMINAL = {"completed", "failed", "cancelled"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_credentials() -> dict[str, Any]:
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
|
|
32
|
+
except (OSError, json.JSONDecodeError):
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_credentials(store: dict[str, Any]) -> None:
|
|
37
|
+
CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
CREDENTIALS_PATH.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
|
39
|
+
try:
|
|
40
|
+
CREDENTIALS_PATH.chmod(0o600)
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _OAuthSession:
|
|
46
|
+
"""Authorization-code + PKCE against the platform's OAuth server.
|
|
47
|
+
|
|
48
|
+
Registers the SDK as a public client via dynamic client registration, opens
|
|
49
|
+
the browser for consent, and falls back to paste-the-code when no local
|
|
50
|
+
callback is reachable (remote kernels)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, base_url: str) -> None:
|
|
53
|
+
self.base_url = base_url
|
|
54
|
+
|
|
55
|
+
def login(self) -> dict[str, Any]:
|
|
56
|
+
client_id = self._register_client()
|
|
57
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=")
|
|
58
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
|
59
|
+
redirect_uri, wait_for_code = self._callback()
|
|
60
|
+
|
|
61
|
+
params = {
|
|
62
|
+
"response_type": "code",
|
|
63
|
+
"client_id": client_id,
|
|
64
|
+
"redirect_uri": redirect_uri,
|
|
65
|
+
"code_challenge": challenge,
|
|
66
|
+
"code_challenge_method": "S256",
|
|
67
|
+
"state": secrets.token_urlsafe(16),
|
|
68
|
+
"resource": f"{self.base_url}/api/v1",
|
|
69
|
+
}
|
|
70
|
+
url = f"{self.base_url}/api/oauth/authorize?{urlencode(params)}"
|
|
71
|
+
print(f"Opening browser for RootCause login…\n {url}", file=sys.stderr)
|
|
72
|
+
webbrowser.open(url)
|
|
73
|
+
code = wait_for_code()
|
|
74
|
+
|
|
75
|
+
with httpx.Client(timeout=30.0) as http:
|
|
76
|
+
resp = http.post(
|
|
77
|
+
f"{self.base_url}/api/oauth/token",
|
|
78
|
+
data={
|
|
79
|
+
"grant_type": "authorization_code",
|
|
80
|
+
"code": code,
|
|
81
|
+
"redirect_uri": redirect_uri,
|
|
82
|
+
"client_id": client_id,
|
|
83
|
+
"code_verifier": verifier,
|
|
84
|
+
"resource": f"{self.base_url}/api/v1",
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
if resp.status_code >= 400:
|
|
88
|
+
raise AuthenticationError(f"Token exchange failed ({resp.status_code}): {resp.text[:300]}")
|
|
89
|
+
token = resp.json()
|
|
90
|
+
return {
|
|
91
|
+
"client_id": client_id,
|
|
92
|
+
"access_token": token["access_token"],
|
|
93
|
+
"refresh_token": token.get("refresh_token"),
|
|
94
|
+
"expires_at": time.time() + float(token.get("expires_in", 3600)),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def refresh(self, entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
98
|
+
if not entry.get("refresh_token"):
|
|
99
|
+
return None
|
|
100
|
+
with httpx.Client(timeout=30.0) as http:
|
|
101
|
+
resp = http.post(
|
|
102
|
+
f"{self.base_url}/api/oauth/token",
|
|
103
|
+
data={
|
|
104
|
+
"grant_type": "refresh_token",
|
|
105
|
+
"refresh_token": entry["refresh_token"],
|
|
106
|
+
"client_id": entry["client_id"],
|
|
107
|
+
"resource": f"{self.base_url}/api/v1",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
if resp.status_code >= 400:
|
|
111
|
+
return None
|
|
112
|
+
token = resp.json()
|
|
113
|
+
return {
|
|
114
|
+
**entry,
|
|
115
|
+
"access_token": token["access_token"],
|
|
116
|
+
"refresh_token": token.get("refresh_token", entry["refresh_token"]),
|
|
117
|
+
"expires_at": time.time() + float(token.get("expires_in", 3600)),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _register_client(self) -> str:
|
|
121
|
+
with httpx.Client(timeout=30.0) as http:
|
|
122
|
+
resp = http.post(
|
|
123
|
+
f"{self.base_url}/api/oauth/register",
|
|
124
|
+
json={
|
|
125
|
+
"client_name": "rootcause-sdk",
|
|
126
|
+
"redirect_uris": ["http://127.0.0.1:8765/callback", "urn:ietf:wg:oauth:2.0:oob"],
|
|
127
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
128
|
+
"response_types": ["code"],
|
|
129
|
+
"token_endpoint_auth_method": "none",
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
if resp.status_code >= 400:
|
|
133
|
+
raise AuthenticationError(
|
|
134
|
+
"Could not register the SDK as an OAuth client "
|
|
135
|
+
f"({resp.status_code}). Use an API key instead: rc.login(api_key='pk_…') "
|
|
136
|
+
"or set ROOTCAUSE_API_KEY."
|
|
137
|
+
)
|
|
138
|
+
return str(resp.json()["client_id"])
|
|
139
|
+
|
|
140
|
+
def _callback(self) -> tuple[str, Callable[[], str]]:
|
|
141
|
+
try:
|
|
142
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
143
|
+
|
|
144
|
+
holder: dict[str, str] = {}
|
|
145
|
+
|
|
146
|
+
class Handler(BaseHTTPRequestHandler):
|
|
147
|
+
def do_GET(self):
|
|
148
|
+
from urllib.parse import parse_qs, urlparse
|
|
149
|
+
|
|
150
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
151
|
+
holder["code"] = qs.get("code", [""])[0]
|
|
152
|
+
self.send_response(200)
|
|
153
|
+
self.send_header("Content-Type", "text/html")
|
|
154
|
+
self.end_headers()
|
|
155
|
+
self.wfile.write(b"<h3>Login complete. You can close this tab.</h3>")
|
|
156
|
+
|
|
157
|
+
def log_message(self, *args):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
server = HTTPServer(("127.0.0.1", 8765), Handler)
|
|
161
|
+
|
|
162
|
+
def wait() -> str:
|
|
163
|
+
server.timeout = 300
|
|
164
|
+
while "code" not in holder:
|
|
165
|
+
server.handle_request()
|
|
166
|
+
server.server_close()
|
|
167
|
+
if not holder["code"]:
|
|
168
|
+
raise AuthenticationError("Browser login returned no authorization code")
|
|
169
|
+
return holder["code"]
|
|
170
|
+
|
|
171
|
+
return "http://127.0.0.1:8765/callback", wait
|
|
172
|
+
except OSError:
|
|
173
|
+
def wait_paste() -> str:
|
|
174
|
+
code = input("Paste the authorization code shown in the browser: ").strip()
|
|
175
|
+
if not code:
|
|
176
|
+
raise AuthenticationError("No authorization code provided")
|
|
177
|
+
return code
|
|
178
|
+
|
|
179
|
+
return "urn:ietf:wg:oauth:2.0:oob", wait_paste
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Transport:
|
|
183
|
+
"""Synchronous HTTP layer: auth header, retries with backoff, problem+json mapping."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
base_url: str,
|
|
188
|
+
token: str,
|
|
189
|
+
*,
|
|
190
|
+
oauth_entry: dict[str, Any] | None = None,
|
|
191
|
+
timeout: float = 120.0,
|
|
192
|
+
httpx_transport: httpx.BaseTransport | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
self.base_url = base_url.rstrip("/")
|
|
195
|
+
self._token = token
|
|
196
|
+
self._oauth_entry = oauth_entry
|
|
197
|
+
self._client = httpx.Client(base_url=self.base_url, timeout=timeout, transport=httpx_transport)
|
|
198
|
+
|
|
199
|
+
def close(self) -> None:
|
|
200
|
+
self._client.close()
|
|
201
|
+
|
|
202
|
+
def request(
|
|
203
|
+
self,
|
|
204
|
+
method: str,
|
|
205
|
+
path: str,
|
|
206
|
+
*,
|
|
207
|
+
json_body: Any = None,
|
|
208
|
+
content: bytes | None = None,
|
|
209
|
+
headers: dict[str, str] | None = None,
|
|
210
|
+
params: dict[str, Any] | None = None,
|
|
211
|
+
max_attempts: int = 4,
|
|
212
|
+
) -> Any:
|
|
213
|
+
response = self._raw(method, path, json_body=json_body, content=content, headers=headers, params=params, max_attempts=max_attempts)
|
|
214
|
+
if response.status_code == 204 or not response.content:
|
|
215
|
+
return {}
|
|
216
|
+
return response.json()
|
|
217
|
+
|
|
218
|
+
def request_bytes(self, method: str, path: str, *, params: dict[str, Any] | None = None) -> bytes:
|
|
219
|
+
return self._raw(method, path, params=params).content
|
|
220
|
+
|
|
221
|
+
def _raw(
|
|
222
|
+
self,
|
|
223
|
+
method: str,
|
|
224
|
+
path: str,
|
|
225
|
+
*,
|
|
226
|
+
json_body: Any = None,
|
|
227
|
+
content: bytes | None = None,
|
|
228
|
+
headers: dict[str, str] | None = None,
|
|
229
|
+
params: dict[str, Any] | None = None,
|
|
230
|
+
max_attempts: int = 4,
|
|
231
|
+
) -> httpx.Response:
|
|
232
|
+
attempt = 0
|
|
233
|
+
while True:
|
|
234
|
+
attempt += 1
|
|
235
|
+
all_headers = {"Authorization": f"Bearer {self._access_token()}", **(headers or {})}
|
|
236
|
+
try:
|
|
237
|
+
response = self._client.request(
|
|
238
|
+
method, path, json=json_body, content=content, headers=all_headers, params=params
|
|
239
|
+
)
|
|
240
|
+
except httpx.TransportError:
|
|
241
|
+
if method == "GET" and attempt < max_attempts:
|
|
242
|
+
time.sleep(min(2.0 ** attempt, 20.0))
|
|
243
|
+
continue
|
|
244
|
+
raise
|
|
245
|
+
if response.status_code in RETRYABLE_STATUSES and attempt < max_attempts:
|
|
246
|
+
retry_after = response.headers.get("Retry-After")
|
|
247
|
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else min(2.0 ** attempt, 20.0)
|
|
248
|
+
time.sleep(delay)
|
|
249
|
+
continue
|
|
250
|
+
if response.status_code >= 400:
|
|
251
|
+
try:
|
|
252
|
+
body = response.json()
|
|
253
|
+
except (json.JSONDecodeError, ValueError):
|
|
254
|
+
body = {"detail": response.text}
|
|
255
|
+
raise RootCauseApiError.from_response(body, response.status_code)
|
|
256
|
+
return response
|
|
257
|
+
|
|
258
|
+
def _access_token(self) -> str:
|
|
259
|
+
entry = self._oauth_entry
|
|
260
|
+
if entry is None:
|
|
261
|
+
return self._token
|
|
262
|
+
if time.time() > float(entry.get("expires_at", 0)) - 60:
|
|
263
|
+
refreshed = _OAuthSession(self.base_url).refresh(entry)
|
|
264
|
+
if refreshed is None:
|
|
265
|
+
raise AuthenticationError("OAuth token expired and refresh failed; run rc.login() again")
|
|
266
|
+
entry.update(refreshed)
|
|
267
|
+
store = _read_credentials()
|
|
268
|
+
store[self.base_url] = entry
|
|
269
|
+
_write_credentials(store)
|
|
270
|
+
return str(entry["access_token"])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def resolve_transport(api_key: str | None = None, base_url: str | None = None) -> Transport:
|
|
274
|
+
"""Credential resolution: explicit key → env → cached OAuth token → interactive PKCE."""
|
|
275
|
+
resolved_base = (base_url or os.environ.get("ROOTCAUSE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
276
|
+
|
|
277
|
+
key = api_key or os.environ.get("ROOTCAUSE_API_KEY")
|
|
278
|
+
if key:
|
|
279
|
+
return Transport(resolved_base, key)
|
|
280
|
+
|
|
281
|
+
store = _read_credentials()
|
|
282
|
+
entry = store.get(resolved_base)
|
|
283
|
+
if isinstance(entry, dict) and entry.get("access_token"):
|
|
284
|
+
return Transport(resolved_base, "", oauth_entry=entry)
|
|
285
|
+
|
|
286
|
+
session = _OAuthSession(resolved_base)
|
|
287
|
+
entry = session.login()
|
|
288
|
+
store[resolved_base] = entry
|
|
289
|
+
_write_credentials(store)
|
|
290
|
+
return Transport(resolved_base, "", oauth_entry=entry)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class _Progress:
|
|
294
|
+
def __init__(self, label: str) -> None:
|
|
295
|
+
self.label = label
|
|
296
|
+
self._last = ""
|
|
297
|
+
|
|
298
|
+
def update(self, status: str, progress: Any = None) -> None:
|
|
299
|
+
pct = f" {progress}%" if isinstance(progress, (int, float)) else ""
|
|
300
|
+
line = f"{self.label}: {status}{pct}"
|
|
301
|
+
if line != self._last and sys.stderr.isatty():
|
|
302
|
+
print(f"\r{line} ", end="", file=sys.stderr, flush=True)
|
|
303
|
+
self._last = line
|
|
304
|
+
|
|
305
|
+
def done(self) -> None:
|
|
306
|
+
if self._last and sys.stderr.isatty():
|
|
307
|
+
print(file=sys.stderr)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def poll_job(
|
|
311
|
+
transport: Transport,
|
|
312
|
+
workspace_id: str,
|
|
313
|
+
job_id: str,
|
|
314
|
+
*,
|
|
315
|
+
label: str = "job",
|
|
316
|
+
interval: float = 3.0,
|
|
317
|
+
timeout: float = 3600.0,
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""Block until a pipeline job reaches a terminal state, drawing a progress line."""
|
|
320
|
+
progress = _Progress(label)
|
|
321
|
+
deadline = time.monotonic() + timeout
|
|
322
|
+
while True:
|
|
323
|
+
doc = transport.request("GET", f"/api/v1/workspaces/{workspace_id}/jobs/{job_id}").get("data", {})
|
|
324
|
+
status = str(doc.get("status", "unknown"))
|
|
325
|
+
progress.update(status, doc.get("progress"))
|
|
326
|
+
if status in JOB_TERMINAL:
|
|
327
|
+
progress.done()
|
|
328
|
+
if status != "completed":
|
|
329
|
+
raise JobFailedError(job_id, status, str(doc.get("error") or doc.get("message") or "") or None)
|
|
330
|
+
return doc
|
|
331
|
+
if time.monotonic() > deadline:
|
|
332
|
+
progress.done()
|
|
333
|
+
raise JobTimeoutError(f"Job {job_id} still '{status}' after {timeout:.0f}s")
|
|
334
|
+
time.sleep(interval)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def poll_run(
|
|
338
|
+
transport: Transport,
|
|
339
|
+
workspace_id: str,
|
|
340
|
+
run_id: str,
|
|
341
|
+
*,
|
|
342
|
+
label: str = "simulation",
|
|
343
|
+
interval: float = 3.0,
|
|
344
|
+
timeout: float = 3600.0,
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
"""Block until a simulation run reaches a terminal state."""
|
|
347
|
+
progress = _Progress(label)
|
|
348
|
+
deadline = time.monotonic() + timeout
|
|
349
|
+
while True:
|
|
350
|
+
doc = transport.request("GET", f"/api/v1/workspaces/{workspace_id}/simulations/{run_id}").get("data", {})
|
|
351
|
+
status = str(doc.get("status", "unknown"))
|
|
352
|
+
progress.update(status, doc.get("progress"))
|
|
353
|
+
if status in RUN_TERMINAL:
|
|
354
|
+
progress.done()
|
|
355
|
+
if status != "completed":
|
|
356
|
+
raise JobFailedError(run_id, status, str(doc.get("error") or "") or None)
|
|
357
|
+
return doc
|
|
358
|
+
if time.monotonic() > deadline:
|
|
359
|
+
progress.done()
|
|
360
|
+
raise JobTimeoutError(f"Run {run_id} still '{status}' after {timeout:.0f}s")
|
|
361
|
+
time.sleep(interval)
|