spore-host 0.1.0__tar.gz

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.
@@ -0,0 +1,58 @@
1
+ # Binaries
2
+ *.exe
3
+ *.exe~
4
+ *.dll
5
+ *.so
6
+ *.dylib
7
+ /bin/
8
+ truffle/bin/
9
+ spawn/bin/
10
+
11
+ # Lambda binaries
12
+ bootstrap
13
+ function.zip
14
+ **/lambda/*/bootstrap
15
+ **/lambda/*-orchestrator
16
+ **/lambda/*-handler
17
+
18
+ # Test binary, built with `go test -c`
19
+ *.test
20
+
21
+ # Output of the go coverage tool
22
+ *.out
23
+
24
+ # Go workspace file
25
+ go.work
26
+
27
+ # Dependency directories
28
+ vendor/
29
+
30
+ # Build artifacts
31
+ dist/
32
+
33
+ # IDE
34
+ .vscode/
35
+ .idea/
36
+ *.swp
37
+ *.swo
38
+ *~
39
+
40
+ # OS
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Temporary files
45
+ *.log
46
+ /tmp/
47
+
48
+ # Release artifacts
49
+ *.tar.gz
50
+ *.zip
51
+ checksums.txt
52
+
53
+ # Configuration (credentials)
54
+ /config/
55
+
56
+ # Local archive (not pushed to GitHub)
57
+ .local-archive/
58
+ spawn/lambda/rest-api/rest-api
@@ -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,50 @@
1
+ # spore-host
2
+
3
+ Python SDK for [spore.host](https://spore.host) — ephemeral EC2 compute for researchers.
4
+
5
+ ```sh
6
+ pip install spore-host
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ import spore
13
+
14
+ # Find instances
15
+ results = spore.truffle.find("amd epyc genoa", region="us-east-1")
16
+ for r in results:
17
+ print(r.instance_type, f"${r.on_demand_price:.4f}/hr")
18
+
19
+ # Check Spot prices
20
+ prices = spore.truffle.spot("c8a.2xlarge", region="us-east-1")
21
+ cheapest = min(prices, key=lambda p: p.spot_price)
22
+
23
+ # Manage running instances
24
+ instances = spore.spawn.list()
25
+ inst = spore.spawn.status("sim-run-42")
26
+ inst.extend("2h") # push the TTL deadline forward
27
+ inst.wait("terminated", on_status=lambda i: print(i.state))
28
+ ```
29
+
30
+ Works in Jupyter notebooks, [marimo](https://marimo.io), and plain Python scripts.
31
+
32
+ ## Documentation
33
+
34
+ Full documentation at **[docs.spore.host/guides/python-sdk](https://docs.spore.host/guides/python-sdk)**
35
+
36
+ - [Jupyter example notebook](examples/jupyter_example.ipynb)
37
+ - [marimo example](examples/marimo_example.py)
38
+ - [Script example](examples/script_example.py)
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.9+
43
+ - AWS credentials configured (`~/.aws/credentials` or environment variables)
44
+ - `boto3`, `requests`
45
+
46
+ Optional notebook extras: `pip install "spore-host[jupyter]"`
47
+
48
+ ## License
49
+
50
+ Apache 2.0 — see [LICENSE](../../LICENSE)
@@ -0,0 +1,197 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# spore.host — Jupyter Notebook Example\n",
8
+ "\n",
9
+ "This notebook shows how to use the `spore-host` Python SDK to find, launch, and manage EC2 instances directly from a notebook.\n",
10
+ "\n",
11
+ "**Prerequisites:**\n",
12
+ "```sh\n",
13
+ "pip install spore-host\n",
14
+ "export SPORE_API_KEY=sk_... # or AWS credentials already configured\n",
15
+ "```"
16
+ ]
17
+ },
18
+ {
19
+ "cell_type": "code",
20
+ "execution_count": null,
21
+ "metadata": {},
22
+ "outputs": [],
23
+ "source": [
24
+ "import spore\n",
25
+ "print(f\"spore-host {spore.__version__}\")"
26
+ ]
27
+ },
28
+ {
29
+ "cell_type": "markdown",
30
+ "metadata": {},
31
+ "source": [
32
+ "## 1. Find the right instance\n",
33
+ "\n",
34
+ "`spore.truffle.find()` searches EC2 instance types using natural language."
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "code",
39
+ "execution_count": null,
40
+ "metadata": {},
41
+ "outputs": [],
42
+ "source": [
43
+ "results = spore.truffle.find(\"amd epyc genoa\", region=\"us-east-1\")\n",
44
+ "\n",
45
+ "# Rich display in Jupyter\n",
46
+ "import pandas as pd\n",
47
+ "pd.DataFrame([\n",
48
+ " {\n",
49
+ " \"Instance\": r.instance_type,\n",
50
+ " \"vCPUs\": r.vcpus,\n",
51
+ " \"Memory (GiB)\": r.memory_gib,\n",
52
+ " \"$/hr\": f\"${r.on_demand_price:.4f}\",\n",
53
+ " \"AZs\": \", \".join(r.available_azs),\n",
54
+ " }\n",
55
+ " for r in results\n",
56
+ "])"
57
+ ]
58
+ },
59
+ {
60
+ "cell_type": "markdown",
61
+ "metadata": {},
62
+ "source": [
63
+ "## 2. Check Spot prices"
64
+ ]
65
+ },
66
+ {
67
+ "cell_type": "code",
68
+ "execution_count": null,
69
+ "metadata": {},
70
+ "outputs": [],
71
+ "source": [
72
+ "prices = spore.truffle.spot(\"c8a.2xlarge\", region=\"us-east-1\")\n",
73
+ "cheapest = min(prices, key=lambda p: p.spot_price)\n",
74
+ "print(f\"Cheapest Spot: {cheapest.availability_zone} @ ${cheapest.spot_price:.4f}/hr ({cheapest.savings_pct:.0f}% savings)\")"
75
+ ]
76
+ },
77
+ {
78
+ "cell_type": "markdown",
79
+ "metadata": {},
80
+ "source": [
81
+ "## 3. Check your account's quota"
82
+ ]
83
+ },
84
+ {
85
+ "cell_type": "code",
86
+ "execution_count": null,
87
+ "metadata": {},
88
+ "outputs": [],
89
+ "source": [
90
+ "quota = spore.truffle.quota(\"c8a.2xlarge\", region=\"us-east-1\")\n",
91
+ "status = \"✅ Can launch\" if quota.can_launch else \"❌ Quota insufficient\"\n",
92
+ "print(f\"{status}: {quota.message}\")"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "markdown",
97
+ "metadata": {},
98
+ "source": [
99
+ "## 4. Check running instances"
100
+ ]
101
+ },
102
+ {
103
+ "cell_type": "code",
104
+ "execution_count": null,
105
+ "metadata": {},
106
+ "outputs": [],
107
+ "source": [
108
+ "instances = spore.spawn.list()\n",
109
+ "if not instances:\n",
110
+ " print(\"No running instances\")\n",
111
+ "else:\n",
112
+ " for inst in instances:\n",
113
+ " display(inst) # uses _repr_html_"
114
+ ]
115
+ },
116
+ {
117
+ "cell_type": "markdown",
118
+ "metadata": {},
119
+ "source": [
120
+ "## 5. Manage an existing instance\n",
121
+ "\n",
122
+ "Replace `\"sim-run-42\"` with your instance name."
123
+ ]
124
+ },
125
+ {
126
+ "cell_type": "code",
127
+ "execution_count": null,
128
+ "metadata": {},
129
+ "outputs": [],
130
+ "source": [
131
+ "inst = spore.spawn.status(\"sim-run-42\")\n",
132
+ "display(inst)\n",
133
+ "\n",
134
+ "# Extend TTL if needed\n",
135
+ "# inst.extend(\"2h\")\n",
136
+ "\n",
137
+ "# Wake a stopped instance\n",
138
+ "# inst.start()\n",
139
+ "\n",
140
+ "# Wait for job to finish (non-blocking poll with callback)\n",
141
+ "# inst.wait(\"terminated\", poll_interval=60, on_status=lambda i: print(f\"{i.state}\"))"
142
+ ]
143
+ },
144
+ {
145
+ "cell_type": "markdown",
146
+ "metadata": {},
147
+ "source": [
148
+ "## 6. Launch and wait (full workflow)\n",
149
+ "\n",
150
+ "> **Note:** `spawn.launch()` is coming in the next SDK release. Use the CLI for now:\n",
151
+ "> ```sh\n",
152
+ "> spawn launch --name my-job --instance-type c8a.2xlarge --ttl 8h --idle-timeout 30m\n",
153
+ "> ```\n",
154
+ "\n",
155
+ "Once launch is available, the full pattern will be:"
156
+ ]
157
+ },
158
+ {
159
+ "cell_type": "code",
160
+ "execution_count": null,
161
+ "metadata": {},
162
+ "outputs": [],
163
+ "source": [
164
+ "# Full workflow (launch available in next release)\n",
165
+ "#\n",
166
+ "# inst = spore.spawn.launch(\n",
167
+ "# \"c8a.2xlarge\",\n",
168
+ "# name=\"notebook-job\",\n",
169
+ "# ttl=\"8h\",\n",
170
+ "# idle_timeout=\"30m\",\n",
171
+ "# on_complete=\"terminate\",\n",
172
+ "# )\n",
173
+ "#\n",
174
+ "# # Poll with progress bar\n",
175
+ "# from tqdm.notebook import tqdm\n",
176
+ "# with tqdm(desc=\"Waiting for job\", unit=\"poll\") as pbar:\n",
177
+ "# inst.wait(\"terminated\", on_status=lambda i: pbar.set_postfix(state=i.state))\n",
178
+ "#\n",
179
+ "# print(f\"Done. Instance {inst.name} terminated.\")\n",
180
+ "print(\"launch() coming soon — use spawn CLI to launch instances\")"
181
+ ]
182
+ }
183
+ ],
184
+ "metadata": {
185
+ "kernelspec": {
186
+ "display_name": "Python 3",
187
+ "language": "python",
188
+ "name": "python3"
189
+ },
190
+ "language_info": {
191
+ "name": "python",
192
+ "version": "3.11.0"
193
+ }
194
+ },
195
+ "nbformat": 4,
196
+ "nbformat_minor": 5
197
+ }
@@ -0,0 +1,149 @@
1
+ """
2
+ spore.host — marimo notebook example
3
+
4
+ Run with:
5
+ pip install marimo spore-host
6
+ marimo edit marimo_example.py
7
+ """
8
+
9
+ import marimo as mo
10
+
11
+ app = mo.App(width="medium")
12
+
13
+
14
+ @app.cell
15
+ def _():
16
+ import spore
17
+ mo.md(f"**spore-host** {spore.__version__} — ephemeral EC2 compute")
18
+
19
+
20
+ @app.cell
21
+ def _(mo):
22
+ mo.md("""
23
+ ## Find instances
24
+
25
+ Search for EC2 instance types using natural language.
26
+ """)
27
+
28
+
29
+ @app.cell
30
+ def _(mo):
31
+ query = mo.ui.text(
32
+ value="amd epyc genoa",
33
+ label="Search query",
34
+ placeholder="e.g. nvidia h100, arm64 32gb, amd genoa",
35
+ )
36
+ region = mo.ui.dropdown(
37
+ options=["us-east-1", "us-west-2", "eu-west-1", "ap-northeast-1"],
38
+ value="us-east-1",
39
+ label="Region",
40
+ )
41
+ mo.hstack([query, region])
42
+
43
+
44
+ @app.cell
45
+ def _(mo, query, region):
46
+ import spore
47
+
48
+ results = spore.truffle.find(query.value, region=region.value)
49
+
50
+ if not results:
51
+ mo.callout(mo.md("No results. Try a different query."), kind="warn")
52
+ else:
53
+ mo.table([
54
+ {
55
+ "Instance": r.instance_type,
56
+ "vCPUs": r.vcpus,
57
+ "Memory (GiB)": f"{r.memory_gib:.0f}",
58
+ "$/hr": f"${r.on_demand_price:.4f}" if r.on_demand_price else "—",
59
+ "AZs": len(r.available_azs),
60
+ }
61
+ for r in results
62
+ ])
63
+
64
+
65
+ @app.cell
66
+ def _(mo):
67
+ mo.md("""
68
+ ## Running instances
69
+
70
+ Current spawn-managed instances in your account.
71
+ """)
72
+
73
+
74
+ @app.cell
75
+ def _(mo):
76
+ refresh_btn = mo.ui.button(label="Refresh", kind="neutral")
77
+ refresh_btn
78
+
79
+
80
+ @app.cell
81
+ def _(mo, refresh_btn):
82
+ import spore
83
+ _ = refresh_btn.value # re-run when button clicked
84
+
85
+ instances = spore.spawn.list(state="all")
86
+
87
+ if not instances:
88
+ mo.callout(mo.md("No instances found."), kind="info")
89
+ else:
90
+ rows = []
91
+ for inst in instances:
92
+ rows.append({
93
+ "Name": inst.name,
94
+ "Type": inst.instance_type,
95
+ "State": inst.state,
96
+ "Region": inst.region,
97
+ "IP": inst.public_ip or "—",
98
+ "TTL": inst.ttl or "—",
99
+ })
100
+ mo.table(rows)
101
+
102
+
103
+ @app.cell
104
+ def _(mo):
105
+ mo.md("""
106
+ ## Manage an instance
107
+
108
+ Enter an instance name or ID to manage it.
109
+ """)
110
+
111
+
112
+ @app.cell
113
+ def _(mo):
114
+ inst_input = mo.ui.text(label="Instance name or ID", placeholder="sim-run-42")
115
+ inst_input
116
+
117
+
118
+ @app.cell
119
+ def _(inst_input, mo):
120
+ import spore
121
+
122
+ if not inst_input.value:
123
+ mo.stop(True, mo.md("Enter an instance name above."))
124
+
125
+ try:
126
+ inst = spore.spawn.status(inst_input.value)
127
+ state_color = {
128
+ "running": "green", "stopped": "orange",
129
+ "terminated": "gray", "pending": "blue",
130
+ }.get(inst.state, "gray")
131
+
132
+ mo.md(f"""
133
+ **{inst.name}** `{inst.instance_id}`
134
+
135
+ | Field | Value |
136
+ |-------|-------|
137
+ | Type | {inst.instance_type} |
138
+ | State | **{inst.state}** |
139
+ | Region | {inst.region} |
140
+ | IP | {inst.public_ip or "—"} |
141
+ | TTL | {inst.ttl or "—"} |
142
+ | Idle timeout | {inst.idle_timeout or "—"} |
143
+ """)
144
+ except Exception as e:
145
+ mo.callout(mo.md(f"Error: {e}"), kind="danger")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ app.run()
@@ -0,0 +1,73 @@
1
+ """
2
+ spore.host — plain Python script example
3
+
4
+ Shows how to use spore-host in a data pipeline or batch script
5
+ without a notebook environment.
6
+
7
+ Usage:
8
+ pip install spore-host
9
+ export SPORE_API_KEY=sk_...
10
+ python script_example.py
11
+ """
12
+
13
+ import spore
14
+
15
+
16
+ def find_best_instance(query: str, region: str = "us-east-1"):
17
+ """Find the best instance type for a workload."""
18
+ print(f"Searching for: {query!r} in {region}")
19
+ results = spore.truffle.find(query, region=region)
20
+
21
+ if not results:
22
+ print("No results found.")
23
+ return None
24
+
25
+ # Sort by price
26
+ results.sort(key=lambda r: r.on_demand_price)
27
+
28
+ print(f"\nFound {len(results)} instance type(s):\n")
29
+ for r in results[:5]:
30
+ gpu_info = f" {r.gpus}× {r.gpu_model}" if r.gpus else ""
31
+ print(f" {r.instance_type:<16} {r.vcpus:>3} vCPU {r.memory_gib:>6.0f} GiB{gpu_info} ${r.on_demand_price:.4f}/hr")
32
+
33
+ return results[0]
34
+
35
+
36
+ def check_running_instances():
37
+ """Print all currently running instances and their costs."""
38
+ instances = spore.spawn.list(state="running")
39
+
40
+ if not instances:
41
+ print("No running instances.")
42
+ return
43
+
44
+ print(f"\n{len(instances)} running instance(s):\n")
45
+ for inst in instances:
46
+ print(f" {inst.name:<20} {inst.instance_type:<14} {inst.region} TTL: {inst.ttl or '—'}")
47
+
48
+
49
+ def extend_if_needed(name: str, min_ttl_hours: float = 1.0):
50
+ """Extend an instance's TTL if less than min_ttl_hours remain."""
51
+ inst = spore.spawn.status(name)
52
+ print(f"{inst.name}: {inst.state} TTL: {inst.ttl or 'none'}")
53
+
54
+ # In a real script you'd parse the TTL and check remaining time
55
+ # For demo: just show how to extend
56
+ if inst.state == "running":
57
+ inst.extend("2h")
58
+ print(f" → Extended TTL by 2h")
59
+
60
+
61
+ if __name__ == "__main__":
62
+ print("=" * 60)
63
+ print("spore.host Python SDK — script example")
64
+ print("=" * 60)
65
+
66
+ # 1. Find instances
67
+ best = find_best_instance("amd epyc genoa", region="us-east-1")
68
+
69
+ # 2. Check what's running
70
+ print("\n" + "=" * 60)
71
+ check_running_instances()
72
+
73
+ print("\nDone. Use `spawn launch` CLI to launch instances.")
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "spore-host"
7
+ version = "0.1.0"
8
+ description = "Python SDK for spore.host — ephemeral EC2 compute for researchers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { name = "Scott Friedman", email = "scttfrdmn@gmail.com" },
14
+ ]
15
+ keywords = ["aws", "ec2", "compute", "research", "hpc"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Scientific/Engineering",
26
+ ]
27
+
28
+ dependencies = [
29
+ "boto3>=1.34",
30
+ "requests>=2.31",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ jupyter = ["ipywidgets>=8.0", "IPython>=8.0"]
35
+ dev = ["pytest>=7", "pytest-asyncio", "black", "ruff"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://spore.host"
39
+ Documentation = "https://docs.spore.host"
40
+ Repository = "https://github.com/scttfrdmn/spore-host"
41
+ Issues = "https://github.com/scttfrdmn/spore-host/issues"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["spore"]
@@ -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"]
@@ -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}>"
@@ -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
@@ -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>"