createos-sandbox 0.1.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.
createos/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """Python client for CreateOS cloud sandboxes."""
2
+
3
+ from ._version import __version__
4
+ from .client import Client, CreateOS
5
+ from .errors import (
6
+ APIError,
7
+ AuthenticationError,
8
+ CommandError,
9
+ CreateOSError,
10
+ OperationTimeout,
11
+ ProtocolError,
12
+ )
13
+ from .instance import Instance, SandboxInstance
14
+ from .models import (
15
+ AttachDiskOptions,
16
+ BandwidthView,
17
+ CommandResult,
18
+ CommandStreamEvent,
19
+ ComputerButtonRequest,
20
+ ComputerClickRequest,
21
+ ComputerClipboard,
22
+ ComputerCreateScreenRequest,
23
+ ComputerDragRequest,
24
+ ComputerLaunchRequest,
25
+ ComputerListWindowsOptions,
26
+ ComputerMouseButton,
27
+ ComputerOpenRequest,
28
+ ComputerPoint,
29
+ ComputerScreen,
30
+ ComputerScreenConnection,
31
+ ComputerScreenGeometry,
32
+ ComputerScreenID,
33
+ ComputerScreenOptions,
34
+ ComputerScreenshotOptions,
35
+ ComputerScrollDirection,
36
+ ComputerScrollRequest,
37
+ ComputerTypeRequest,
38
+ ComputerWindow,
39
+ ComputerWindowGeometry,
40
+ ComputerWindowMoveRequest,
41
+ ComputerWindowResizeRequest,
42
+ CreateSandboxRequest,
43
+ CreateSandboxResponse,
44
+ DestroyedResponse,
45
+ DetachDiskOptions,
46
+ Disk,
47
+ DiskAttachment,
48
+ DiskConfig,
49
+ DiskCreateRequest,
50
+ DiskCredentials,
51
+ DiskKind,
52
+ DiskMountStatus,
53
+ EgressView,
54
+ ExecOptions,
55
+ ExecStreamEventType,
56
+ ForkSandboxRequest,
57
+ GetTemplateOptions,
58
+ Health,
59
+ HostPublic,
60
+ HostStatus,
61
+ ListSandboxesOptions,
62
+ ManagedProcess,
63
+ ManagedProcessConnectEvent,
64
+ ManagedProcessConnectEventType,
65
+ ManagedProcessConnectOptions,
66
+ ManagedProcessCreateRequest,
67
+ ManagedProcessDeleteOptions,
68
+ ManagedProcessForeground,
69
+ ManagedProcessKind,
70
+ ManagedProcessOutputWindow,
71
+ ManagedProcessSignal,
72
+ ManagedProcessState,
73
+ ManagedProcessStream,
74
+ ManagedProcessWaitOptions,
75
+ ManagedProcessWaitScope,
76
+ Model,
77
+ Network,
78
+ NetworkCreateRequest,
79
+ NetworkEntry,
80
+ NetworkMember,
81
+ PaginationOptions,
82
+ PTYSize,
83
+ Readiness,
84
+ RequestOptions,
85
+ ResizeSandboxResponse,
86
+ RetryOptions,
87
+ RootFSData,
88
+ RootFSEntry,
89
+ RunCommandRequest,
90
+ RunCommandResponse,
91
+ Sandbox,
92
+ SandboxDisk,
93
+ SandboxStatus,
94
+ Shape,
95
+ StrEnum,
96
+ Template,
97
+ TemplateCreateRequest,
98
+ TemplateInclude,
99
+ TemplateLogEvent,
100
+ TemplateLogsOptions,
101
+ TemplateStatus,
102
+ WaitOptions,
103
+ WhoAmI,
104
+ WhoAmIStats,
105
+ )
106
+ from .self import self_delete, self_pause
createos/_streams.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from collections import deque
6
+ from collections.abc import Iterator
7
+ from typing import Any, Generic, TypeVar
8
+
9
+ import httpx
10
+
11
+ from .errors import ProtocolError
12
+ from .models import (
13
+ CommandStreamEvent,
14
+ ExecStreamEventType,
15
+ ManagedProcessConnectEvent,
16
+ ManagedProcessConnectEventType,
17
+ ManagedProcessStream,
18
+ Model,
19
+ TemplateLogEvent,
20
+ )
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ class BinaryStream:
26
+ """A closeable, context-managed streaming HTTP response."""
27
+
28
+ def __init__(self, response: httpx.Response) -> None:
29
+ self._response = response
30
+
31
+ @property
32
+ def headers(self) -> httpx.Headers:
33
+ return self._response.headers
34
+
35
+ def read(self) -> bytes:
36
+ return self._response.read()
37
+
38
+ def iter_bytes(self, chunk_size: int | None = None):
39
+ return self._response.iter_bytes(chunk_size)
40
+
41
+ def close(self) -> None:
42
+ self._response.close()
43
+
44
+ def __enter__(self):
45
+ return self
46
+
47
+ def __exit__(self, *_: object) -> None:
48
+ self.close()
49
+
50
+
51
+ class NDJSONStream(Generic[T], Iterator[T]):
52
+ model: type[Model]
53
+
54
+ def __init__(self, response: httpx.Response) -> None:
55
+ self._response = response
56
+ self._lines = response.iter_lines()
57
+ self._closed = False
58
+
59
+ def __iter__(self):
60
+ return self
61
+
62
+ def __next__(self) -> T:
63
+ return self.decode(self._next_payload())
64
+
65
+ def _next_payload(self) -> dict[str, Any]:
66
+ while True:
67
+ try:
68
+ line = next(self._lines).strip()
69
+ except StopIteration:
70
+ self.close()
71
+ raise
72
+ # Accept both raw NDJSON and the control lines used by SSE streams.
73
+ if not line or line.startswith((":", "event:", "id:", "retry:")):
74
+ continue
75
+ if line.startswith("data:"):
76
+ line = line[5:].strip()
77
+ if not line:
78
+ continue
79
+ try:
80
+ data = json.loads(line)
81
+ except json.JSONDecodeError as exc:
82
+ self.close()
83
+ raise ProtocolError(f"decode NDJSON event: {exc}") from exc
84
+ if not isinstance(data, dict):
85
+ self.close()
86
+ raise ProtocolError(
87
+ "decode NDJSON event: expected a JSON object"
88
+ )
89
+ return data
90
+
91
+ def decode(self, data: dict[str, Any]) -> T:
92
+ return self.model.from_dict(data) # type: ignore[return-value]
93
+
94
+ def receive(self) -> T:
95
+ return next(self)
96
+
97
+ recv = receive
98
+
99
+ def close(self) -> None:
100
+ if not self._closed:
101
+ self._closed = True
102
+ self._response.close()
103
+
104
+ def __enter__(self):
105
+ return self
106
+
107
+ def __exit__(self, *_: object) -> None:
108
+ self.close()
109
+
110
+
111
+ class CommandStream(NDJSONStream[CommandStreamEvent]):
112
+ model = CommandStreamEvent
113
+
114
+ def __init__(self, response: httpx.Response) -> None:
115
+ super().__init__(response)
116
+ self._queued: deque[CommandStreamEvent] = deque()
117
+
118
+ def __next__(self) -> CommandStreamEvent:
119
+ while not self._queued:
120
+ data = self._next_payload()
121
+ # One wire frame may contain output, an error, and an exit status.
122
+ if data.get("hb"):
123
+ self._queued.append(
124
+ CommandStreamEvent(type=ExecStreamEventType.HEARTBEAT)
125
+ )
126
+ if data.get("stdout"):
127
+ self._queued.append(
128
+ CommandStreamEvent(
129
+ type=ExecStreamEventType.STDOUT, data=data["stdout"]
130
+ )
131
+ )
132
+ if data.get("stderr"):
133
+ self._queued.append(
134
+ CommandStreamEvent(
135
+ type=ExecStreamEventType.STDERR, data=data["stderr"]
136
+ )
137
+ )
138
+ if data.get("error"):
139
+ self._queued.append(
140
+ CommandStreamEvent(
141
+ type=ExecStreamEventType.ERROR,
142
+ error_message=data["error"],
143
+ )
144
+ )
145
+ if data.get("exit_code") is not None:
146
+ self._queued.append(
147
+ CommandStreamEvent(
148
+ type=ExecStreamEventType.EXIT,
149
+ exit_code=data["exit_code"],
150
+ )
151
+ )
152
+ return self._queued.popleft()
153
+
154
+
155
+ class ProcessStream(NDJSONStream[ManagedProcessConnectEvent]):
156
+ def decode(self, data: dict[str, Any]) -> ManagedProcessConnectEvent:
157
+ raw = data.get("data_base64", "")
158
+ try:
159
+ decoded = base64.b64decode(raw, validate=True) if raw else b""
160
+ except ValueError as exc:
161
+ raise ProtocolError(f"decode process output: {exc}") from exc
162
+ return ManagedProcessConnectEvent(
163
+ type=ManagedProcessConnectEventType(data["type"]),
164
+ sequence=data.get("seq", 0),
165
+ stream=ManagedProcessStream(data["stream"])
166
+ if data.get("stream")
167
+ else None,
168
+ data=decoded,
169
+ exit_code=data.get("exit_code"),
170
+ signal=data.get("signal"),
171
+ error_message=data.get("error", ""),
172
+ oldest_available_sequence=data.get("oldest_available_seq", 0),
173
+ )
174
+
175
+
176
+ class TemplateLogStream(NDJSONStream[TemplateLogEvent]):
177
+ model = TemplateLogEvent
createos/_transport.py ADDED
@@ -0,0 +1,297 @@
1
+ from __future__ import annotations
2
+
3
+ import email.utils
4
+ import json
5
+ import random
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+ from urllib.parse import urljoin, urlparse
11
+
12
+ import httpx
13
+
14
+ from .errors import APIError, AuthenticationError, ProtocolError
15
+ from .models import Model, RequestOptions, RetryOptions
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class RawResponse:
20
+ response: httpx.Response
21
+
22
+ def close(self) -> None:
23
+ self.response.close()
24
+
25
+ def __enter__(self):
26
+ return self
27
+
28
+ def __exit__(self, *_: object) -> None:
29
+ self.close()
30
+
31
+
32
+ class Transport:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ base_url: str,
37
+ api_key: str,
38
+ timeout: float,
39
+ user_agent: str,
40
+ retry: RetryOptions,
41
+ http_client: httpx.Client | None = None,
42
+ ) -> None:
43
+ parsed = urlparse(base_url.strip())
44
+ if parsed.scheme not in {"http", "https"}:
45
+ raise ValueError("base URL scheme must be http or https")
46
+ if not parsed.netloc:
47
+ raise ValueError("base URL must include a host")
48
+ if parsed.username or parsed.password:
49
+ raise ValueError("base URL must not contain user information")
50
+ if parsed.query or parsed.fragment:
51
+ raise ValueError(
52
+ "base URL must not include a query string or fragment"
53
+ )
54
+ if timeout <= 0:
55
+ raise ValueError("timeout must be positive")
56
+ _validate_retry(retry)
57
+ self.base_url = base_url.rstrip("/")
58
+ self._origin = _origin(self.base_url)
59
+ self.api_key = api_key.strip()
60
+ self.timeout = timeout
61
+ self.user_agent = user_agent
62
+ self.retry = retry
63
+ self._owns_client = http_client is None
64
+ self.client = http_client or httpx.Client()
65
+
66
+ def close(self) -> None:
67
+ if self._owns_client:
68
+ self.client.close()
69
+
70
+ def request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ *,
75
+ body: Any = None,
76
+ params: dict[str, Any] | None = None,
77
+ options: RequestOptions | None = None,
78
+ skip_auth: bool = False,
79
+ ) -> Any:
80
+ response = self.request_raw(
81
+ method,
82
+ path,
83
+ body=body,
84
+ params=params,
85
+ options=options,
86
+ skip_auth=skip_auth,
87
+ )
88
+ try:
89
+ self._raise_for_status(response, method, path)
90
+ if not response.content and response.status_code in {204, 205}:
91
+ return None
92
+ try:
93
+ envelope = response.json()
94
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
95
+ raise ProtocolError(
96
+ f"decode {method.upper()} {path} response: {exc}"
97
+ ) from exc
98
+ if not isinstance(envelope, dict) or "status" not in envelope:
99
+ raise ProtocolError(
100
+ f"decode {method.upper()} {path} response: "
101
+ "invalid JSend envelope"
102
+ )
103
+ if envelope["status"] != "success":
104
+ message = envelope.get("message") or "unexpected JSend status"
105
+ raise ProtocolError(str(message))
106
+ return envelope.get("data")
107
+ finally:
108
+ response.close()
109
+
110
+ def request_raw(
111
+ self,
112
+ method: str,
113
+ path: str,
114
+ *,
115
+ body: Any = None,
116
+ raw_body: Any = None,
117
+ content_type: str | None = None,
118
+ params: dict[str, Any] | None = None,
119
+ options: RequestOptions | None = None,
120
+ skip_auth: bool = False,
121
+ stream: bool = False,
122
+ ) -> httpx.Response:
123
+ options = options or RequestOptions()
124
+ retry = options.retry or self.retry
125
+ _validate_retry(retry)
126
+ attempts = (
127
+ 0
128
+ if options.disable_retry or raw_body is not None or stream
129
+ else retry.max_retries
130
+ )
131
+ url = self._url(path)
132
+ headers = {
133
+ key: value
134
+ for key, value in options.headers.items()
135
+ if key.lower() not in _SENSITIVE
136
+ }
137
+ headers.setdefault("Accept", "application/json")
138
+ headers.setdefault("User-Agent", self.user_agent)
139
+ if not skip_auth:
140
+ if not self.api_key:
141
+ raise AuthenticationError(
142
+ "authentication is required: configure an API key"
143
+ )
144
+ headers["X-Api-Key"] = self.api_key
145
+ content = None
146
+ json_body = None
147
+ if body is not None and raw_body is not None:
148
+ raise ValueError("request cannot have both body and raw_body")
149
+ if body is not None:
150
+ json_body = _serialize(body)
151
+ elif raw_body is not None:
152
+ content = raw_body
153
+ if content_type:
154
+ headers["Content-Type"] = content_type
155
+ for attempt in range(attempts + 1):
156
+ try:
157
+ request = self.client.build_request(
158
+ method.upper(),
159
+ url,
160
+ params=params,
161
+ headers=headers,
162
+ json=json_body,
163
+ content=content,
164
+ )
165
+ request_timeout = (
166
+ options.timeout
167
+ if options.timeout is not None
168
+ else self.timeout
169
+ )
170
+ request.extensions["timeout"] = {
171
+ "connect": request_timeout,
172
+ "read": request_timeout,
173
+ "write": request_timeout,
174
+ "pool": request_timeout,
175
+ }
176
+ response = self.client.send(
177
+ request, stream=stream, follow_redirects=False
178
+ )
179
+ except (httpx.TransportError, httpx.TimeoutException):
180
+ if attempt >= attempts or not _idempotent(method):
181
+ raise
182
+ time.sleep(_backoff(attempt, retry))
183
+ continue
184
+ if 300 <= response.status_code < 400 and response.headers.get(
185
+ "location"
186
+ ):
187
+ destination = urljoin(
188
+ str(response.url), response.headers["location"]
189
+ )
190
+ response.close()
191
+ if _origin(destination) != self._origin:
192
+ raise ProtocolError(
193
+ "refusing redirect to non-base origin "
194
+ f"{_origin(destination)!r}"
195
+ )
196
+ raise ProtocolError("unexpected redirect response")
197
+ if attempt >= attempts or not _retryable(
198
+ method, response.status_code
199
+ ):
200
+ return response
201
+ delay = _retry_after(response.headers.get("retry-after"))
202
+ response.close()
203
+ time.sleep(delay if delay is not None else _backoff(attempt, retry))
204
+ raise AssertionError("unreachable")
205
+
206
+ def stream(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
207
+ response = self.request_raw(method, path, stream=True, **kwargs)
208
+ self._raise_for_status(response, method, path)
209
+ return response
210
+
211
+ def _url(self, path: str) -> str:
212
+ # Keep endpoints relative to the configured base path, even when the
213
+ # endpoint has a leading slash.
214
+ url = f"{self.base_url}/{path.lstrip('/')}"
215
+ if _origin(url) != self._origin:
216
+ raise ValueError("request URL must use the configured base origin")
217
+ return url
218
+
219
+ @staticmethod
220
+ def _raise_for_status(
221
+ response: httpx.Response, method: str, path: str
222
+ ) -> None:
223
+ if 200 <= response.status_code < 300:
224
+ return
225
+ body = response.read()[: 4 << 20]
226
+ error = APIError(
227
+ status_code=response.status_code,
228
+ method=method,
229
+ endpoint=path,
230
+ body=body,
231
+ headers=response.headers,
232
+ )
233
+ response.close()
234
+ raise error
235
+
236
+
237
+ _SENSITIVE = {
238
+ "authorization",
239
+ "proxy-authorization",
240
+ "x-api-key",
241
+ "x-auth-token",
242
+ "cookie",
243
+ "set-cookie",
244
+ }
245
+
246
+
247
+ def _serialize(value: Any) -> Any:
248
+ if isinstance(value, Model):
249
+ return value.to_dict()
250
+ if hasattr(value, "value"):
251
+ return value.value
252
+ return value
253
+
254
+
255
+ def _origin(url: str) -> str:
256
+ parsed = urlparse(url)
257
+ return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
258
+
259
+
260
+ def _idempotent(method: str) -> bool:
261
+ return method.upper() in {"GET", "HEAD", "PUT", "DELETE"}
262
+
263
+
264
+ def _retryable(method: str, status: int) -> bool:
265
+ return status in {429, 503} or (
266
+ _idempotent(method) and status in {408, 500, 502, 504}
267
+ )
268
+
269
+
270
+ def _validate_retry(retry: RetryOptions) -> None:
271
+ if (
272
+ retry.max_retries < 0
273
+ or retry.base_delay <= 0
274
+ or retry.max_delay < retry.base_delay
275
+ ):
276
+ raise ValueError("retry configuration is invalid")
277
+
278
+
279
+ def _backoff(attempt: int, retry: RetryOptions) -> float:
280
+ return min(
281
+ retry.base_delay * (2 ** min(attempt, 30))
282
+ + random.random() * retry.base_delay,
283
+ retry.max_delay,
284
+ )
285
+
286
+
287
+ def _retry_after(value: str | None) -> float | None:
288
+ if not value:
289
+ return None
290
+ try:
291
+ return max(0.0, float(value.strip()))
292
+ except ValueError:
293
+ try:
294
+ date = email.utils.parsedate_to_datetime(value)
295
+ return max(0.0, (date - datetime.now(timezone.utc)).total_seconds())
296
+ except (TypeError, ValueError):
297
+ return None
createos/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"