watasu 0.1.1__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.
watasu/__init__.py ADDED
@@ -0,0 +1,206 @@
1
+ """Python SDK for Watasu sandboxes.
2
+
3
+ The public sync surface talks to Watasu REST and WebSocket runtime endpoints.
4
+ """
5
+
6
+ from .api import ApiClient, client
7
+ from .connection_config import ApiParams, ConnectionConfig, ProxyTypes, Username
8
+ from .exceptions import (
9
+ AuthenticationException,
10
+ BuildException,
11
+ FileNotFoundException,
12
+ FileUploadException,
13
+ GitAuthException,
14
+ GitUpstreamException,
15
+ InvalidArgumentException,
16
+ NotEnoughSpaceException,
17
+ NotFoundException,
18
+ RateLimitException,
19
+ SandboxException,
20
+ SandboxNotFoundException,
21
+ TemplateException,
22
+ TimeoutException,
23
+ VolumeException,
24
+ )
25
+ from .sandbox.commands.command_handle import (
26
+ CommandExitException,
27
+ CommandResult,
28
+ PtyOutput,
29
+ PtySize,
30
+ Stderr,
31
+ Stdout,
32
+ )
33
+ from .sandbox.commands.main import ProcessInfo
34
+ from .sandbox.filesystem.filesystem import (
35
+ EntryInfo,
36
+ FileType,
37
+ FilesystemEvent,
38
+ FilesystemEventType,
39
+ WriteInfo,
40
+ )
41
+ from .sandbox.sandbox_api import (
42
+ ALL_TRAFFIC,
43
+ GitBranches,
44
+ GitFileStatus,
45
+ GitResetMode,
46
+ GitStatus,
47
+ GitHubMcpServer,
48
+ GitHubMcpServerConfig,
49
+ McpServer,
50
+ SandboxInfo,
51
+ SandboxInfoLifecycle,
52
+ SandboxLifecycle,
53
+ SandboxMetrics,
54
+ SandboxNetworkInfo,
55
+ SandboxNetworkOpts,
56
+ SandboxNetworkRule,
57
+ SandboxNetworkRuleInfo,
58
+ SandboxNetworkRules,
59
+ SandboxNetworkSelector,
60
+ SandboxNetworkSelectorContext,
61
+ SandboxNetworkTransform,
62
+ SandboxNetworkUpdate,
63
+ SandboxQuery,
64
+ SandboxState,
65
+ SnapshotInfo,
66
+ get_signature,
67
+ )
68
+ from .sandbox_async.main import AsyncSandbox
69
+ from .sandbox_sync.commands.command_handle import CommandHandle
70
+ from .sandbox_sync.filesystem.watch_handle import WatchHandle
71
+ from .sandbox_sync.main import Sandbox
72
+ from .sandbox_sync.paginator import SandboxPaginator, SnapshotPaginator
73
+ from .stubs import (
74
+ AsyncSnapshotPaginator,
75
+ AsyncTemplate,
76
+ AsyncVolume,
77
+ AsyncWatchHandle,
78
+ BuildInfo,
79
+ BuildStatusReason,
80
+ CopyItem,
81
+ LogEntry,
82
+ LogEntryEnd,
83
+ LogEntryLevel,
84
+ LogEntryStart,
85
+ OutputHandler,
86
+ ReadyCmd,
87
+ Template,
88
+ TemplateBase,
89
+ TemplateBuildStatus,
90
+ TemplateBuildStatusResponse,
91
+ TemplateClass,
92
+ TemplateTag,
93
+ TemplateTagInfo,
94
+ Volume,
95
+ VolumeAndToken,
96
+ VolumeApiParams,
97
+ VolumeConnectionConfig,
98
+ VolumeEntryStat,
99
+ VolumeFileType,
100
+ VolumeInfo,
101
+ default_build_logger,
102
+ wait_for_file,
103
+ wait_for_port,
104
+ wait_for_process,
105
+ wait_for_timeout,
106
+ wait_for_url,
107
+ )
108
+
109
+ __all__ = [
110
+ "ALL_TRAFFIC",
111
+ "ApiClient",
112
+ "ApiParams",
113
+ "AsyncSandbox",
114
+ "AsyncSnapshotPaginator",
115
+ "AsyncTemplate",
116
+ "AsyncVolume",
117
+ "AsyncWatchHandle",
118
+ "AuthenticationException",
119
+ "BuildException",
120
+ "BuildInfo",
121
+ "BuildStatusReason",
122
+ "CommandExitException",
123
+ "CommandHandle",
124
+ "CommandResult",
125
+ "ConnectionConfig",
126
+ "CopyItem",
127
+ "EntryInfo",
128
+ "FileNotFoundException",
129
+ "FileType",
130
+ "FileUploadException",
131
+ "FilesystemEvent",
132
+ "FilesystemEventType",
133
+ "GitAuthException",
134
+ "GitBranches",
135
+ "GitFileStatus",
136
+ "GitHubMcpServer",
137
+ "GitHubMcpServerConfig",
138
+ "GitResetMode",
139
+ "GitStatus",
140
+ "GitUpstreamException",
141
+ "InvalidArgumentException",
142
+ "LogEntry",
143
+ "LogEntryEnd",
144
+ "LogEntryLevel",
145
+ "LogEntryStart",
146
+ "McpServer",
147
+ "NotEnoughSpaceException",
148
+ "NotFoundException",
149
+ "OutputHandler",
150
+ "ProxyTypes",
151
+ "PtyOutput",
152
+ "PtySize",
153
+ "RateLimitException",
154
+ "ReadyCmd",
155
+ "Sandbox",
156
+ "SandboxException",
157
+ "SandboxInfo",
158
+ "SandboxInfoLifecycle",
159
+ "SandboxLifecycle",
160
+ "SandboxMetrics",
161
+ "SandboxNetworkInfo",
162
+ "SandboxNetworkOpts",
163
+ "SandboxNetworkRule",
164
+ "SandboxNetworkRuleInfo",
165
+ "SandboxNetworkRules",
166
+ "SandboxNetworkSelector",
167
+ "SandboxNetworkSelectorContext",
168
+ "SandboxNetworkTransform",
169
+ "SandboxNetworkUpdate",
170
+ "SandboxNotFoundException",
171
+ "SandboxPaginator",
172
+ "SandboxQuery",
173
+ "SandboxState",
174
+ "SnapshotInfo",
175
+ "SnapshotPaginator",
176
+ "Stderr",
177
+ "Stdout",
178
+ "Template",
179
+ "TemplateBase",
180
+ "TemplateBuildStatus",
181
+ "TemplateBuildStatusResponse",
182
+ "TemplateClass",
183
+ "TemplateException",
184
+ "TemplateTag",
185
+ "TemplateTagInfo",
186
+ "TimeoutException",
187
+ "Username",
188
+ "Volume",
189
+ "VolumeAndToken",
190
+ "VolumeApiParams",
191
+ "VolumeConnectionConfig",
192
+ "VolumeEntryStat",
193
+ "VolumeException",
194
+ "VolumeFileType",
195
+ "VolumeInfo",
196
+ "WatchHandle",
197
+ "WriteInfo",
198
+ "client",
199
+ "default_build_logger",
200
+ "get_signature",
201
+ "wait_for_file",
202
+ "wait_for_port",
203
+ "wait_for_process",
204
+ "wait_for_timeout",
205
+ "wait_for_url",
206
+ ]
@@ -0,0 +1,6 @@
1
+ from .control import ControlClient
2
+ from .data_plane import DataPlaneClient
3
+ from .errors import map_http_error
4
+ from .process_ws import ProcessSocket
5
+
6
+ __all__ = ["ControlClient", "DataPlaneClient", "ProcessSocket", "map_http_error"]
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import requests
6
+
7
+ from watasu.connection_config import ConnectionConfig
8
+ from watasu.exceptions import format_request_timeout_error
9
+ from watasu._transport.errors import map_http_error
10
+
11
+
12
+ class ControlClient:
13
+ def __init__(self, config: ConnectionConfig):
14
+ self.config = config
15
+ self._session = requests.Session()
16
+ self._session.trust_env = False
17
+
18
+ def request(
19
+ self,
20
+ method: str,
21
+ path: str,
22
+ *,
23
+ json: Optional[Dict[str, Any]] = None,
24
+ params: Optional[Dict[str, Any]] = None,
25
+ request_timeout: Optional[float] = None,
26
+ resource: Optional[str] = None,
27
+ ) -> Dict[str, Any]:
28
+ try:
29
+ response = self._session.request(
30
+ method,
31
+ self.config.control_url(path),
32
+ headers=self.config.auth_headers,
33
+ json=json,
34
+ params=params,
35
+ timeout=self.config.get_request_timeout(request_timeout),
36
+ proxies=_requests_proxies(self.config.proxy),
37
+ )
38
+ except requests.Timeout:
39
+ raise format_request_timeout_error()
40
+
41
+ if response.status_code < 400:
42
+ if not response.content:
43
+ return {}
44
+ return response.json()
45
+
46
+ payload: Any
47
+ try:
48
+ payload = response.json()
49
+ except ValueError:
50
+ payload = response.text
51
+ raise map_http_error(response.status_code, payload, response.text, resource=resource)
52
+
53
+ def get(self, path: str, **kwargs: Any) -> Dict[str, Any]:
54
+ return self.request("GET", path, **kwargs)
55
+
56
+ def post(self, path: str, **kwargs: Any) -> Dict[str, Any]:
57
+ return self.request("POST", path, **kwargs)
58
+
59
+ def patch(self, path: str, **kwargs: Any) -> Dict[str, Any]:
60
+ return self.request("PATCH", path, **kwargs)
61
+
62
+ def delete(self, path: str, **kwargs: Any) -> Dict[str, Any]:
63
+ return self.request("DELETE", path, **kwargs)
64
+
65
+
66
+ def _requests_proxies(proxy: Any):
67
+ if proxy is None:
68
+ return None
69
+ if isinstance(proxy, str):
70
+ return {"http": proxy, "https": proxy}
71
+ return proxy
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterator, Optional
4
+
5
+ import requests
6
+
7
+ from watasu.connection_config import ConnectionConfig
8
+ from watasu.exceptions import format_request_timeout_error
9
+ from watasu._transport.errors import map_http_error
10
+
11
+
12
+ class DataPlaneClient:
13
+ def __init__(
14
+ self,
15
+ base_url: str,
16
+ token: str,
17
+ config: ConnectionConfig,
18
+ ) -> None:
19
+ self.base_url = base_url.rstrip("/")
20
+ self.token = token
21
+ self.config = config
22
+ self._session = requests.Session()
23
+ self._session.trust_env = False
24
+
25
+ @property
26
+ def headers(self) -> Dict[str, str]:
27
+ return {"Authorization": f"Bearer {self.token}"}
28
+
29
+ def url(self, path: str) -> str:
30
+ return f"{self.base_url}/{path.lstrip('/')}"
31
+
32
+ def request(
33
+ self,
34
+ method: str,
35
+ path: str,
36
+ *,
37
+ params: Optional[Dict[str, Any]] = None,
38
+ json: Optional[Dict[str, Any]] = None,
39
+ data: Optional[bytes] = None,
40
+ request_timeout: Optional[float] = None,
41
+ resource: Optional[str] = None,
42
+ stream: bool = False,
43
+ ) -> requests.Response:
44
+ try:
45
+ response = self._session.request(
46
+ method,
47
+ self.url(path),
48
+ headers=self.headers,
49
+ params=params,
50
+ json=json,
51
+ data=data,
52
+ timeout=self.config.get_request_timeout(request_timeout),
53
+ proxies=_requests_proxies(self.config.proxy),
54
+ stream=stream,
55
+ )
56
+ except requests.Timeout:
57
+ raise format_request_timeout_error()
58
+
59
+ if response.status_code < 400:
60
+ return response
61
+
62
+ try:
63
+ payload: Any = response.json()
64
+ except ValueError:
65
+ payload = response.text
66
+ raise map_http_error(response.status_code, payload, response.text, resource=resource)
67
+
68
+ def get_json(self, path: str, **kwargs: Any) -> Dict[str, Any]:
69
+ return self.request("GET", path, **kwargs).json()
70
+
71
+ def post_json(self, path: str, **kwargs: Any) -> Dict[str, Any]:
72
+ return self.request("POST", path, **kwargs).json()
73
+
74
+ def put_json(self, path: str, **kwargs: Any) -> Dict[str, Any]:
75
+ return self.request("PUT", path, **kwargs).json()
76
+
77
+ def delete_json(self, path: str, **kwargs: Any) -> Dict[str, Any]:
78
+ return self.request("DELETE", path, **kwargs).json()
79
+
80
+ def get_bytes(self, path: str, **kwargs: Any) -> bytes:
81
+ return self.request("GET", path, **kwargs).content
82
+
83
+ def iter_bytes(self, path: str, chunk_size: int = 65_536, **kwargs: Any) -> Iterator[bytes]:
84
+ response = self.request("GET", path, stream=True, **kwargs)
85
+ return response.iter_content(chunk_size=chunk_size)
86
+
87
+
88
+ def _requests_proxies(proxy: Any):
89
+ if proxy is None:
90
+ return None
91
+ if isinstance(proxy, str):
92
+ return {"http": proxy, "https": proxy}
93
+ return proxy
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+ from watasu.exceptions import (
6
+ AuthenticationException,
7
+ FileNotFoundException,
8
+ InvalidArgumentException,
9
+ NotEnoughSpaceException,
10
+ NotFoundException,
11
+ RateLimitException,
12
+ SandboxException,
13
+ SandboxNotFoundException,
14
+ TimeoutException,
15
+ )
16
+
17
+
18
+ def error_message(payload: Any, fallback: str) -> str:
19
+ if isinstance(payload, Mapping):
20
+ errors = payload.get("errors")
21
+ if isinstance(errors, list) and errors:
22
+ return "; ".join(str(item) for item in errors)
23
+ for key in ("message", "reason", "error"):
24
+ value = payload.get(key)
25
+ if value:
26
+ return str(value)
27
+ return fallback
28
+
29
+
30
+ def map_http_error(
31
+ status_code: int,
32
+ payload: Any,
33
+ fallback: str,
34
+ *,
35
+ resource: Optional[str] = None,
36
+ ) -> Exception:
37
+ message = error_message(payload, fallback)
38
+
39
+ if status_code in {401, 403}:
40
+ return AuthenticationException(message)
41
+ if status_code == 404:
42
+ if resource in {"file", "directory"}:
43
+ return FileNotFoundException(message)
44
+ if resource == "sandbox":
45
+ return SandboxNotFoundException(message)
46
+ return NotFoundException(message)
47
+ if status_code in {408, 504}:
48
+ return TimeoutException(message)
49
+ if status_code == 429:
50
+ return RateLimitException(message)
51
+ if status_code == 507:
52
+ return NotEnoughSpaceException(message)
53
+ if status_code in {400, 409, 413, 422}:
54
+ return InvalidArgumentException(message)
55
+ return SandboxException(message)
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import queue
6
+ import threading
7
+ import time
8
+ from typing import Any, Dict, Iterable, Iterator, Optional, Union
9
+
10
+ from watasu.connection_config import KEEPALIVE_PING_INTERVAL_SEC
11
+ from watasu.exceptions import SandboxException, format_request_timeout_error
12
+
13
+
14
+ class ProcessSocket:
15
+ def __init__(
16
+ self,
17
+ base_url: str,
18
+ token: str,
19
+ path: str,
20
+ *,
21
+ keepalive_interval: float = KEEPALIVE_PING_INTERVAL_SEC,
22
+ request_timeout: Optional[float] = None,
23
+ ) -> None:
24
+ self.base_url = base_url.rstrip("/")
25
+ self.token = token
26
+ self.path = path
27
+ self.keepalive_interval = keepalive_interval
28
+ self.request_timeout = request_timeout
29
+ self._ws = None
30
+ self._closed = False
31
+
32
+ def connect(self) -> "ProcessSocket":
33
+ try:
34
+ import websocket
35
+ except ImportError as error:
36
+ raise SandboxException(
37
+ "The 'websocket-client' package is required for command streaming."
38
+ ) from error
39
+
40
+ ws_url = _ws_url(self.base_url, self.path)
41
+ self._ws = websocket.create_connection(
42
+ ws_url,
43
+ header=[f"Authorization: Bearer {self.token}"],
44
+ timeout=self.request_timeout,
45
+ )
46
+ return self
47
+
48
+ def send_json(self, payload: Dict[str, Any]) -> None:
49
+ self._require_open().send(json.dumps(payload))
50
+
51
+ def send_stdin(self, data: Union[str, bytes]) -> None:
52
+ if isinstance(data, bytes):
53
+ raw = data
54
+ else:
55
+ raw = data.encode("utf-8")
56
+ self.send_json({"type": "stdin", "data": base64.b64encode(raw).decode("ascii")})
57
+
58
+ def send_signal(self, signal: str = "SIGKILL") -> None:
59
+ self.send_json({"type": "signal", "signal": signal})
60
+
61
+ def close(self) -> None:
62
+ self._closed = True
63
+ if self._ws is not None:
64
+ self._ws.close()
65
+
66
+ def frames(self, timeout: Optional[float] = None) -> Iterator[Dict[str, Any]]:
67
+ ws = self._require_open()
68
+ deadline = None if timeout in (None, 0) else time.monotonic() + float(timeout)
69
+ next_ping = time.monotonic() + self.keepalive_interval
70
+
71
+ while not self._closed:
72
+ now = time.monotonic()
73
+ if now >= next_ping:
74
+ try:
75
+ ws.ping("watasu-sdk")
76
+ except Exception as error:
77
+ raise SandboxException(f"process websocket ping failed: {error}") from error
78
+ next_ping = now + self.keepalive_interval
79
+
80
+ socket_timeout = min(1.0, max(0.1, next_ping - now))
81
+ if deadline is not None:
82
+ remaining = deadline - now
83
+ if remaining <= 0:
84
+ raise format_request_timeout_error()
85
+ socket_timeout = min(socket_timeout, remaining)
86
+
87
+ ws.settimeout(socket_timeout)
88
+ try:
89
+ message = ws.recv()
90
+ except TimeoutError:
91
+ continue
92
+ except Exception as error:
93
+ if _is_timeout_error(error):
94
+ continue
95
+ if self._closed:
96
+ return
97
+ raise SandboxException(f"process websocket failed: {error}") from error
98
+
99
+ if message is None:
100
+ return
101
+ if isinstance(message, bytes):
102
+ raise SandboxException("process websocket returned binary frame")
103
+ try:
104
+ frame = json.loads(message)
105
+ except json.JSONDecodeError as error:
106
+ raise SandboxException(f"process websocket returned invalid JSON: {message}") from error
107
+ if frame.get("type") in {"ready", "pong"}:
108
+ continue
109
+ if frame.get("type") == "error":
110
+ raise SandboxException(frame.get("message") or frame.get("code") or "process error")
111
+ yield frame
112
+
113
+ def _require_open(self):
114
+ if self._ws is None:
115
+ raise SandboxException("process websocket is not connected")
116
+ return self._ws
117
+
118
+
119
+ class ProcessEventStream:
120
+ def __init__(self, socket: ProcessSocket, frames: Iterable[Dict[str, Any]]) -> None:
121
+ self.socket = socket
122
+ self._frames = iter(frames)
123
+
124
+ def __iter__(self) -> "ProcessEventStream":
125
+ return self
126
+
127
+ def __next__(self) -> Dict[str, Any]:
128
+ return next(self._frames)
129
+
130
+ def close(self) -> None:
131
+ self.socket.close()
132
+
133
+
134
+ class QueuedProcessEventStream:
135
+ def __init__(self, socket: ProcessSocket, first_frame: Dict[str, Any], frames: Iterable[Dict[str, Any]]) -> None:
136
+ self.socket = socket
137
+ self._queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue()
138
+ self._queue.put(first_frame)
139
+ self._closed = False
140
+
141
+ def pump() -> None:
142
+ try:
143
+ for frame in frames:
144
+ self._queue.put(frame)
145
+ finally:
146
+ self._queue.put(None)
147
+
148
+ self._thread = threading.Thread(target=pump, daemon=True)
149
+ self._thread.start()
150
+
151
+ def __iter__(self) -> "QueuedProcessEventStream":
152
+ return self
153
+
154
+ def __next__(self) -> Dict[str, Any]:
155
+ item = self._queue.get()
156
+ if item is None:
157
+ raise StopIteration
158
+ return item
159
+
160
+ def close(self) -> None:
161
+ self._closed = True
162
+ self.socket.close()
163
+
164
+
165
+ def _ws_url(base_url: str, path: str) -> str:
166
+ if base_url.startswith("https://"):
167
+ prefix = "wss://"
168
+ rest = base_url[len("https://") :]
169
+ elif base_url.startswith("http://"):
170
+ prefix = "ws://"
171
+ rest = base_url[len("http://") :]
172
+ else:
173
+ prefix = "wss://"
174
+ rest = base_url
175
+ return f"{prefix}{rest.rstrip('/')}/{path.lstrip('/')}"
176
+
177
+
178
+ def _is_timeout_error(error: Exception) -> bool:
179
+ name = error.__class__.__name__.lower()
180
+ return "timeout" in name
watasu/api/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from watasu._transport.control import ControlClient
6
+ from watasu.connection_config import ConnectionConfig
7
+
8
+
9
+ class ApiClient(ControlClient):
10
+ def __init__(self, **opts: Any):
11
+ super().__init__(ConnectionConfig(**opts))
12
+
13
+
14
+ def client(**opts: Any) -> ApiClient:
15
+ return ApiClient(**opts)
16
+
17
+
18
+ __all__ = ["ApiClient", "client"]