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 +62 -0
- tyto/_http.py +82 -0
- tyto/client.py +51 -0
- tyto/config.py +25 -0
- tyto/errors.py +13 -0
- tyto/models.py +250 -0
- tyto/resources/__init__.py +0 -0
- tyto/resources/auth.py +26 -0
- tyto/resources/files.py +26 -0
- tyto/resources/holds.py +53 -0
- tyto/resources/nests.py +285 -0
- tyto/resources/previews.py +40 -0
- tyto/resources/sessions.py +112 -0
- tyto/resources/snapshots.py +64 -0
- tyto/ws.py +10 -0
- tyto_run-0.11.0.dist-info/METADATA +136 -0
- tyto_run-0.11.0.dist-info/RECORD +18 -0
- tyto_run-0.11.0.dist-info/WHEEL +4 -0
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
|
+
)
|
tyto/resources/files.py
ADDED
|
@@ -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)
|
tyto/resources/holds.py
ADDED
|
@@ -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)
|
tyto/resources/nests.py
ADDED
|
@@ -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,,
|