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 +206 -0
- watasu/_transport/__init__.py +6 -0
- watasu/_transport/control.py +71 -0
- watasu/_transport/data_plane.py +93 -0
- watasu/_transport/errors.py +55 -0
- watasu/_transport/process_ws.py +180 -0
- watasu/api/__init__.py +18 -0
- watasu/connection_config.py +102 -0
- watasu/exceptions.py +70 -0
- watasu/py.typed +1 -0
- watasu/sandbox/__init__.py +1 -0
- watasu/sandbox/commands/__init__.py +12 -0
- watasu/sandbox/commands/command_handle.py +28 -0
- watasu/sandbox/commands/main.py +12 -0
- watasu/sandbox/filesystem/__init__.py +3 -0
- watasu/sandbox/filesystem/filesystem.py +87 -0
- watasu/sandbox/sandbox_api.py +83 -0
- watasu/sandbox_async/__init__.py +3 -0
- watasu/sandbox_async/main.py +3 -0
- watasu/sandbox_sync/__init__.py +3 -0
- watasu/sandbox_sync/commands/__init__.py +4 -0
- watasu/sandbox_sync/commands/command.py +194 -0
- watasu/sandbox_sync/commands/command_handle.py +137 -0
- watasu/sandbox_sync/filesystem/__init__.py +4 -0
- watasu/sandbox_sync/filesystem/filesystem.py +160 -0
- watasu/sandbox_sync/filesystem/watch_handle.py +3 -0
- watasu/sandbox_sync/main.py +308 -0
- watasu/sandbox_sync/paginator.py +20 -0
- watasu/stubs.py +108 -0
- watasu-0.1.1.dist-info/METADATA +71 -0
- watasu-0.1.1.dist-info/RECORD +35 -0
- watasu-0.1.1.dist-info/WHEEL +4 -0
- watasu-0.1.1.dist-info/licenses/LICENSE +14 -0
- watasu-0.1.1.dist-info/licenses/LICENSE-APACHE +176 -0
- watasu-0.1.1.dist-info/licenses/LICENSE-MIT +21 -0
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,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"]
|