solari-core 0.2.0__tar.gz

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.
@@ -0,0 +1,71 @@
1
+ # build artifacts
2
+ dist/
3
+ **/dist/
4
+ **/node_modules/
5
+ **/target/
6
+ *.log
7
+
8
+ # VM images / snapshots / kernels — large binaries, never committed
9
+ *.img
10
+ *.ext4
11
+ *.qcow2
12
+ *.raw
13
+ *.snapshot
14
+ /snapshots/build/
15
+ /guest/rootfs/build/
16
+ /guest/kernel/build/
17
+ vmlinux
18
+ bzImage
19
+
20
+ # terraform state
21
+ *.tfstate
22
+ *.tfstate.*
23
+ .terraform/
24
+ *.tfvars.local
25
+
26
+ # secrets / env
27
+ .env
28
+ .env.*
29
+ *.pem
30
+
31
+ # Python
32
+ __pycache__/
33
+ *.pyc
34
+ .venv/
35
+
36
+ # NOTE: tests/sdk/ts-request-fixtures.json is deliberately NOT ignored.
37
+ #
38
+ # It was, and that quietly disabled two gates at once:
39
+ #
40
+ # 1. ci.yml's "Committed fixture must match what the SDK emits today" step runs
41
+ # `git diff --exit-code tests/sdk/ts-request-fixtures.json`. On an ignored,
42
+ # never-tracked file that command can only ever succeed — it had never once
43
+ # been able to fail.
44
+ # 2. The Go/Rust/C++/Python contract suites now assert against that fixture from
45
+ # inside their OWN CI jobs, none of which run the TypeScript half. If the file
46
+ # is not committed, it is simply absent there.
47
+ #
48
+ # It is generated, but it is generated from the REFERENCE SDK and checked in as
49
+ # the baseline the other four bindings are held to — regenerating it and diffing
50
+ # is exactly how a wire change is made to announce itself.
51
+
52
+ # Built guest-agent binaries (produced by go build / build-rootfs staging)
53
+ guest/agent/agent
54
+ guest/rootfs/guest-agent
55
+
56
+ # build-rootfs.sh template staging + generated Dockerfile (regenerated each run)
57
+ guest/rootfs/guest-agent.service
58
+ guest/rootfs/template.packages
59
+ guest/rootfs/template-files/
60
+ guest/rootfs/.Dockerfile.gen
61
+ guest/rootfs/out/
62
+
63
+ # local test artifacts
64
+ staging-desktop.png
65
+ desktop.png
66
+ infra/terraform/snapshots-volumes/.build/
67
+
68
+ # deploy-e2e scratch state: holds a presigned REPO_URL with live STS creds
69
+ # (ASIA... + X-Amz-Security-Token). Never commit. Also stale PHASE_* here makes
70
+ # a bare re-run skip bake+verify and exit 0 with zero assertions.
71
+ infra/.deploy-e2e/
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: solari-core
3
+ Version: 0.2.0
4
+ Summary: Shared runtime for the Solari VM SDKs (transport, session handles, image builder, types).
5
+ Project-URL: Homepage, https://getsolari.com
6
+ Author: Solari
7
+ License: MIT
8
+ Keywords: desktop,microvm,sandbox,solari
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: httpx>=0.24
11
+ Requires-Dist: websockets>=11.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # solari-core
15
+
16
+ Shared runtime for the Solari VM SDKs — the HTTP/WebSocket transport, the
17
+ `Desktop` and `Sandbox` session handles, the image builder, and the typed error
18
+ hierarchy. Mirrors the TypeScript `@solarisdk/core` package.
19
+
20
+ You normally install a leaf package instead:
21
+
22
+ - [`solari-desktop`](https://pypi.org/project/solari-desktop/) — computer-use desktops
23
+ - [`solari-sandbox`](https://pypi.org/project/solari-sandbox/) — code sandboxes
24
+
25
+ Both depend on and re-export `solari-core`, so `SandboxClient` / `DesktopClient`
26
+ plus the shared types are importable straight from the leaf package.
27
+
28
+ Docs: <https://getsolari.com>
@@ -0,0 +1,15 @@
1
+ # solari-core
2
+
3
+ Shared runtime for the Solari VM SDKs — the HTTP/WebSocket transport, the
4
+ `Desktop` and `Sandbox` session handles, the image builder, and the typed error
5
+ hierarchy. Mirrors the TypeScript `@solarisdk/core` package.
6
+
7
+ You normally install a leaf package instead:
8
+
9
+ - [`solari-desktop`](https://pypi.org/project/solari-desktop/) — computer-use desktops
10
+ - [`solari-sandbox`](https://pypi.org/project/solari-sandbox/) — code sandboxes
11
+
12
+ Both depend on and re-export `solari-core`, so `SandboxClient` / `DesktopClient`
13
+ plus the shared types are importable straight from the leaf package.
14
+
15
+ Docs: <https://getsolari.com>
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "solari-core"
7
+ version = "0.2.0"
8
+ description = "Shared runtime for the Solari VM SDKs (transport, session handles, image builder, types)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Solari" }]
13
+ keywords = ["solari", "sandbox", "desktop", "microvm"]
14
+ dependencies = ["httpx>=0.24", "websockets>=11.0"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://getsolari.com"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["solari_core"]
@@ -0,0 +1,51 @@
1
+ """solari_core — shared runtime for the Solari VM SDKs.
2
+
3
+ The transport, session handles (:class:`Desktop`, :class:`Sandbox`), image
4
+ builder, and typed errors shared by ``solari-desktop`` and ``solari-sandbox``.
5
+ Mirrors the TypeScript ``@solarisdk/core`` package. You normally install one of
6
+ the leaf packages (``solari-desktop`` / ``solari-sandbox``), which re-export
7
+ this surface; import from here directly only for shared types.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from ._http import HttpTransport, new_idempotency_key
12
+ from .desktop import Desktop, DesktopConfig, ExecStreamHandler
13
+ from .handle import SessionConfig, SessionHandle, SessionHooks
14
+ from .image import CompiledImage, Image, LocalCopy
15
+ from .sandbox import Sandbox
16
+ from .template_client import SyncTemplateClient, TemplateClient
17
+ from .volume_client import SyncVolumeClient, VolumeClient
18
+ from .errors import (
19
+ ActionError, AuthError, ConcurrencyLimitError, ConnectionError,
20
+ GatewayError, NoCapacityError, SolariError, PlanError, TimeoutError,
21
+ )
22
+ from .types import (
23
+ CodeLanguage, CodeResultItem, CommandResult, CreateDesktopResponse,
24
+ CreateSandboxResponse, DeleteDesktopResponse, DesktopLifecycleResponse,
25
+ DesktopStatus, ExecResult, ExecStreamChunk, FsEntry, FsSearchMatch, FsStat,
26
+ FsWatchEvent, GatewayErrorBody, GitBranch, GitCommit, GitStatus,
27
+ GetDesktopResponse, HealthResult, KeyAction, MetricsResult, MouseAction,
28
+ MouseButton, PackageManager, PkgInstallResult, PortInfo, ProcessInfo,
29
+ RpcErrorBody, RpcResponse, RunCodeResult, SandboxKind, SandboxState,
30
+ SandboxView, ScreenshotFormat, SnapshotView,
31
+ )
32
+
33
+ __version__ = "0.2.0"
34
+
35
+ __all__ = [
36
+ "HttpTransport", "new_idempotency_key",
37
+ "Image", "CompiledImage", "LocalCopy",
38
+ "TemplateClient", "SyncTemplateClient", "VolumeClient", "SyncVolumeClient",
39
+ "Desktop", "DesktopConfig", "ExecStreamHandler", "Sandbox",
40
+ "SessionHandle", "SessionConfig", "SessionHooks",
41
+ "ActionError", "AuthError", "ConcurrencyLimitError", "ConnectionError",
42
+ "GatewayError", "NoCapacityError", "SolariError", "PlanError", "TimeoutError",
43
+ "CreateDesktopResponse", "CreateSandboxResponse", "DeleteDesktopResponse",
44
+ "DesktopLifecycleResponse", "DesktopStatus", "ExecResult", "ExecStreamChunk",
45
+ "FsEntry", "FsStat", "GatewayErrorBody", "GetDesktopResponse", "HealthResult",
46
+ "KeyAction", "MouseAction", "MouseButton", "PackageManager", "PkgInstallResult",
47
+ "PortInfo", "ProcessInfo", "RpcErrorBody", "RpcResponse", "ScreenshotFormat",
48
+ "CodeLanguage", "CodeResultItem", "CommandResult", "FsSearchMatch",
49
+ "FsWatchEvent", "GitBranch", "GitCommit", "GitStatus", "MetricsResult",
50
+ "RunCodeResult", "SandboxKind", "SandboxState", "SandboxView", "SnapshotView",
51
+ ]
@@ -0,0 +1,142 @@
1
+ """Shared async HTTP transport (mirrors the TypeScript ``src/http.ts``).
2
+
3
+ Both :class:`DesktopClient` and :class:`SandboxClient` delegate here so there is
4
+ ONE place owning auth headers, error mapping, retries/backoff, idempotency keys,
5
+ and timeouts.
6
+
7
+ Retry policy: idempotent requests (GET, DELETE, or any carrying an
8
+ Idempotency-Key) are retried on network errors, HTTP 5xx, and bodies flagged
9
+ ``retryable``, with exponential backoff + jitter. Non-idempotent writes are
10
+ never silently retried — pass ``idempotency_key`` to opt a create into safe
11
+ retries. 429 is NOT retried (it is our ConcurrencyLimitError).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import random
17
+ import uuid
18
+ from typing import Any, Dict, Optional
19
+
20
+ import httpx
21
+
22
+ from .errors import ConnectionError as PtConnectionError
23
+ from .errors import SolariError, map_gateway_error
24
+ from .types import GatewayErrorBody
25
+
26
+
27
+ def new_idempotency_key() -> str:
28
+ """A fresh idempotency key (UUID)."""
29
+ return str(uuid.uuid4())
30
+
31
+
32
+ class HttpTransport:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ api_key: str,
37
+ base_url: str,
38
+ http: Optional[httpx.AsyncClient] = None,
39
+ max_retries: int = 5,
40
+ request_timeout_ms: int = 300_000,
41
+ retry_delay_ms: Optional[int] = None,
42
+ ) -> None:
43
+ if not api_key:
44
+ raise SolariError("HttpTransport requires an api_key")
45
+ if not base_url:
46
+ raise SolariError("HttpTransport requires a base_url")
47
+ self._api_key = api_key
48
+ self._base_url = base_url.rstrip("/")
49
+ self._http = http
50
+ self._owns_http = http is None
51
+ self._max_retries = max_retries
52
+ self._timeout = request_timeout_ms / 1000.0
53
+ self._retry_delay_ms = retry_delay_ms
54
+
55
+ def _client(self) -> httpx.AsyncClient:
56
+ if self._http is None:
57
+ self._http = httpx.AsyncClient()
58
+ return self._http
59
+
60
+ def auth_headers(self) -> Dict[str, str]:
61
+ return {"Authorization": f"Bearer {self._api_key}"}
62
+
63
+ def ws_origin(self) -> str:
64
+ if self._base_url.startswith("https"):
65
+ return "wss" + self._base_url[len("https"):]
66
+ if self._base_url.startswith("http"):
67
+ return "ws" + self._base_url[len("http"):]
68
+ return self._base_url
69
+
70
+ async def request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ body: Optional[Any] = None,
75
+ *,
76
+ idempotency_key: Optional[str] = None,
77
+ ) -> Any:
78
+ idempotent = method in ("GET", "DELETE") or idempotency_key is not None
79
+ headers: Dict[str, str] = {
80
+ "Authorization": f"Bearer {self._api_key}",
81
+ "Accept": "application/json",
82
+ }
83
+ if body is not None:
84
+ headers["Content-Type"] = "application/json"
85
+ if idempotency_key:
86
+ headers["Idempotency-Key"] = idempotency_key
87
+
88
+ attempt = 0
89
+ while True:
90
+ try:
91
+ res = await self._client().request(
92
+ method,
93
+ f"{self._base_url}{path}",
94
+ headers=headers,
95
+ json=body if body is not None else None,
96
+ timeout=self._timeout,
97
+ )
98
+ except httpx.HTTPError as exc:
99
+ if idempotent and attempt < self._max_retries:
100
+ await asyncio.sleep(self._backoff(attempt))
101
+ attempt += 1
102
+ continue
103
+ raise PtConnectionError(f"{method} {path} failed: {exc}") from exc
104
+
105
+ if res.is_error:
106
+ err_body: Optional[GatewayErrorBody] = None
107
+ try:
108
+ parsed = res.json()
109
+ if isinstance(parsed, dict):
110
+ err_body = GatewayErrorBody(
111
+ code=parsed.get("code"),
112
+ error=parsed.get("error"),
113
+ message=parsed.get("message"),
114
+ retryable=parsed.get("retryable"),
115
+ )
116
+ except Exception: # noqa: BLE001 - body may be empty/non-JSON
117
+ err_body = None
118
+ # 5xx or explicit retryable hint; NOT 429 (ConcurrencyLimitError).
119
+ retryable = res.status_code >= 500 or (
120
+ err_body is not None and err_body.retryable is True
121
+ )
122
+ if idempotent and retryable and attempt < self._max_retries:
123
+ await asyncio.sleep(self._backoff(attempt))
124
+ attempt += 1
125
+ continue
126
+ raise map_gateway_error(res.status_code, err_body)
127
+
128
+ if not res.content:
129
+ return None
130
+ return res.json()
131
+
132
+ def _backoff(self, attempt: int) -> float:
133
+ if self._retry_delay_ms is not None:
134
+ return self._retry_delay_ms / 1000.0
135
+ # Base 150ms (was 1000): a create bounced by a transient host no_capacity
136
+ # during a burst re-picks a different host in ~150ms, not a full second.
137
+ return min(0.15 * 2 ** attempt, 8.0) + random.random() * 0.25
138
+
139
+ async def aclose(self) -> None:
140
+ if self._owns_http and self._http is not None:
141
+ await self._http.aclose()
142
+ self._http = None