tyto.run 0.11.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.
tyto/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ from .client import Tyto
2
+ from .config import TytoConfig
3
+ from .errors import TytoAPIError, TytoError
4
+ from .models import (
5
+ AuthPollResponse,
6
+ AuthStartResponse,
7
+ DeleteSnapshotResponse,
8
+ ForkResponse,
9
+ ForkStorage,
10
+ KeepaliveHoldData,
11
+ NestData,
12
+ NestLifecycle,
13
+ PreviewData,
14
+ ReadFileResult,
15
+ RestoreResponse,
16
+ SessionData,
17
+ SnapshotData,
18
+ SnapshotList,
19
+ User,
20
+ WakeResponse,
21
+ )
22
+ from .resources.auth import AuthResource
23
+ from .resources.files import FileSystem
24
+ from .resources.holds import HoldsResource
25
+ from .resources.nests import Nest, NestsResource
26
+ from .resources.previews import PreviewsResource, TopLevelPreviewsResource
27
+ from .resources.sessions import Session, SessionsResource
28
+ from .resources.snapshots import SnapshotsResource, TopLevelSnapshotsResource
29
+
30
+ __all__ = [
31
+ "Tyto",
32
+ "TytoConfig",
33
+ "TytoError",
34
+ "TytoAPIError",
35
+ "User",
36
+ "AuthStartResponse",
37
+ "AuthPollResponse",
38
+ "NestData",
39
+ "NestLifecycle",
40
+ "WakeResponse",
41
+ "SessionData",
42
+ "PreviewData",
43
+ "SnapshotData",
44
+ "SnapshotList",
45
+ "RestoreResponse",
46
+ "ForkResponse",
47
+ "ForkStorage",
48
+ "DeleteSnapshotResponse",
49
+ "KeepaliveHoldData",
50
+ "ReadFileResult",
51
+ "Nest",
52
+ "NestsResource",
53
+ "Session",
54
+ "SessionsResource",
55
+ "FileSystem",
56
+ "PreviewsResource",
57
+ "TopLevelPreviewsResource",
58
+ "SnapshotsResource",
59
+ "TopLevelSnapshotsResource",
60
+ "HoldsResource",
61
+ "AuthResource",
62
+ ]
tyto/_http.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import httpx
6
+
7
+ from .config import TytoConfig
8
+ from .errors import TytoAPIError
9
+
10
+
11
+ class HttpClient:
12
+ def __init__(self, config: TytoConfig) -> None:
13
+ self._config = config
14
+ self._client = httpx.Client(
15
+ base_url=config.api_url,
16
+ headers={"Authorization": f"Bearer {config.api_key}"},
17
+ timeout=30.0,
18
+ )
19
+
20
+ def _raise_for_status(self, response: httpx.Response) -> None:
21
+ if response.is_success:
22
+ return
23
+ try:
24
+ body = response.json()
25
+ code = body.get("error")
26
+ message = body.get("message") or f"HTTP {response.status_code}"
27
+ except Exception:
28
+ code = None
29
+ message = f"HTTP {response.status_code}"
30
+ raise TytoAPIError(response.status_code, code, message)
31
+
32
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
33
+ resp = self._client.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
34
+ self._raise_for_status(resp)
35
+ if resp.status_code == 204:
36
+ return None
37
+ return resp.json()
38
+
39
+ def post(self, path: str, json: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
40
+ resp = self._client.post(path, json=json, params={k: v for k, v in (params or {}).items() if v is not None})
41
+ self._raise_for_status(resp)
42
+ if resp.status_code == 204:
43
+ return None
44
+ return resp.json()
45
+
46
+ def put(self, path: str, json: Any = None) -> Any:
47
+ resp = self._client.put(path, json=json)
48
+ self._raise_for_status(resp)
49
+ if resp.status_code == 204:
50
+ return None
51
+ return resp.json()
52
+
53
+ def delete(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
54
+ resp = self._client.delete(path, params={k: v for k, v in (params or {}).items() if v is not None})
55
+ self._raise_for_status(resp)
56
+ if resp.status_code == 204:
57
+ return None
58
+ return resp.json()
59
+
60
+ def put_binary(self, path: str, data: bytes, content_type: str, params: Dict[str, Any]) -> None:
61
+ resp = self._client.put(
62
+ path,
63
+ content=data,
64
+ headers={"Content-Type": content_type},
65
+ params={k: v for k, v in params.items() if v is not None},
66
+ )
67
+ self._raise_for_status(resp)
68
+
69
+ def get_binary(self, path: str, params: Optional[Dict[str, Any]] = None) -> tuple[bytes, str]:
70
+ resp = self._client.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
71
+ self._raise_for_status(resp)
72
+ kind = resp.headers.get("X-Tyto-FS-Kind", "file")
73
+ return resp.content, kind
74
+
75
+ def close(self) -> None:
76
+ self._client.close()
77
+
78
+ def __enter__(self) -> "HttpClient":
79
+ return self
80
+
81
+ def __exit__(self, *args: Any) -> None:
82
+ self.close()
tyto/client.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ._http import HttpClient
6
+ from .config import TytoConfig, resolve_config
7
+ from .models import User
8
+ from .resources.auth import AuthResource
9
+ from .resources.nests import Nest, NestsResource
10
+ from .resources.previews import TopLevelPreviewsResource
11
+ from .resources.snapshots import TopLevelSnapshotsResource
12
+
13
+
14
+ class Tyto:
15
+ def __init__(
16
+ self,
17
+ api_key: Optional[str] = None,
18
+ api_url: Optional[str] = None,
19
+ ) -> None:
20
+ self._config = resolve_config(api_key=api_key, api_url=api_url)
21
+ self._http = HttpClient(self._config)
22
+ self.auth = AuthResource(self._http)
23
+ self.nests = NestsResource(self._http, self._config)
24
+ self.previews = TopLevelPreviewsResource(self._http)
25
+ self.snapshots = TopLevelSnapshotsResource(self._http)
26
+
27
+ def create(
28
+ self,
29
+ name: str,
30
+ template: str = "ubuntu-24-dev",
31
+ repo_url: Optional[str] = None,
32
+ ) -> Nest:
33
+ return self.nests.create(name=name, template=template, repo_url=repo_url)
34
+
35
+ def health(self) -> dict:
36
+ return self._http.get("/healthz")
37
+
38
+ def ready(self) -> dict:
39
+ return self._http.get("/readyz")
40
+
41
+ def me(self) -> User:
42
+ return User.from_dict(self._http.get("/me"))
43
+
44
+ def close(self) -> None:
45
+ self._http.close()
46
+
47
+ def __enter__(self) -> "Tyto":
48
+ return self
49
+
50
+ def __exit__(self, *args) -> None:
51
+ self.close()
tyto/config.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from .errors import TytoError
7
+
8
+
9
+ @dataclass
10
+ class TytoConfig:
11
+ api_key: str = ""
12
+ api_url: str = "https://api.tyto.run"
13
+
14
+
15
+ def resolve_config(
16
+ api_key: str | None = None,
17
+ api_url: str | None = None,
18
+ ) -> TytoConfig:
19
+ key = api_key or os.environ.get("TYTO_API_KEY", "")
20
+ if not key:
21
+ raise TytoError(
22
+ "api_key is required. Pass it as an argument or set the TYTO_API_KEY environment variable."
23
+ )
24
+ url = (api_url or os.environ.get("TYTO_API_URL", "https://api.tyto.run")).rstrip("/")
25
+ return TytoConfig(api_key=key, api_url=url)
tyto/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ class TytoError(Exception):
2
+ pass
3
+
4
+
5
+ class TytoAPIError(TytoError):
6
+ def __init__(self, status: int, code: str | None, message: str) -> None:
7
+ super().__init__(message)
8
+ self.status = status
9
+ self.code = code
10
+ self.message = message
11
+
12
+ def __repr__(self) -> str:
13
+ return f"TytoAPIError(status={self.status}, code={self.code!r}, message={self.message!r})"
tyto/models.py ADDED
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional, Type, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ def _from_dict(cls: Type[T], d: dict) -> T:
10
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
11
+ return cls(**{k: v for k, v in d.items() if k in known}) # type: ignore[call-arg]
12
+
13
+
14
+ @dataclass
15
+ class User:
16
+ id: str
17
+ email: str
18
+
19
+ @classmethod
20
+ def from_dict(cls, d: dict) -> "User":
21
+ return _from_dict(cls, d)
22
+
23
+
24
+ @dataclass
25
+ class AuthStartResponse:
26
+ login_url: str
27
+ device_code: str
28
+ expires_in: int
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: dict) -> "AuthStartResponse":
32
+ return _from_dict(cls, d)
33
+
34
+
35
+ @dataclass
36
+ class AuthPollResponse:
37
+ status: str
38
+ api_key: Optional[str] = None
39
+ user: Optional[User] = None
40
+
41
+ @classmethod
42
+ def from_dict(cls, d: dict) -> "AuthPollResponse":
43
+ obj = _from_dict(cls, d)
44
+ if isinstance(obj.user, dict):
45
+ obj.user = User.from_dict(obj.user)
46
+ return obj
47
+
48
+
49
+ @dataclass
50
+ class NestData:
51
+ id: str
52
+ user_id: str
53
+ name: str
54
+ template: str
55
+ status: str
56
+ created_at: str
57
+ updated_at: str
58
+ repo_url: Optional[str] = None
59
+ error_message: Optional[str] = None
60
+ sleep_source: Optional[str] = None
61
+ lifecycle_error: Optional[str] = None
62
+ last_activity_at: Optional[str] = None
63
+ last_wake_at: Optional[str] = None
64
+
65
+ @classmethod
66
+ def from_dict(cls, d: dict) -> "NestData":
67
+ return _from_dict(cls, d)
68
+
69
+
70
+ @dataclass
71
+ class WakeResponse:
72
+ nest_id: Optional[str] = None
73
+ from_: Optional[str] = None
74
+ to: Optional[str] = None
75
+ path: Optional[str] = None
76
+ reason: Optional[str] = None
77
+ duration_ms: Optional[int] = None
78
+
79
+ @classmethod
80
+ def from_dict(cls, d: dict) -> "WakeResponse":
81
+ mapped = {("from_" if k == "from" else k): v for k, v in d.items()}
82
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
83
+ return cls(**{k: v for k, v in mapped.items() if k in known})
84
+
85
+
86
+ @dataclass
87
+ class NestLifecycle:
88
+ nest_id: Optional[str] = None
89
+ status: Optional[str] = None
90
+ sleep_source: Optional[str] = None
91
+ last_activity_at: Optional[str] = None
92
+ last_wake_at: Optional[str] = None
93
+ lifecycle_error: Optional[str] = None
94
+
95
+ @classmethod
96
+ def from_dict(cls, d: dict) -> "NestLifecycle":
97
+ return _from_dict(cls, d)
98
+
99
+
100
+ @dataclass
101
+ class SessionData:
102
+ id: Optional[int] = None
103
+ nest_id: Optional[str] = None
104
+ tty: Optional[bool] = None
105
+ command: Optional[str] = None
106
+ cwd: Optional[str] = None
107
+ status: Optional[str] = None
108
+ attached: Optional[int] = None
109
+ started_at: Optional[str] = None
110
+ last_activity_at: Optional[str] = None
111
+ exit_code: Optional[int] = None
112
+ ended_at: Optional[str] = None
113
+ attach_url: Optional[str] = None
114
+
115
+ @classmethod
116
+ def from_dict(cls, d: dict) -> "SessionData":
117
+ return _from_dict(cls, d)
118
+
119
+
120
+ @dataclass
121
+ class PreviewData:
122
+ id: Optional[str] = None
123
+ nest_id: Optional[str] = None
124
+ name: Optional[str] = None
125
+ port: Optional[int] = None
126
+ auth: Optional[str] = None
127
+ public: Optional[bool] = None
128
+ url: Optional[str] = None
129
+ path_url: Optional[str] = None
130
+ created_at: Optional[str] = None
131
+ expires_at: Optional[str] = None
132
+ revoked_at: Optional[str] = None
133
+ expires_in: Optional[int] = None
134
+
135
+ @classmethod
136
+ def from_dict(cls, d: dict) -> "PreviewData":
137
+ return _from_dict(cls, d)
138
+
139
+
140
+ @dataclass
141
+ class SnapshotData:
142
+ id: Optional[str] = None
143
+ nest_id: Optional[str] = None
144
+ user_id: Optional[str] = None
145
+ name: Optional[str] = None
146
+ description: Optional[str] = None
147
+ state: Optional[str] = None
148
+ template_id: Optional[str] = None
149
+ logical_dirty_bytes: Optional[int] = None
150
+ apparent_bytes: Optional[int] = None
151
+ physical_bytes: Optional[int] = None
152
+ reclaimable_bytes: Optional[int] = None
153
+ reclaimable_status: Optional[str] = None
154
+ index_status: Optional[str] = None
155
+ created_at: Optional[str] = None
156
+
157
+ @classmethod
158
+ def from_dict(cls, d: dict) -> "SnapshotData":
159
+ return _from_dict(cls, d)
160
+
161
+
162
+ @dataclass
163
+ class SnapshotList:
164
+ nest_id: Optional[str] = None
165
+ snapshots: Optional[List[SnapshotData]] = None
166
+
167
+ @classmethod
168
+ def from_dict(cls, d: dict) -> "SnapshotList":
169
+ obj = _from_dict(cls, d)
170
+ if obj.snapshots:
171
+ obj.snapshots = [SnapshotData.from_dict(s) if isinstance(s, dict) else s for s in obj.snapshots]
172
+ return obj
173
+
174
+
175
+ @dataclass
176
+ class RestoreResponse:
177
+ nest_id: Optional[str] = None
178
+ restored_from: Optional[str] = None
179
+ status: Optional[str] = None
180
+ message: Optional[str] = None
181
+
182
+ @classmethod
183
+ def from_dict(cls, d: dict) -> "RestoreResponse":
184
+ return _from_dict(cls, d)
185
+
186
+
187
+ @dataclass
188
+ class ForkStorage:
189
+ copy_method: Optional[str] = None
190
+ physical_bytes_added_now: Optional[int] = None
191
+
192
+ @classmethod
193
+ def from_dict(cls, d: dict) -> "ForkStorage":
194
+ return _from_dict(cls, d)
195
+
196
+
197
+ @dataclass
198
+ class ForkResponse:
199
+ id: Optional[str] = None
200
+ name: Optional[str] = None
201
+ source_nest_id: Optional[str] = None
202
+ status: Optional[str] = None
203
+ template_id: Optional[str] = None
204
+ source_restarted: Optional[bool] = None
205
+ source_restart_error: Optional[str] = None
206
+ storage: Optional[ForkStorage] = None
207
+
208
+ @classmethod
209
+ def from_dict(cls, d: dict) -> "ForkResponse":
210
+ obj = _from_dict(cls, d)
211
+ if isinstance(obj.storage, dict):
212
+ obj.storage = ForkStorage.from_dict(obj.storage)
213
+ return obj
214
+
215
+
216
+ @dataclass
217
+ class DeleteSnapshotResponse:
218
+ snapshot_id: Optional[str] = None
219
+ can_delete: Optional[bool] = None
220
+ would_free_bytes: Optional[int] = None
221
+ would_remain_shared_bytes: Optional[int] = None
222
+ reclaimable_status: Optional[str] = None
223
+ blocked_by: Optional[List[str]] = None
224
+ deleted: Optional[bool] = None
225
+
226
+ @classmethod
227
+ def from_dict(cls, d: dict) -> "DeleteSnapshotResponse":
228
+ return _from_dict(cls, d)
229
+
230
+
231
+ @dataclass
232
+ class KeepaliveHoldData:
233
+ nest_id: Optional[str] = None
234
+ name: Optional[str] = None
235
+ source: Optional[str] = None
236
+ reason: Optional[str] = None
237
+ expires_at: Optional[str] = None
238
+ last_heartbeat_at: Optional[str] = None
239
+ created_at: Optional[str] = None
240
+ updated_at: Optional[str] = None
241
+
242
+ @classmethod
243
+ def from_dict(cls, d: dict) -> "KeepaliveHoldData":
244
+ return _from_dict(cls, d)
245
+
246
+
247
+ @dataclass
248
+ class ReadFileResult:
249
+ data: bytes
250
+ kind: str
File without changes
tyto/resources/auth.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from .._http import HttpClient
6
+ from ..models import AuthPollResponse, AuthStartResponse
7
+
8
+
9
+ class AuthResource:
10
+ def __init__(self, http: HttpClient) -> None:
11
+ self._http = http
12
+
13
+ def start_cli(
14
+ self,
15
+ client: str = "tyto-cli",
16
+ hostname: Optional[str] = None,
17
+ ) -> AuthStartResponse:
18
+ body: dict = {"client": client}
19
+ if hostname is not None:
20
+ body["hostname"] = hostname
21
+ return AuthStartResponse.from_dict(self._http.post("/auth/cli/start", json=body))
22
+
23
+ def poll_cli(self, device_code: str) -> AuthPollResponse:
24
+ return AuthPollResponse.from_dict(
25
+ self._http.post("/auth/cli/poll", json={"device_code": device_code})
26
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from .._http import HttpClient
4
+ from ..models import ReadFileResult
5
+
6
+
7
+ class FileSystem:
8
+ def __init__(self, nest_id: str, http: HttpClient) -> None:
9
+ self._nest_id = nest_id
10
+ self._http = http
11
+
12
+ def write(self, path: str, data: bytes, kind: str = "file") -> None:
13
+ content_type = "application/x-tar" if kind == "dir" else "application/octet-stream"
14
+ self._http.put_binary(
15
+ f"/nest/{self._nest_id}/fs/write",
16
+ data=data,
17
+ content_type=content_type,
18
+ params={"path": path, "kind": kind},
19
+ )
20
+
21
+ def read(self, path: str) -> ReadFileResult:
22
+ data, kind = self._http.get_binary(
23
+ f"/nest/{self._nest_id}/fs/read",
24
+ params={"path": path},
25
+ )
26
+ return ReadFileResult(data=data, kind=kind)
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional
4
+
5
+ from .._http import HttpClient
6
+ from ..models import KeepaliveHoldData
7
+
8
+
9
+ class HoldsResource:
10
+ def __init__(self, nest_id: str, http: HttpClient) -> None:
11
+ self._nest_id = nest_id
12
+ self._http = http
13
+
14
+ def list(self) -> List[KeepaliveHoldData]:
15
+ result = self._http.get(f"/nest/{self._nest_id}/holds")
16
+ return [KeepaliveHoldData.from_dict(d) for d in result]
17
+
18
+ def put(
19
+ self,
20
+ name: str,
21
+ ttl: Optional[str] = None,
22
+ reason: Optional[str] = None,
23
+ source: Optional[str] = None,
24
+ ) -> KeepaliveHoldData:
25
+ body: dict = {}
26
+ if ttl is not None:
27
+ body["ttl"] = ttl
28
+ if reason is not None:
29
+ body["reason"] = reason
30
+ if source is not None:
31
+ body["source"] = source
32
+ result = self._http.put(f"/nest/{self._nest_id}/holds/{name}", json=body)
33
+ return KeepaliveHoldData.from_dict(result)
34
+
35
+ def delete(self, name: str) -> None:
36
+ self._http.delete(f"/nest/{self._nest_id}/holds/{name}")
37
+
38
+ def heartbeat(
39
+ self,
40
+ name: str,
41
+ ttl: Optional[str] = None,
42
+ reason: Optional[str] = None,
43
+ source: Optional[str] = None,
44
+ ) -> KeepaliveHoldData:
45
+ body: dict = {}
46
+ if ttl is not None:
47
+ body["ttl"] = ttl
48
+ if reason is not None:
49
+ body["reason"] = reason
50
+ if source is not None:
51
+ body["source"] = source
52
+ result = self._http.post(f"/nest/{self._nest_id}/holds/{name}/heartbeat", json=body)
53
+ return KeepaliveHoldData.from_dict(result)
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import shlex
6
+ import tarfile
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import List, Optional, Union
11
+
12
+ from .._http import HttpClient
13
+ from ..config import TytoConfig
14
+ from ..models import (
15
+ DeleteSnapshotResponse,
16
+ ForkResponse,
17
+ NestData,
18
+ NestLifecycle,
19
+ PreviewData,
20
+ RestoreResponse,
21
+ SessionData,
22
+ SnapshotData,
23
+ WakeResponse,
24
+ )
25
+ from ..ws import connect_ws
26
+ from .files import FileSystem
27
+ from .holds import HoldsResource
28
+ from .previews import PreviewsResource
29
+ from .sessions import Session, SessionsResource
30
+ from .snapshots import SnapshotsResource
31
+
32
+
33
+ class Nest:
34
+ def __init__(self, data: NestData, http: HttpClient, config: TytoConfig) -> None:
35
+ self._data = data
36
+ self._http = http
37
+ self._config = config
38
+ self.fs = FileSystem(data.id, http)
39
+ self.sessions = SessionsResource(data.id, http, config)
40
+ self.previews = PreviewsResource(data.id, http)
41
+ self.snapshots = SnapshotsResource(data.id, http)
42
+ self.holds = HoldsResource(data.id, http)
43
+
44
+ @property
45
+ def id(self) -> str:
46
+ return self._data.id
47
+
48
+ @property
49
+ def user_id(self) -> str:
50
+ return self._data.user_id
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ return self._data.name
55
+
56
+ @property
57
+ def template(self) -> str:
58
+ return self._data.template
59
+
60
+ @property
61
+ def status(self) -> str:
62
+ return self._data.status
63
+
64
+ @property
65
+ def repo_url(self) -> Optional[str]:
66
+ return self._data.repo_url
67
+
68
+ @property
69
+ def error_message(self) -> Optional[str]:
70
+ return self._data.error_message
71
+
72
+ @property
73
+ def sleep_source(self) -> Optional[str]:
74
+ return self._data.sleep_source
75
+
76
+ @property
77
+ def lifecycle_error(self) -> Optional[str]:
78
+ return self._data.lifecycle_error
79
+
80
+ @property
81
+ def last_activity_at(self) -> Optional[str]:
82
+ return self._data.last_activity_at
83
+
84
+ @property
85
+ def last_wake_at(self) -> Optional[str]:
86
+ return self._data.last_wake_at
87
+
88
+ @property
89
+ def created_at(self) -> str:
90
+ return self._data.created_at
91
+
92
+ @property
93
+ def updated_at(self) -> str:
94
+ return self._data.updated_at
95
+
96
+ @property
97
+ def data(self) -> NestData:
98
+ return self._data
99
+
100
+ def start(self) -> Union["Nest", WakeResponse]:
101
+ result = self._http.post(f"/nest/{self._data.id}/start")
102
+ if "user_id" in result:
103
+ self._data = NestData.from_dict(result)
104
+ return self
105
+ return WakeResponse.from_dict(result)
106
+
107
+ def stop(self) -> "Nest":
108
+ result = self._http.post(f"/nest/{self._data.id}/stop")
109
+ self._data = NestData.from_dict(result)
110
+ return self
111
+
112
+ def wake(self, reason: Optional[str] = None) -> WakeResponse:
113
+ body = {"reason": reason} if reason else {}
114
+ return WakeResponse.from_dict(self._http.post(f"/nest/{self._data.id}/wake", json=body))
115
+
116
+ def delete(self) -> Optional[NestData]:
117
+ result = self._http.delete(f"/nest/{self._data.id}")
118
+ return NestData.from_dict(result) if result else None
119
+
120
+ def lifecycle(self) -> NestLifecycle:
121
+ return NestLifecycle.from_dict(self._http.get(f"/nest/{self._data.id}/lifecycle"))
122
+
123
+ def restore(self, snapshot_id: str) -> RestoreResponse:
124
+ result = self._http.post(
125
+ f"/nest/{self._data.id}/restore",
126
+ json={"snapshot_id": snapshot_id},
127
+ )
128
+ return RestoreResponse.from_dict(result)
129
+
130
+ def fork(
131
+ self,
132
+ name: str,
133
+ stop_if_running: bool = False,
134
+ restart_source: bool = False,
135
+ ) -> ForkResponse:
136
+ result = self._http.post(
137
+ f"/nest/{self._data.id}/fork",
138
+ json={"name": name, "stop_if_running": stop_if_running, "restart_source": restart_source},
139
+ )
140
+ return ForkResponse.from_dict(result)
141
+
142
+ def run(
143
+ self,
144
+ argv: list,
145
+ cwd: Optional[str] = None,
146
+ cols: int = 80,
147
+ rows: int = 24,
148
+ timeout_s: float = 120.0,
149
+ ) -> str:
150
+ """Run a command in the nest and return its captured output.
151
+
152
+ The API runs managed sessions to completion server-side and only exposes
153
+ live output over a WebSocket, which is racy for short commands (the
154
+ session can finish before the stream attaches, yielding HTTP 410). So we
155
+ redirect the command's stdout+stderr and exit code to files, poll the
156
+ filesystem until the exit-code file appears, then read the output back
157
+ over the (reliable) filesystem API. No WebSocket required.
158
+ """
159
+ work_dir = "/home/tyto/.tyto-run"
160
+ run_id = uuid.uuid4().hex
161
+ out_abs = f"{work_dir}/{run_id}.out"
162
+ rc_abs = f"{work_dir}/{run_id}.rc"
163
+ out_rel = f".tyto-run/{run_id}.out"
164
+ rc_rel = f".tyto-run/{run_id}.rc"
165
+
166
+ effective_cwd = cwd or "/home/tyto"
167
+ inner = " ".join(shlex.quote(a) for a in argv)
168
+ script = (
169
+ f"mkdir -p {shlex.quote(work_dir)}; "
170
+ f"{{ cd {shlex.quote(effective_cwd)} && {inner} ; }} > {shlex.quote(out_abs)} 2>&1; "
171
+ f"echo $? > {shlex.quote(rc_abs)}"
172
+ )
173
+
174
+ self.sessions.create(
175
+ argv=["bash", "-lc", script], tty=True, cwd=cwd, cols=cols, rows=rows
176
+ )
177
+
178
+ # The exit-code file is written only after the command completes, so its
179
+ # presence is an unambiguous "done" signal. (Session status is unreliable
180
+ # here: a quiet tty session reports "idle" while still running.)
181
+ deadline = time.perf_counter() + timeout_s
182
+ while time.perf_counter() < deadline:
183
+ time.sleep(0.5)
184
+ try:
185
+ if self.fs.read(rc_rel).data.decode().strip():
186
+ break
187
+ except Exception:
188
+ continue # exit-code file not written yet
189
+
190
+ try:
191
+ return self.fs.read(out_rel).data.decode(errors="replace")
192
+ except Exception:
193
+ return ""
194
+
195
+ def create_snapshot(
196
+ self,
197
+ name: Optional[str] = None,
198
+ description: Optional[str] = None,
199
+ stop_if_running: bool = False,
200
+ ) -> SnapshotData:
201
+ return self.snapshots.create(name=name, description=description, stop_if_running=stop_if_running)
202
+
203
+ def delete_snapshot(self, snapshot_id: str, dry_run: bool = False) -> DeleteSnapshotResponse:
204
+ params: dict = {"dry_run": dry_run} if dry_run else {}
205
+ result = self._http.delete(f"/snapshots/{snapshot_id}", params=params)
206
+ return DeleteSnapshotResponse.from_dict(result)
207
+
208
+ def create_session(
209
+ self,
210
+ argv: list,
211
+ tty: bool = False,
212
+ cwd: Optional[str] = None,
213
+ cols: Optional[int] = None,
214
+ rows: Optional[int] = None,
215
+ env: Optional[dict] = None,
216
+ ) -> Session:
217
+ return self.sessions.create(argv=argv, tty=tty, cwd=cwd, cols=cols, rows=rows, env=env)
218
+
219
+ def create_preview(
220
+ self,
221
+ port: int,
222
+ auth: str = "private",
223
+ public: bool = False,
224
+ name: Optional[str] = None,
225
+ ) -> PreviewData:
226
+ return self.previews.create(port=port, auth=auth, public=public, name=name)
227
+
228
+ def put(self, local_path: str, remote_path: str) -> None:
229
+ local = Path(local_path)
230
+ if local.is_dir():
231
+ buf = io.BytesIO()
232
+ with tarfile.open(fileobj=buf, mode="w:") as tf:
233
+ tf.add(str(local), arcname=".")
234
+ self.fs.write(remote_path, buf.getvalue(), kind="dir")
235
+ else:
236
+ self.fs.write(remote_path, local.read_bytes(), kind="file")
237
+
238
+ def get(self, remote_path: str, local_path: str) -> None:
239
+ result = self.fs.read(remote_path)
240
+ if result.kind == "dir":
241
+ dest = Path(local_path)
242
+ dest.mkdir(parents=True, exist_ok=True)
243
+ buf = io.BytesIO(result.data)
244
+ with tarfile.open(fileobj=buf, mode="r:") as tf:
245
+ tf.extractall(str(dest))
246
+ else:
247
+ dest = Path(local_path)
248
+ dest.parent.mkdir(parents=True, exist_ok=True)
249
+ dest.write_bytes(result.data)
250
+
251
+ def console(self):
252
+ return connect_ws(self._config, f"/nest/{self._data.id}/console")
253
+
254
+ def exec(self):
255
+ return connect_ws(self._config, f"/nest/{self._data.id}/exec")
256
+
257
+ def __repr__(self) -> str:
258
+ return f"Nest(id={self.id!r}, name={self.name!r}, status={self.status!r})"
259
+
260
+
261
+ class NestsResource:
262
+ def __init__(self, http: HttpClient, config: TytoConfig) -> None:
263
+ self._http = http
264
+ self._config = config
265
+
266
+ def create(
267
+ self,
268
+ name: str,
269
+ template: str = "ubuntu-24-dev",
270
+ repo_url: Optional[str] = None,
271
+ ) -> Nest:
272
+ body: dict = {"name": name, "template": template}
273
+ if repo_url is not None:
274
+ body["repo_url"] = repo_url
275
+ result = self._http.post("/nest/", json=body)
276
+ return Nest(NestData.from_dict(result), self._http, self._config)
277
+
278
+ def list(self) -> List[Nest]:
279
+ result = self._http.get("/nest/")
280
+ return [Nest(NestData.from_dict(d), self._http, self._config) for d in result]
281
+
282
+ def get(self, nest_id: str) -> Nest:
283
+ result = self._http.get(f"/nest/{nest_id}")
284
+ return Nest(NestData.from_dict(result), self._http, self._config)
285
+
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional
4
+
5
+ from .._http import HttpClient
6
+ from ..models import PreviewData
7
+
8
+
9
+ class PreviewsResource:
10
+ def __init__(self, nest_id: str, http: HttpClient) -> None:
11
+ self._nest_id = nest_id
12
+ self._http = http
13
+
14
+ def create(
15
+ self,
16
+ port: int,
17
+ auth: str = "private",
18
+ public: bool = False,
19
+ name: Optional[str] = None,
20
+ ) -> PreviewData:
21
+ body: dict = {"port": port, "auth": auth, "public": public}
22
+ if name is not None:
23
+ body["name"] = name
24
+ result = self._http.post(f"/nest/{self._nest_id}/previews", json=body)
25
+ return PreviewData.from_dict(result)
26
+
27
+ def list(self) -> List[PreviewData]:
28
+ result = self._http.get(f"/nest/{self._nest_id}/previews")
29
+ return [PreviewData.from_dict(d) for d in result]
30
+
31
+
32
+ class TopLevelPreviewsResource:
33
+ def __init__(self, http: HttpClient) -> None:
34
+ self._http = http
35
+
36
+ def get(self, preview_id: str) -> PreviewData:
37
+ return PreviewData.from_dict(self._http.get(f"/previews/{preview_id}"))
38
+
39
+ def revoke(self, preview_id: str) -> None:
40
+ self._http.delete(f"/previews/{preview_id}")
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from .._http import HttpClient
6
+ from ..config import TytoConfig
7
+ from ..models import SessionData
8
+ from ..ws import connect_ws
9
+
10
+
11
+ class Session:
12
+ def __init__(self, data: SessionData, nest_id: str, http: HttpClient, config: TytoConfig) -> None:
13
+ self._data = data
14
+ self._nest_id = nest_id
15
+ self._http = http
16
+ self._config = config
17
+
18
+ @property
19
+ def id(self) -> Optional[int]:
20
+ return self._data.id
21
+
22
+ @property
23
+ def nest_id(self) -> Optional[str]:
24
+ return self._data.nest_id
25
+
26
+ @property
27
+ def tty(self) -> Optional[bool]:
28
+ return self._data.tty
29
+
30
+ @property
31
+ def command(self) -> Optional[str]:
32
+ return self._data.command
33
+
34
+ @property
35
+ def cwd(self) -> Optional[str]:
36
+ return self._data.cwd
37
+
38
+ @property
39
+ def status(self) -> Optional[str]:
40
+ return self._data.status
41
+
42
+ @property
43
+ def attached(self) -> Optional[int]:
44
+ return self._data.attached
45
+
46
+ @property
47
+ def started_at(self) -> Optional[str]:
48
+ return self._data.started_at
49
+
50
+ @property
51
+ def exit_code(self) -> Optional[int]:
52
+ return self._data.exit_code
53
+
54
+ @property
55
+ def ended_at(self) -> Optional[str]:
56
+ return self._data.ended_at
57
+
58
+ @property
59
+ def attach_url(self) -> Optional[str]:
60
+ return self._data.attach_url
61
+
62
+ @property
63
+ def data(self) -> SessionData:
64
+ return self._data
65
+
66
+ def kill(self, signal: str = "TERM", grace_ms: int = 5000) -> "Session":
67
+ result = self._http.post(
68
+ f"/nest/{self._nest_id}/sessions/{self._data.id}/kill",
69
+ json={"signal": signal, "grace_ms": grace_ms},
70
+ )
71
+ self._data = SessionData.from_dict(result)
72
+ return self
73
+
74
+ def attach(self):
75
+ return connect_ws(self._config, f"/nest/{self._nest_id}/sessions/{self._data.id}/attach")
76
+
77
+
78
+ class SessionsResource:
79
+ def __init__(self, nest_id: str, http: HttpClient, config: TytoConfig) -> None:
80
+ self._nest_id = nest_id
81
+ self._http = http
82
+ self._config = config
83
+
84
+ def create(
85
+ self,
86
+ argv: List[str],
87
+ tty: bool = False,
88
+ cwd: Optional[str] = None,
89
+ cols: Optional[int] = None,
90
+ rows: Optional[int] = None,
91
+ env: Optional[Dict[str, str]] = None,
92
+ ) -> Session:
93
+ body: dict = {"tty": tty, "argv": argv}
94
+ if cwd is not None:
95
+ body["cwd"] = cwd
96
+ if cols is not None:
97
+ body["cols"] = cols
98
+ if rows is not None:
99
+ body["rows"] = rows
100
+ if env is not None:
101
+ body["env"] = env
102
+ result = self._http.post(f"/nest/{self._nest_id}/sessions", json=body)
103
+ return Session(SessionData.from_dict(result), self._nest_id, self._http, self._config)
104
+
105
+ def list(self, all: bool = False, history: bool = False) -> List[Session]:
106
+ params: dict = {}
107
+ if all:
108
+ params["all"] = True
109
+ if history:
110
+ params["history"] = True
111
+ result = self._http.get(f"/nest/{self._nest_id}/sessions", params=params)
112
+ return [Session(SessionData.from_dict(d), self._nest_id, self._http, self._config) for d in result]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from .._http import HttpClient
6
+ from ..models import (
7
+ DeleteSnapshotResponse,
8
+ ForkResponse,
9
+ RestoreResponse,
10
+ SnapshotData,
11
+ SnapshotList,
12
+ )
13
+
14
+
15
+ class SnapshotsResource:
16
+ def __init__(self, nest_id: str, http: HttpClient) -> None:
17
+ self._nest_id = nest_id
18
+ self._http = http
19
+
20
+ def create(
21
+ self,
22
+ name: Optional[str] = None,
23
+ description: Optional[str] = None,
24
+ stop_if_running: bool = False,
25
+ ) -> SnapshotData:
26
+ body: dict = {"stop_if_running": stop_if_running}
27
+ if name is not None:
28
+ body["name"] = name
29
+ if description is not None:
30
+ body["description"] = description
31
+ result = self._http.post(f"/nest/{self._nest_id}/snapshots", json=body)
32
+ return SnapshotData.from_dict(result)
33
+
34
+ def list(self) -> SnapshotList:
35
+ return SnapshotList.from_dict(self._http.get(f"/nest/{self._nest_id}/snapshots"))
36
+
37
+ def restore(self, snapshot_id: str) -> RestoreResponse:
38
+ result = self._http.post(
39
+ f"/nest/{self._nest_id}/restore",
40
+ json={"snapshot_id": snapshot_id},
41
+ )
42
+ return RestoreResponse.from_dict(result)
43
+
44
+ def fork(
45
+ self,
46
+ name: str,
47
+ stop_if_running: bool = False,
48
+ restart_source: bool = False,
49
+ ) -> ForkResponse:
50
+ result = self._http.post(
51
+ f"/nest/{self._nest_id}/fork",
52
+ json={"name": name, "stop_if_running": stop_if_running, "restart_source": restart_source},
53
+ )
54
+ return ForkResponse.from_dict(result)
55
+
56
+
57
+ class TopLevelSnapshotsResource:
58
+ def __init__(self, http: HttpClient) -> None:
59
+ self._http = http
60
+
61
+ def delete(self, snapshot_id: str, dry_run: bool = False) -> DeleteSnapshotResponse:
62
+ params = {"dry_run": dry_run} if dry_run else {}
63
+ result = self._http.delete(f"/snapshots/{snapshot_id}", params=params)
64
+ return DeleteSnapshotResponse.from_dict(result)
tyto/ws.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import TytoConfig
4
+
5
+
6
+ def connect_ws(config: TytoConfig, path: str):
7
+ from websockets.sync.client import connect
8
+
9
+ ws_url = config.api_url.replace("https://", "wss://").replace("http://", "ws://") + path
10
+ return connect(ws_url, additional_headers={"Authorization": f"Bearer {config.api_key}"})
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: tyto.run
3
+ Version: 0.11.0
4
+ Summary: Official Python SDK for the Tyto API
5
+ Project-URL: Homepage, https://tyto.run
6
+ Author: Bonya
7
+ License: MIT
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: websockets>=13.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # tyto
14
+
15
+ Python SDK for the [Tyto API](https://api.tyto.run) — manage nests, sessions, files, previews, snapshots, and keepalive holds.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install tyto
21
+ ```
22
+
23
+ ## Auth
24
+
25
+ Export your API key:
26
+
27
+ ```bash
28
+ export TYTO_API_KEY=your_key_here
29
+ ```
30
+
31
+ Or pass it directly:
32
+
33
+ ```python
34
+ from tyto import Tyto
35
+ tyto = Tyto(api_key="your_key_here")
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ import os
42
+ from tyto import Tyto
43
+
44
+ tyto = Tyto(api_key=os.environ["TYTO_API_KEY"])
45
+
46
+ # Who am I?
47
+ me = tyto.me()
48
+ print(me.email)
49
+
50
+ # Create a nest
51
+ nest = tyto.create(name="my-nest", template="ubuntu-24-dev")
52
+
53
+ # Upload / download a file or directory
54
+ nest.put("./hello.txt", "hello.txt")
55
+ nest.get("hello.txt", "./hello.downloaded.txt")
56
+
57
+ # Create a session and attach over WebSocket
58
+ session = nest.create_session(argv=["bash", "-lc", "echo hi"], tty=False)
59
+ with session.attach() as ws:
60
+ for message in ws:
61
+ print(message)
62
+
63
+ # Open raw WebSocket connections
64
+ with nest.exec() as ws: ...
65
+ with nest.console() as ws: ...
66
+
67
+ # Create a preview
68
+ preview = nest.create_preview(port=3000, auth="private")
69
+ print(preview.url)
70
+
71
+ # Snapshot, fork, restore
72
+ snap = nest.create_snapshot(name="v1")
73
+ fork = nest.fork(name="my-fork")
74
+ nest.delete_snapshot(snap.id)
75
+ # nest.restore(snap.id)
76
+
77
+ # Keepalive hold
78
+ nest.holds.put("ci", ttl="30m", reason="CI job")
79
+ nest.holds.heartbeat("ci", ttl="30m")
80
+ nest.holds.delete("ci")
81
+
82
+ # Lifecycle
83
+ nest.stop()
84
+ nest.start()
85
+ nest.wake(reason="wakeup")
86
+ nest.delete()
87
+ ```
88
+
89
+ ## Resources
90
+
91
+ | Resource | Description |
92
+ |---|---|
93
+ | `tyto.create()` | Create a nest |
94
+ | `tyto.nests` | Create / list / get nests |
95
+ | `tyto.previews` | Inspect / revoke previews by ID |
96
+ | `tyto.auth` | CLI browser auth flow |
97
+ | `nest.put(local, remote)` | Upload a file or directory |
98
+ | `nest.get(remote, local)` | Download a file or directory |
99
+ | `nest.create_session()` | Create a managed session |
100
+ | `nest.create_preview()` | Create a preview URL |
101
+ | `nest.create_snapshot()` | Create a snapshot |
102
+ | `nest.delete_snapshot()` | Delete a snapshot |
103
+ | `nest.fs` | Low-level file upload / download |
104
+ | `nest.sessions` | Managed sessions (create / list) |
105
+ | `nest.previews` | Previews scoped to the nest |
106
+ | `nest.snapshots` | Snapshots + fork/restore |
107
+ | `nest.holds` | Keepalive holds |
108
+ | `session.attach()` | WebSocket stream for a session |
109
+ | `nest.console()` | Interactive shell WebSocket |
110
+ | `nest.exec()` | Command WebSocket |
111
+
112
+ ## Configuration
113
+
114
+ | Parameter | Env var | Default |
115
+ |---|---|---|
116
+ | `api_key` | `TYTO_API_KEY` | — (required) |
117
+ | `api_url` | `TYTO_API_URL` | `https://api.tyto.run` |
118
+
119
+ ## Examples
120
+
121
+ ```bash
122
+ python examples/quickstart.py
123
+ python examples/files.py
124
+ python examples/preview.py
125
+ python examples/snapshot_fork.py
126
+ python examples/websocket_exec.py
127
+ ```
128
+
129
+ ## Context manager
130
+
131
+ The `Tyto` client can be used as a context manager to ensure the underlying HTTP connection pool is closed:
132
+
133
+ ```python
134
+ with Tyto() as tyto:
135
+ nests = tyto.nests.list()
136
+ ```
@@ -0,0 +1,18 @@
1
+ tyto/__init__.py,sha256=5hkYjZd1XOcdWWnaRnsoDEblpejdJuVq-sg_m4TpSpU,1473
2
+ tyto/_http.py,sha256=splieE2PMZITwLcfMVn-BDCGD0T-vCjih65pJGju03k,2940
3
+ tyto/client.py,sha256=_t6qVm_DDWYhQq2iHb4hJxwJorTQDmWDNDTVOTQBorM,1486
4
+ tyto/config.py,sha256=PcuP8opW09RUXj4Lwa2zGkP9GAyzFtbHPshMCqyBEbM,658
5
+ tyto/errors.py,sha256=7dX-lK0MkNJYTUmp1ScCegl85g_3wzGNa7mBGoKHPmU,397
6
+ tyto/models.py,sha256=e2gr8o42x7yzbIW12FB2kXaW1UHSSkA7LZ7zm-i3jug,6571
7
+ tyto/ws.py,sha256=l8Dwu13YS_LDew4wm2Z_dBf93dG_2NBYvcWeQZsuoUM,350
8
+ tyto/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ tyto/resources/auth.py,sha256=lA1o84qCwoD_KWc254imj67FXmLdR3O5KwRrIovQUBo,787
10
+ tyto/resources/files.py,sha256=fp5XqFgVa5ccES3O23g95b1l_OyKao-tdG_HcEnzz54,847
11
+ tyto/resources/holds.py,sha256=c9Zy3Jh4fjcFuKlfR9Mmr7-DNyZVdrh8fKOxnlsTKxo,1656
12
+ tyto/resources/nests.py,sha256=df8cCatt6T6cRovPiGuaVqtn_KhQWwlMGTeZ93Fn-2k,9342
13
+ tyto/resources/previews.py,sha256=g89Xxn9J-EVt1hmaWDQn2ctybhZNTF_uTYnv4wAcNqY,1215
14
+ tyto/resources/sessions.py,sha256=VX1n0lEEhbiuoGhV0wrkyLLucf7Y3Q2VTEIp24xZD9Y,3238
15
+ tyto/resources/snapshots.py,sha256=MFi5wn7r2umbMFOyDbh-uHcRhhKfwnqkg7l8_KmKQFU,2023
16
+ tyto_run-0.11.0.dist-info/METADATA,sha256=LJD0DElIyqkcz8UbPBCbAnEukou-18Qaqa_g_SGCINY,3217
17
+ tyto_run-0.11.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
18
+ tyto_run-0.11.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any