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 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} &nbsp;|&nbsp; '
127
+ f'State: <span style="color:{state_colour};font-weight:600">{self.state}</span><br>'
128
+ f'Region: {self.region} &nbsp;|&nbsp; IP: {self.public_ip or "—"}<br>'
129
+ f'TTL: {self.ttl or "—"} &nbsp;|&nbsp; 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any