spore-host 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.
- spore/__init__.py +35 -0
- spore/client.py +89 -0
- spore/spawn.py +279 -0
- spore/truffle.py +166 -0
- spore_host-0.1.0.dist-info/METADATA +83 -0
- spore_host-0.1.0.dist-info/RECORD +7 -0
- spore_host-0.1.0.dist-info/WHEEL +4 -0
spore/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
spore-host — ephemeral EC2 compute for researchers.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
import spore
|
|
6
|
+
results = spore.truffle.find("nvidia h100", region="us-east-1")
|
|
7
|
+
instance = spore.spawn.launch("c8a.2xlarge", ttl="8h")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import Client
|
|
11
|
+
from . import truffle, spawn
|
|
12
|
+
|
|
13
|
+
# Module-level convenience: a default client using ambient AWS credentials
|
|
14
|
+
_default: Client | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_default() -> Client:
|
|
18
|
+
global _default
|
|
19
|
+
if _default is None:
|
|
20
|
+
_default = Client()
|
|
21
|
+
return _default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str):
|
|
25
|
+
# Allow `spore.truffle.find(...)` and `spore.spawn.launch(...)` at module level
|
|
26
|
+
default = _get_default()
|
|
27
|
+
if name == "truffle":
|
|
28
|
+
return default.truffle
|
|
29
|
+
if name == "spawn":
|
|
30
|
+
return default.spawn
|
|
31
|
+
raise AttributeError(f"module 'spore' has no attribute {name!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
__all__ = ["Client", "truffle", "spawn"]
|
spore/client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Top-level Client — holds credentials and spawns sub-clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client:
|
|
13
|
+
"""
|
|
14
|
+
spore.host client. Uses standard AWS credential chain by default.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
api_key: spore.host API key (sk_...). If omitted, reads SPORE_API_KEY
|
|
18
|
+
env var, then falls back to calling the REST API with
|
|
19
|
+
SigV4-signed requests using ambient AWS credentials.
|
|
20
|
+
api_url: Override the REST API base URL (useful for self-hosted).
|
|
21
|
+
profile: AWS profile name (e.g. "my-research-account").
|
|
22
|
+
region: Default AWS region.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
DEFAULT_API_URL = "https://v7ochiyks4uknie3u4s7tiix7a0ejduj.lambda-url.us-east-1.on.aws"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
api_url: Optional[str] = None,
|
|
31
|
+
profile: Optional[str] = None,
|
|
32
|
+
region: Optional[str] = None,
|
|
33
|
+
):
|
|
34
|
+
self._api_key = api_key or os.environ.get("SPORE_API_KEY")
|
|
35
|
+
self._api_url = (api_url or os.environ.get("SPORE_API_URL") or self.DEFAULT_API_URL).rstrip("/")
|
|
36
|
+
self._profile = profile or os.environ.get("AWS_PROFILE")
|
|
37
|
+
self._region = region or os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
|
38
|
+
|
|
39
|
+
# Lazy boto3 session — created on first use
|
|
40
|
+
self._session: Optional[boto3.Session] = None
|
|
41
|
+
|
|
42
|
+
# Sub-clients
|
|
43
|
+
from .truffle import TruffleClient
|
|
44
|
+
from .spawn import SpawnClient
|
|
45
|
+
self.truffle = TruffleClient(self)
|
|
46
|
+
self.spawn = SpawnClient(self)
|
|
47
|
+
|
|
48
|
+
# ── HTTP helpers ──────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _headers(self) -> dict:
|
|
51
|
+
h = {"Content-Type": "application/json"}
|
|
52
|
+
if self._api_key:
|
|
53
|
+
h["X-API-Key"] = self._api_key
|
|
54
|
+
return h
|
|
55
|
+
|
|
56
|
+
def get(self, path: str, params: dict = None) -> dict:
|
|
57
|
+
resp = requests.get(
|
|
58
|
+
f"{self._api_url}{path}",
|
|
59
|
+
headers=self._headers(),
|
|
60
|
+
params=params or {},
|
|
61
|
+
timeout=30,
|
|
62
|
+
)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
return resp.json()
|
|
65
|
+
|
|
66
|
+
def post(self, path: str, body: dict = None) -> dict:
|
|
67
|
+
resp = requests.post(
|
|
68
|
+
f"{self._api_url}{path}",
|
|
69
|
+
headers=self._headers(),
|
|
70
|
+
json=body or {},
|
|
71
|
+
timeout=30,
|
|
72
|
+
)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return resp.json()
|
|
75
|
+
|
|
76
|
+
# ── AWS session (for direct SDK calls when needed) ────────────────────────
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def boto_session(self) -> boto3.Session:
|
|
80
|
+
if self._session is None:
|
|
81
|
+
kwargs = {"region_name": self._region}
|
|
82
|
+
if self._profile:
|
|
83
|
+
kwargs["profile_name"] = self._profile
|
|
84
|
+
self._session = boto3.Session(**kwargs)
|
|
85
|
+
return self._session
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
key_hint = f"api_key={self._api_key[:8]}..." if self._api_key else "no api_key"
|
|
89
|
+
return f"<spore.Client {key_hint} region={self._region}>"
|
spore/spawn.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""spawn — EC2 instance lifecycle: launch, status, stop, extend, terminate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Callable, List, Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import Client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Instance:
|
|
17
|
+
"""A spawn-managed EC2 instance."""
|
|
18
|
+
|
|
19
|
+
instance_id: str
|
|
20
|
+
name: str
|
|
21
|
+
instance_type: str
|
|
22
|
+
state: str
|
|
23
|
+
region: str
|
|
24
|
+
public_ip: str = ""
|
|
25
|
+
dns: str = ""
|
|
26
|
+
launch_time: Optional[datetime] = None
|
|
27
|
+
ttl: str = ""
|
|
28
|
+
idle_timeout: str = ""
|
|
29
|
+
_client: Optional["SpawnClient"] = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
# ── Actions ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def stop(self, hibernate: bool = False) -> "Instance":
|
|
34
|
+
"""Stop the instance (preserves it; use start() to resume)."""
|
|
35
|
+
action = "hibernate" if hibernate else "stop"
|
|
36
|
+
self._client._action(self.instance_id, action, self.region)
|
|
37
|
+
self.state = "stopping"
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def start(self) -> "Instance":
|
|
41
|
+
"""Start a stopped instance."""
|
|
42
|
+
self._client._action(self.instance_id, "start", self.region)
|
|
43
|
+
self.state = "pending"
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def terminate(self) -> "Instance":
|
|
47
|
+
"""Permanently terminate the instance."""
|
|
48
|
+
self._client._action(self.instance_id, "terminate", self.region)
|
|
49
|
+
self.state = "shutting-down"
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def extend(self, duration: str) -> "Instance":
|
|
53
|
+
"""
|
|
54
|
+
Extend the TTL deadline.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
duration: Duration to add, e.g. "2h", "30m", "1d".
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> instance.extend("4h")
|
|
61
|
+
"""
|
|
62
|
+
self._client._action(self.instance_id, "extend", self.region, {"duration": duration})
|
|
63
|
+
self.ttl = duration
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def refresh(self) -> "Instance":
|
|
67
|
+
"""Fetch current state from the API."""
|
|
68
|
+
updated = self._client.status(self.instance_id)
|
|
69
|
+
self.state = updated.state
|
|
70
|
+
self.public_ip = updated.public_ip
|
|
71
|
+
self.ttl = updated.ttl
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
# ── Waiting ───────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def wait(
|
|
77
|
+
self,
|
|
78
|
+
state: str = "terminated",
|
|
79
|
+
poll_interval: int = 30,
|
|
80
|
+
timeout: int = 43200,
|
|
81
|
+
on_status: Optional[Callable[["Instance"], None]] = None,
|
|
82
|
+
) -> "Instance":
|
|
83
|
+
"""
|
|
84
|
+
Block until the instance reaches a target state.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
state: Target state: "running", "stopped", "terminated".
|
|
88
|
+
poll_interval: Seconds between polls (default 30).
|
|
89
|
+
timeout: Max seconds to wait (default 12h).
|
|
90
|
+
on_status: Optional callback called on each poll with the Instance.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> instance.wait("terminated", on_status=lambda i: print(i.state))
|
|
94
|
+
"""
|
|
95
|
+
deadline = time.time() + timeout
|
|
96
|
+
while time.time() < deadline:
|
|
97
|
+
self.refresh()
|
|
98
|
+
if on_status:
|
|
99
|
+
on_status(self)
|
|
100
|
+
if self.state == state:
|
|
101
|
+
return self
|
|
102
|
+
if self.state in ("terminated", "shutting-down") and state != "terminated":
|
|
103
|
+
raise RuntimeError(f"Instance terminated before reaching {state!r}")
|
|
104
|
+
time.sleep(poll_interval)
|
|
105
|
+
raise TimeoutError(f"Instance did not reach {state!r} within {timeout}s")
|
|
106
|
+
|
|
107
|
+
def wait_running(self, **kwargs) -> "Instance":
|
|
108
|
+
"""Block until running."""
|
|
109
|
+
return self.wait("running", **kwargs)
|
|
110
|
+
|
|
111
|
+
def wait_done(self, **kwargs) -> "Instance":
|
|
112
|
+
"""Block until terminated (job complete or TTL fired)."""
|
|
113
|
+
return self.wait("terminated", **kwargs)
|
|
114
|
+
|
|
115
|
+
# ── Notebook display ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _repr_html_(self) -> str:
|
|
118
|
+
state_colour = {
|
|
119
|
+
"running": "#059669", "stopped": "#d97706",
|
|
120
|
+
"terminated": "#6b7280", "pending": "#4059E5",
|
|
121
|
+
}.get(self.state, "#6b7280")
|
|
122
|
+
return (
|
|
123
|
+
f'<div style="font-family:monospace;font-size:0.9rem;padding:8px;'
|
|
124
|
+
f'border:1px solid #e5e7eb;border-radius:6px;background:#f8f9fa">'
|
|
125
|
+
f'<b>{self.name}</b> <code style="font-size:0.8em">{self.instance_id}</code><br>'
|
|
126
|
+
f'Type: {self.instance_type} | '
|
|
127
|
+
f'State: <span style="color:{state_colour};font-weight:600">{self.state}</span><br>'
|
|
128
|
+
f'Region: {self.region} | IP: {self.public_ip or "—"}<br>'
|
|
129
|
+
f'TTL: {self.ttl or "—"} | Idle timeout: {self.idle_timeout or "—"}'
|
|
130
|
+
f'</div>'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def __repr__(self) -> str:
|
|
134
|
+
return f"<Instance {self.name} ({self.instance_id}) {self.state} {self.region}>"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SpawnClient:
|
|
138
|
+
def __init__(self, client: "Client"):
|
|
139
|
+
self._c = client
|
|
140
|
+
|
|
141
|
+
def launch(
|
|
142
|
+
self,
|
|
143
|
+
instance_type: str,
|
|
144
|
+
*,
|
|
145
|
+
name: Optional[str] = None,
|
|
146
|
+
region: Optional[str] = None,
|
|
147
|
+
ttl: str = "4h",
|
|
148
|
+
idle_timeout: Optional[str] = None,
|
|
149
|
+
spot: bool = False,
|
|
150
|
+
on_complete: str = "terminate",
|
|
151
|
+
slack_workspace: Optional[str] = None,
|
|
152
|
+
active_processes: Optional[List[str]] = None,
|
|
153
|
+
phone: Optional[str] = None,
|
|
154
|
+
wait: bool = False,
|
|
155
|
+
) -> Instance:
|
|
156
|
+
"""
|
|
157
|
+
Launch an EC2 instance.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
instance_type: EC2 instance type, e.g. "c8a.2xlarge".
|
|
161
|
+
name: Instance name (auto-generated if omitted).
|
|
162
|
+
region: AWS region (default: client region).
|
|
163
|
+
ttl: Time-to-live, e.g. "8h", "2d". Hard termination deadline.
|
|
164
|
+
idle_timeout: Stop if idle for this duration, e.g. "30m".
|
|
165
|
+
spot: Use Spot pricing.
|
|
166
|
+
on_complete: Action on SPAWN_COMPLETE: "terminate", "stop", "hibernate".
|
|
167
|
+
slack_workspace: Slack workspace ID for lifecycle notifications.
|
|
168
|
+
active_processes: Process names that indicate active work (e.g. ["rsession"]).
|
|
169
|
+
phone: Phone number for SMS notifications (+1XXXXXXXXXX).
|
|
170
|
+
wait: If True, block until instance is running.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Instance object.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
>>> inst = spore.spawn.launch(
|
|
177
|
+
... "c8a.2xlarge",
|
|
178
|
+
... name="my-analysis",
|
|
179
|
+
... ttl="12h",
|
|
180
|
+
... idle_timeout="30m",
|
|
181
|
+
... )
|
|
182
|
+
"""
|
|
183
|
+
raise NotImplementedError(
|
|
184
|
+
"spawn.launch() requires the spore.host CLI or spored on an EC2 instance. "
|
|
185
|
+
"Use the CLI: spawn launch --instance-type c8a.2xlarge --ttl 12h\n"
|
|
186
|
+
"This SDK method will be implemented when the REST API launch endpoint is complete."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def list(
|
|
190
|
+
self,
|
|
191
|
+
state: str = "running",
|
|
192
|
+
region: Optional[str] = None,
|
|
193
|
+
) -> List[Instance]:
|
|
194
|
+
"""
|
|
195
|
+
List spawn-managed instances.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
state: Filter by state: "running", "stopped", "all".
|
|
199
|
+
region: AWS region (all regions if omitted).
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> running = spore.spawn.list()
|
|
203
|
+
>>> for inst in running:
|
|
204
|
+
... print(inst.name, inst.state)
|
|
205
|
+
"""
|
|
206
|
+
params: dict = {"state": state}
|
|
207
|
+
if region:
|
|
208
|
+
params["region"] = region
|
|
209
|
+
data = self._c.get("/v1/instances", params=params)
|
|
210
|
+
return [self._parse(i) for i in data.get("instances", [])]
|
|
211
|
+
|
|
212
|
+
def status(self, instance_id_or_name: str) -> Instance:
|
|
213
|
+
"""
|
|
214
|
+
Get detailed status for a single instance.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
>>> inst = spore.spawn.status("sim-run-42")
|
|
218
|
+
>>> print(inst.state, inst.ttl)
|
|
219
|
+
"""
|
|
220
|
+
data = self._c.get(f"/v1/instances/{instance_id_or_name}")
|
|
221
|
+
return self._parse(data)
|
|
222
|
+
|
|
223
|
+
def stop(self, instance_id_or_name: str, hibernate: bool = False) -> Instance:
|
|
224
|
+
"""Stop a running instance."""
|
|
225
|
+
action = "hibernate" if hibernate else "stop"
|
|
226
|
+
data = self._action(instance_id_or_name, action)
|
|
227
|
+
return self.status(instance_id_or_name)
|
|
228
|
+
|
|
229
|
+
def start(self, instance_id_or_name: str) -> Instance:
|
|
230
|
+
"""Start a stopped instance."""
|
|
231
|
+
self._action(instance_id_or_name, "start")
|
|
232
|
+
return self.status(instance_id_or_name)
|
|
233
|
+
|
|
234
|
+
def terminate(self, instance_id_or_name: str) -> dict:
|
|
235
|
+
"""Permanently terminate an instance."""
|
|
236
|
+
return self._action(instance_id_or_name, "terminate")
|
|
237
|
+
|
|
238
|
+
def extend(self, instance_id_or_name: str, duration: str) -> dict:
|
|
239
|
+
"""
|
|
240
|
+
Extend an instance's TTL deadline.
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
>>> spore.spawn.extend("sim-run-42", "4h")
|
|
244
|
+
"""
|
|
245
|
+
return self._action(instance_id_or_name, "extend", body={"duration": duration})
|
|
246
|
+
|
|
247
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def _action(
|
|
250
|
+
self,
|
|
251
|
+
instance_id_or_name: str,
|
|
252
|
+
action: str,
|
|
253
|
+
region: Optional[str] = None,
|
|
254
|
+
body: Optional[dict] = None,
|
|
255
|
+
) -> dict:
|
|
256
|
+
path = f"/v1/instances/{instance_id_or_name}/{action}"
|
|
257
|
+
return self._c.post(path, body or {})
|
|
258
|
+
|
|
259
|
+
def _parse(self, d: dict) -> Instance:
|
|
260
|
+
launch_time = None
|
|
261
|
+
if d.get("launch_time"):
|
|
262
|
+
try:
|
|
263
|
+
launch_time = datetime.fromisoformat(d["launch_time"].replace("Z", "+00:00"))
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
inst = Instance(
|
|
267
|
+
instance_id=d.get("instance_id", ""),
|
|
268
|
+
name=d.get("name", ""),
|
|
269
|
+
instance_type=d.get("instance_type", ""),
|
|
270
|
+
state=d.get("state", ""),
|
|
271
|
+
region=d.get("region", ""),
|
|
272
|
+
public_ip=d.get("public_ip", ""),
|
|
273
|
+
dns=d.get("dns", ""),
|
|
274
|
+
launch_time=launch_time,
|
|
275
|
+
ttl=d.get("ttl", ""),
|
|
276
|
+
idle_timeout=d.get("idle_timeout", ""),
|
|
277
|
+
)
|
|
278
|
+
inst._client = self
|
|
279
|
+
return inst
|
spore/truffle.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""truffle — EC2 instance discovery: search, spot prices, quota checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .client import Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class InstanceType:
|
|
14
|
+
instance_type: str
|
|
15
|
+
region: str
|
|
16
|
+
vcpus: int
|
|
17
|
+
memory_gib: float
|
|
18
|
+
architecture: str
|
|
19
|
+
on_demand_price: float = 0.0
|
|
20
|
+
gpus: int = 0
|
|
21
|
+
gpu_model: str = ""
|
|
22
|
+
gpu_memory_gib: float = 0.0
|
|
23
|
+
available_azs: List[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def memory_gb(self) -> float:
|
|
27
|
+
return self.memory_gib
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
gpu = f" {self.gpus}×{self.gpu_model}" if self.gpus else ""
|
|
31
|
+
price = f" ${self.on_demand_price:.4f}/hr" if self.on_demand_price else ""
|
|
32
|
+
return f"<InstanceType {self.instance_type} {self.vcpus}vCPU {self.memory_gib:.0f}GiB{gpu}{price}>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SpotPrice:
|
|
37
|
+
instance_type: str
|
|
38
|
+
region: str
|
|
39
|
+
availability_zone: str
|
|
40
|
+
spot_price: float
|
|
41
|
+
on_demand_price: float
|
|
42
|
+
savings_pct: float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class QuotaInfo:
|
|
47
|
+
instance_type: str
|
|
48
|
+
region: str
|
|
49
|
+
vcpus: int
|
|
50
|
+
can_launch: bool
|
|
51
|
+
message: str
|
|
52
|
+
spot: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TruffleClient:
|
|
56
|
+
def __init__(self, client: "Client"):
|
|
57
|
+
self._c = client
|
|
58
|
+
|
|
59
|
+
def find(
|
|
60
|
+
self,
|
|
61
|
+
query: str,
|
|
62
|
+
region: Optional[str] = None,
|
|
63
|
+
regions: Optional[List[str]] = None,
|
|
64
|
+
) -> List[InstanceType]:
|
|
65
|
+
"""
|
|
66
|
+
Find EC2 instance types matching a natural language query.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
query: Natural language description, e.g. "nvidia h100 8gpu",
|
|
70
|
+
"amd epyc genoa", "arm64 64gb memory".
|
|
71
|
+
region: Single region to search (e.g. "us-east-1").
|
|
72
|
+
regions: Multiple regions (overrides region).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of InstanceType objects sorted by price.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> results = spore.truffle.find("amd epyc genoa", region="us-east-1")
|
|
79
|
+
>>> for r in results:
|
|
80
|
+
... print(r.instance_type, f"${r.on_demand_price:.4f}/hr")
|
|
81
|
+
"""
|
|
82
|
+
params: dict = {"q": query}
|
|
83
|
+
if regions:
|
|
84
|
+
params["region"] = ",".join(regions)
|
|
85
|
+
elif region:
|
|
86
|
+
params["region"] = region
|
|
87
|
+
|
|
88
|
+
data = self._c.get("/v1/search", params=params)
|
|
89
|
+
return [self._parse(r) for r in data.get("results", [])]
|
|
90
|
+
|
|
91
|
+
def spot(
|
|
92
|
+
self,
|
|
93
|
+
instance_type: str,
|
|
94
|
+
region: Optional[str] = None,
|
|
95
|
+
regions: Optional[List[str]] = None,
|
|
96
|
+
) -> List[SpotPrice]:
|
|
97
|
+
"""
|
|
98
|
+
Get current Spot prices for an instance type across regions/AZs.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> prices = spore.truffle.spot("c8a.2xlarge", region="us-east-1")
|
|
102
|
+
>>> cheapest = min(prices, key=lambda p: p.spot_price)
|
|
103
|
+
"""
|
|
104
|
+
params: dict = {"type": instance_type}
|
|
105
|
+
if regions:
|
|
106
|
+
params["region"] = ",".join(regions)
|
|
107
|
+
elif region:
|
|
108
|
+
params["region"] = region
|
|
109
|
+
|
|
110
|
+
data = self._c.get("/v1/spot", params=params)
|
|
111
|
+
return [
|
|
112
|
+
SpotPrice(
|
|
113
|
+
instance_type=p["instance_type"],
|
|
114
|
+
region=p["region"],
|
|
115
|
+
availability_zone=p["availability_zone"],
|
|
116
|
+
spot_price=float(p["spot_price"]),
|
|
117
|
+
on_demand_price=float(p.get("on_demand_price", 0)),
|
|
118
|
+
savings_pct=float(p.get("savings_percent", 0)),
|
|
119
|
+
)
|
|
120
|
+
for p in data.get("prices", [])
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def quota(
|
|
124
|
+
self,
|
|
125
|
+
instance_type: str,
|
|
126
|
+
region: str,
|
|
127
|
+
spot: bool = False,
|
|
128
|
+
) -> QuotaInfo:
|
|
129
|
+
"""
|
|
130
|
+
Check whether your AWS account has enough quota to launch an instance type.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> q = spore.truffle.quota("p4d.24xlarge", region="us-east-1")
|
|
134
|
+
>>> if not q.can_launch:
|
|
135
|
+
... print(q.message)
|
|
136
|
+
"""
|
|
137
|
+
params = {"type": instance_type, "region": region, "spot": str(spot).lower()}
|
|
138
|
+
data = self._c.get("/v1/quota", params=params)
|
|
139
|
+
return QuotaInfo(
|
|
140
|
+
instance_type=instance_type,
|
|
141
|
+
region=region,
|
|
142
|
+
vcpus=int(data.get("vcpus", 0)),
|
|
143
|
+
can_launch=bool(data.get("can_launch", False)),
|
|
144
|
+
message=data.get("message", ""),
|
|
145
|
+
spot=spot,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _parse(self, r: dict) -> InstanceType:
|
|
149
|
+
return InstanceType(
|
|
150
|
+
instance_type=r.get("instance_type", ""),
|
|
151
|
+
region=r.get("region", ""),
|
|
152
|
+
vcpus=int(r.get("v_cp_us", r.get("vcpus", 0))),
|
|
153
|
+
memory_gib=float(r.get("memory_mi_b", r.get("memory_gib", 0))) / 1024
|
|
154
|
+
if r.get("memory_mi_b") else float(r.get("memory_gib", 0)),
|
|
155
|
+
architecture=r.get("architecture", ""),
|
|
156
|
+
on_demand_price=float(r.get("on_demand_price", 0)),
|
|
157
|
+
gpus=int(r.get("gp_us", r.get("gpus", 0))),
|
|
158
|
+
gpu_model=r.get("gpu_model", ""),
|
|
159
|
+
gpu_memory_gib=float(r.get("gpu_memory_mi_b", 0)) / 1024,
|
|
160
|
+
available_azs=r.get("available_a_zs", r.get("available_azs", [])),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ── Notebook display ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _repr_html_(self) -> str:
|
|
166
|
+
return "<em>spore.truffle — use .find(), .spot(), .quota()</em>"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spore-host
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for spore.host — ephemeral EC2 compute for researchers
|
|
5
|
+
Project-URL: Homepage, https://spore.host
|
|
6
|
+
Project-URL: Documentation, https://docs.spore.host
|
|
7
|
+
Project-URL: Repository, https://github.com/scttfrdmn/spore-host
|
|
8
|
+
Project-URL: Issues, https://github.com/scttfrdmn/spore-host/issues
|
|
9
|
+
Author-email: Scott Friedman <scttfrdmn@gmail.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: aws,compute,ec2,hpc,research
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: boto3>=1.34
|
|
23
|
+
Requires-Dist: requests>=2.31
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Provides-Extra: jupyter
|
|
30
|
+
Requires-Dist: ipython>=8.0; extra == 'jupyter'
|
|
31
|
+
Requires-Dist: ipywidgets>=8.0; extra == 'jupyter'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# spore-host
|
|
35
|
+
|
|
36
|
+
Python SDK for [spore.host](https://spore.host) — ephemeral EC2 compute for researchers.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install spore-host
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import spore
|
|
46
|
+
|
|
47
|
+
# Find instances
|
|
48
|
+
results = spore.truffle.find("amd epyc genoa", region="us-east-1")
|
|
49
|
+
for r in results:
|
|
50
|
+
print(r.instance_type, f"${r.on_demand_price:.4f}/hr")
|
|
51
|
+
|
|
52
|
+
# Check Spot prices
|
|
53
|
+
prices = spore.truffle.spot("c8a.2xlarge", region="us-east-1")
|
|
54
|
+
cheapest = min(prices, key=lambda p: p.spot_price)
|
|
55
|
+
|
|
56
|
+
# Manage running instances
|
|
57
|
+
instances = spore.spawn.list()
|
|
58
|
+
inst = spore.spawn.status("sim-run-42")
|
|
59
|
+
inst.extend("2h") # push the TTL deadline forward
|
|
60
|
+
inst.wait("terminated", on_status=lambda i: print(i.state))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Works in Jupyter notebooks, [marimo](https://marimo.io), and plain Python scripts.
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
Full documentation at **[docs.spore.host/guides/python-sdk](https://docs.spore.host/guides/python-sdk)**
|
|
68
|
+
|
|
69
|
+
- [Jupyter example notebook](examples/jupyter_example.ipynb)
|
|
70
|
+
- [marimo example](examples/marimo_example.py)
|
|
71
|
+
- [Script example](examples/script_example.py)
|
|
72
|
+
|
|
73
|
+
## Requirements
|
|
74
|
+
|
|
75
|
+
- Python 3.9+
|
|
76
|
+
- AWS credentials configured (`~/.aws/credentials` or environment variables)
|
|
77
|
+
- `boto3`, `requests`
|
|
78
|
+
|
|
79
|
+
Optional notebook extras: `pip install "spore-host[jupyter]"`
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
Apache 2.0 — see [LICENSE](../../LICENSE)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
spore/__init__.py,sha256=pzYwu6OvGe7yGfnNRN1kCN78SM4GY_fEnt-uEuOwLBQ,897
|
|
2
|
+
spore/client.py,sha256=cbCormV1I6znoYVXZ3luqlOvPVGp3qYuhmrlm_cFTB4,3181
|
|
3
|
+
spore/spawn.py,sha256=4bfGFFgY8qjlIHgjw2CKGxirBlks00OHAjps8uXlE-s,10472
|
|
4
|
+
spore/truffle.py,sha256=LjOnmcDX8W-fsXwhLNp-QdqqUpjvidGgENe3mnZy0cg,5409
|
|
5
|
+
spore_host-0.1.0.dist-info/METADATA,sha256=N2qrpPzb9VTKAijUOgOLcNy1ZHa5q-l65u4q1GJkRsI,2663
|
|
6
|
+
spore_host-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
spore_host-0.1.0.dist-info/RECORD,,
|