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 +106 -0
- createos/_streams.py +177 -0
- createos/_transport.py +297 -0
- createos/_version.py +1 -0
- createos/client.py +224 -0
- createos/errors.py +76 -0
- createos/instance.py +415 -0
- createos/models.py +833 -0
- createos/py.typed +1 -0
- createos/self.py +27 -0
- createos/services.py +836 -0
- createos_sandbox-0.1.0.dist-info/METADATA +384 -0
- createos_sandbox-0.1.0.dist-info/RECORD +15 -0
- createos_sandbox-0.1.0.dist-info/WHEEL +4 -0
- createos_sandbox-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|