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 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)