spanda-sdk 0.4.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.
spanda/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Spanda SDK package alias — `from spanda import SpandaClient`."""
2
+
3
+ from spanda_sdk import SpandaClient, SpandaError, TelemetryStream
4
+
5
+ __all__ = ["SpandaClient", "SpandaError", "TelemetryStream"]
spanda_sdk/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Official Spanda Python SDK."""
2
+
3
+ from spanda_sdk.client import SpandaClient
4
+ from spanda_sdk.errors import SpandaError
5
+ from spanda_sdk.stream import TelemetryStream
6
+
7
+ __all__ = ["SpandaClient", "SpandaError", "TelemetryStream"]
spanda_sdk/client.py ADDED
@@ -0,0 +1,176 @@
1
+ """Official Spanda Python SDK — thin REST client over Control Center API v1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ import uuid
10
+ from typing import Any, Mapping, Optional
11
+
12
+ from spanda_sdk.errors import ConnectionError, PermissionError, SpandaError, ValidationError
13
+ from spanda_sdk.stream import TelemetryStream
14
+
15
+ __all__ = ["SpandaClient", "SpandaError", "TelemetryStream"]
16
+
17
+
18
+ class SpandaClient:
19
+ """REST v1 client with optional Bearer auth and correlation IDs."""
20
+
21
+ def __init__(
22
+ self,
23
+ base_url: Optional[str] = None,
24
+ api_key: Optional[str] = None,
25
+ timeout: float = 30.0,
26
+ ) -> None:
27
+ resolved_url = base_url or os.environ.get(
28
+ "SPANDA_CONTROL_CENTER_URL", "http://127.0.0.1:8080"
29
+ )
30
+ self.base_url = resolved_url.rstrip("/")
31
+ self.api_key = api_key if api_key is not None else os.environ.get("SPANDA_API_KEY")
32
+ self.timeout = timeout
33
+
34
+ @classmethod
35
+ def local(cls) -> "SpandaClient":
36
+ """Connect to the local Control Center."""
37
+ return cls()
38
+
39
+ def _request(
40
+ self,
41
+ method: str,
42
+ path: str,
43
+ body: Optional[Mapping[str, Any]] = None,
44
+ auth: bool = False,
45
+ ) -> Any:
46
+ url = f"{self.base_url}{path}"
47
+ headers = {"Accept": "application/json", "X-Correlation-ID": f"py-sdk-{uuid.uuid4().hex[:12]}"}
48
+ data = None
49
+ if body is not None:
50
+ headers["Content-Type"] = "application/json"
51
+ data = json.dumps(body).encode("utf-8")
52
+ if auth and self.api_key:
53
+ headers["Authorization"] = f"Bearer {self.api_key}"
54
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
55
+ try:
56
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
57
+ payload = resp.read().decode("utf-8")
58
+ if not payload:
59
+ return {}
60
+ return json.loads(payload)
61
+ except urllib.error.HTTPError as exc:
62
+ detail = exc.read().decode("utf-8", errors="replace")
63
+ if exc.code in (401, 403):
64
+ raise PermissionError(detail, status=exc.code) from exc
65
+ if exc.code == 400:
66
+ raise ValidationError(detail, status=exc.code) from exc
67
+ raise SpandaError(detail, status=exc.code) from exc
68
+ except urllib.error.URLError as exc:
69
+ raise ConnectionError(str(exc.reason)) from exc
70
+
71
+ def _program_body(self, file_or_project: str) -> dict[str, str]:
72
+ return {"file": file_or_project}
73
+
74
+ def readiness(self, file_or_project: str) -> Any:
75
+ return self._request(
76
+ "POST", "/v1/programs/readiness", self._program_body(file_or_project)
77
+ )
78
+
79
+ def assure(self, file_or_project: str) -> Any:
80
+ return self._request(
81
+ "POST", "/v1/programs/assure", self._program_body(file_or_project)
82
+ )
83
+
84
+ def diagnose(self, trace_or_file: str) -> Any:
85
+ return self._request(
86
+ "POST", "/v1/programs/diagnose", self._program_body(trace_or_file)
87
+ )
88
+
89
+ def heal(self, target: str) -> Any:
90
+ return self._request("POST", "/v1/programs/recovery/heal", self._program_body(target))
91
+
92
+ def verify_hardware(self, project: str) -> Any:
93
+ return self._request(
94
+ "POST", "/v1/programs/verify/hardware", self._program_body(project)
95
+ )
96
+
97
+ def verify_capabilities(self, project: str) -> Any:
98
+ return self._request(
99
+ "POST",
100
+ "/v1/programs/verify/capabilities",
101
+ {"file": project, "traceability": True},
102
+ )
103
+
104
+ def list_entities(self) -> Any:
105
+ return self._request("GET", "/v1/entities")
106
+
107
+ def get_entity(self, entity_id: str) -> Any:
108
+ return self._request("GET", f"/v1/entities/{entity_id}")
109
+
110
+ def list_devices(self) -> Any:
111
+ return self._request("GET", "/v1/devices", auth=True)
112
+
113
+ def provision_device(self, device_id: str, body: Optional[Mapping[str, Any]] = None) -> Any:
114
+ return self._request(
115
+ "POST",
116
+ f"/v1/devices/{device_id}/provision",
117
+ body or {},
118
+ auth=True,
119
+ )
120
+
121
+ def run_simulation(self, project: str, *, execute: bool = False) -> Any:
122
+ return self._request(
123
+ "POST",
124
+ "/v1/programs/simulation",
125
+ {"file": project, "execute": execute},
126
+ )
127
+
128
+ def replay(
129
+ self,
130
+ trace: str,
131
+ *,
132
+ deterministic: bool = False,
133
+ playback: bool = False,
134
+ ) -> Any:
135
+ return self._request(
136
+ "POST",
137
+ "/v1/programs/replay",
138
+ {
139
+ "file": trace,
140
+ "deterministic": deterministic,
141
+ "playback": playback,
142
+ },
143
+ )
144
+
145
+ def get_health(self, entity_id: str) -> Any:
146
+ return self._request("GET", f"/v1/entities/{entity_id}/health")
147
+
148
+ def get_trust(self, entity_id: str) -> Any:
149
+ return self._request("GET", f"/v1/entities/{entity_id}/trust")
150
+
151
+ def get_package_trust(self, package: str, version: Optional[str] = None) -> Any:
152
+ path = f"/v1/trust/package?name={package}"
153
+ if version:
154
+ path += f"&version={version}"
155
+ return self._request("GET", path)
156
+
157
+ def health_check(self) -> Any:
158
+ return self._request("GET", "/v1/health")
159
+
160
+ def rpc(self, method: str, params: Optional[Mapping[str, Any]] = None) -> Any:
161
+ payload = self._request(
162
+ "POST",
163
+ "/v1/rpc",
164
+ {"method": method, "params": params or {}},
165
+ )
166
+ return payload.get("result", payload)
167
+
168
+ # Backward-compatible Control Center helpers
169
+ def dashboard(self) -> Any:
170
+ return self._request("GET", "/v1/dashboard")
171
+
172
+ def readiness_run(self, body: Optional[Mapping[str, Any]] = None) -> Any:
173
+ return self._request("POST", "/v1/readiness/run", body or {})
174
+
175
+ def trust_package(self, name: str, version: Optional[str] = None) -> Any:
176
+ return self.get_package_trust(name, version)
spanda_sdk/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Structured errors for the Spanda Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SpandaError(Exception):
7
+ """Base SDK error."""
8
+
9
+ def __init__(self, message: str, *, status: int | None = None) -> None:
10
+ super().__init__(message)
11
+ self.status = status
12
+
13
+
14
+ class ValidationError(SpandaError):
15
+ """Invalid request or response payload."""
16
+
17
+
18
+ class ReadinessError(SpandaError):
19
+ """Readiness evaluation failed."""
20
+
21
+
22
+ class VerificationError(SpandaError):
23
+ """Hardware or capability verification failed."""
24
+
25
+
26
+ class SecurityError(SpandaError):
27
+ """Authentication or authorization failure."""
28
+
29
+
30
+ class ConnectionError(SpandaError):
31
+ """Network or server connectivity failure."""
32
+
33
+
34
+ class PermissionError(SpandaError):
35
+ """Insufficient permissions for the requested operation."""
spanda_sdk/stream.py ADDED
@@ -0,0 +1,43 @@
1
+ """WebSocket telemetry stream for Control Center events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from typing import Any, AsyncIterator, Callable, Optional
9
+
10
+
11
+ class TelemetryStream:
12
+ """Async WebSocket client for `WS /v1/stream/telemetry`."""
13
+
14
+ def __init__(self, base_url: Optional[str] = None) -> None:
15
+ http = base_url or os.environ.get(
16
+ "SPANDA_CONTROL_CENTER_URL", "http://127.0.0.1:8080"
17
+ )
18
+ self.ws_url = http.replace("https://", "wss://").replace("http://", "ws://")
19
+ if not self.ws_url.endswith("/v1/stream/telemetry"):
20
+ self.ws_url = f"{self.ws_url.rstrip('/')}/v1/stream/telemetry"
21
+
22
+ async def events(self) -> AsyncIterator[dict[str, Any]]:
23
+ """Yield parsed JSON events from the telemetry stream."""
24
+ try:
25
+ import websockets
26
+ except ImportError as exc:
27
+ raise RuntimeError(
28
+ "Install stream extras: pip install 'spanda-sdk[stream]'"
29
+ ) from exc
30
+
31
+ async with websockets.connect(self.ws_url) as ws:
32
+ async for message in ws:
33
+ yield json.loads(message)
34
+
35
+ async def listen(self, handler: Callable[[dict[str, Any]], None]) -> None:
36
+ """Invoke handler for each telemetry event."""
37
+ async for event in self.events():
38
+ handler(event)
39
+
40
+
41
+ def run_stream(handler: Callable[[dict[str, Any]], None], base_url: Optional[str] = None) -> None:
42
+ """Blocking helper to run an event handler loop."""
43
+ asyncio.run(TelemetryStream(base_url).listen(handler))
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: spanda-sdk
3
+ Version: 0.4.0
4
+ Summary: Official Python SDK for Spanda Control Center API v1
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: stream
8
+ Requires-Dist: websockets>=12; extra == "stream"
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7; extra == "dev"
11
+ Requires-Dist: websockets>=12; extra == "dev"
12
+
13
+ # Spanda Python SDK
14
+
15
+ Official Python client for Spanda Control Center API v1.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install -e sdk/python
21
+ # WebSocket telemetry
22
+ pip install -e "sdk/python[stream]"
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from spanda import SpandaClient
29
+
30
+ client = SpandaClient.local()
31
+ report = client.readiness("rover.sd")
32
+ print(report["report"])
33
+ ```
34
+
35
+ Requires Control Center running:
36
+
37
+ ```bash
38
+ spanda control-center serve --program examples/robotics/rover.sd
39
+ ```
40
+
41
+ ## Environment
42
+
43
+ | Variable | Purpose |
44
+ |----------|---------|
45
+ | `SPANDA_CONTROL_CENTER_URL` | API base URL (default `http://127.0.0.1:8080`) |
46
+ | `SPANDA_API_KEY` | Bearer token for authenticated endpoints |
47
+
48
+ ## Documentation
49
+
50
+ - [docs/sdk-python.md](../../docs/sdk-python.md)
51
+ - [docs/control-center-api.md](../../docs/control-center-api.md)
52
+
53
+ ## Legacy client
54
+
55
+ Enterprise ops helpers (`ControlCenterClient` for drift, OTA, SRE) remain in `packages/sdk-python`.
@@ -0,0 +1,9 @@
1
+ spanda/__init__.py,sha256=JugMNefsj4j3V_ESvGT_DM41Mx_-GbJGhXSln8d5wnk,199
2
+ spanda_sdk/__init__.py,sha256=GsiGu_HpjMum6bPpm4GzbSlPwUq3gOzqzJzOBreP4lI,228
3
+ spanda_sdk/client.py,sha256=VyvRMyxrHUv61WL5EVMuij0mxfKOGqE-dN3ejf1dQf0,6095
4
+ spanda_sdk/errors.py,sha256=WmSmBx3GwENozxpT-98Sw5cUA84zZ11kLZn3r68AIVo,817
5
+ spanda_sdk/stream.py,sha256=CVV6c0-UrwxXEikgopj-UG6Cu2SUNUMVGjDR4c9PNtQ,1578
6
+ spanda_sdk-0.4.0.dist-info/METADATA,sha256=CDZYDICbb3mftYh_JE7NGhVVrkfPSIbYyDPBldK0OG4,1274
7
+ spanda_sdk-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ spanda_sdk-0.4.0.dist-info/top_level.txt,sha256=O6u6MBJCrrAHyikzANQXI5x_OrbByshTCVdX62KgD-c,18
9
+ spanda_sdk-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ spanda
2
+ spanda_sdk