Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-python-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ jobs:
- name: Install
run: pip install -e ".[dev]"
- name: Test
run: pytest tests/ -v --tb=short 2>/dev/null || echo "No tests yet"
run: pytest tests/ -v --tb=short
32 changes: 31 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ Release tags use the `python-vX.Y.Z` prefix.

## [Unreleased]

## [0.1.3] - 2026-07-09

### Fixed
- **`spore.truffle` / `spore.spawn` now work as documented.** The top-level
quickstart (`import spore; spore.truffle.find(...)`) resolved `spore.truffle`
to the *module* (which has no `.find`), because the same-named submodule
shadowed the module-level `__getattr__` hook — and importing it is unavoidable
(the client does it internally). The implementation modules are renamed to
`spore._truffle` / `spore._spawn` (private), and `spore.truffle`/`spore.spawn`
are now robust lazy proxies to a default client's sub-clients. Public classes
are re-exported from the top level: `from spore import Client, SpawnClient,
TruffleClient, Instance, InstanceType, SpotPrice, QuotaInfo`.
- **`spore.spawn.launch()` no longer raises `TypeError`.** It constructed
`Instance(private_ip=…, availability_zone=…)` but those fields didn't exist on
the dataclass. Added `private_ip` / `availability_zone` to `Instance` (populated
by launch/status/list), and `launch()` now builds its `Instance` through the
same `_parse` path as the rest of the client.
- **`spore.truffle.find()` no longer returns zeroed memory, GPU memory, and AZs.**
The parser read mangled JSON keys (`memory_mi_b`, `gpu_memory_mi_b`,
`available_a_zs`) that the REST API never sends; it now reads the real keys
(`memory_mib`, `gpu_memory_mib`, `availability_zones`, `vcpus`, `gpus`), so
`memory_gib` / `gpu_memory_gib` / `available_azs` are populated correctly.

### Added
- Test suite (`tests/test_sdk.py`) covering the quickstart entry points and the
parsers against the real REST API JSON keys. CI now runs it as a gate (it
previously swallowed a missing/failing suite with `|| echo "No tests yet"`),
so a broken SDK can no longer ship green.

## [0.1.2]

Baseline. Earlier history is in the
Expand All @@ -16,5 +45,6 @@ Baseline. Earlier history is in the

---

[Unreleased]: https://github.com/spore-host/python-sdk/compare/python-v0.1.2...HEAD
[Unreleased]: https://github.com/spore-host/python-sdk/compare/python-v0.1.3...HEAD
[0.1.3]: https://github.com/spore-host/python-sdk/compare/python-v0.1.2...python-v0.1.3
[0.1.2]: https://github.com/spore-host/python-sdk/releases/tag/python-v0.1.2
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "spore-host"
version = "0.1.2"
version = "0.1.3"
description = "Python SDK for spore.host — ephemeral EC2 compute for researchers"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
67 changes: 55 additions & 12 deletions spore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,23 @@
import spore
results = spore.truffle.find("nvidia h100", region="us-east-1")
instance = spore.spawn.launch("c8a.2xlarge", ttl="8h")

Or with an explicit client:
from spore import Client
c = Client(region="us-east-1")
c.truffle.find("amd epyc genoa")
"""

# Defer annotation evaluation so `_default: Client | None` (PEP 604 union) doesn't
# execute at import on Python 3.9 (requires-python >=3.9); without this it raises
# `TypeError: unsupported operand type(s) for |`.
from __future__ import annotations

from .client import Client
from . import truffle, spawn
from ._spawn import Instance, SpawnClient
from ._truffle import InstanceType, QuotaInfo, SpotPrice, TruffleClient

# Module-level convenience: a default client using ambient AWS credentials
# Module-level convenience: a default client using ambient AWS credentials.
_default: Client | None = None


Expand All @@ -21,15 +32,47 @@ def _get_default() -> Client:
return _default


def __getattr__(name: str):
# Allow `spore.truffle.find(...)` and `spore.spawn.launch(...)` at module level
default = _get_default()
if name == "truffle":
return default.truffle
if name == "spawn":
return default.spawn
raise AttributeError(f"module 'spore' has no attribute {name!r}")
class _LazySubClient:
"""Proxy for `spore.truffle` / `spore.spawn` that forwards to the default
client's sub-client, constructing the default `Client()` on first use (so
`import spore` has no credential-resolution side effect).

The implementation modules are named ``_truffle`` / ``_spawn`` (private) so
these public proxy attributes never collide with a submodule. An earlier
version kept the modules named ``truffle``/``spawn`` and relied on a
module-level ``__getattr__``, but importing a same-named submodule (which
``Client.__init__`` does) permanently rebinds ``spore.truffle`` to the module
and shadows the hook — so ``spore.truffle`` resolved to the method-less module
(bug #2). Private module names remove the collision entirely.
"""

__slots__ = ("_attr",)

def __init__(self, attr: str):
self._attr = attr

def _target(self):
return getattr(_get_default(), self._attr)

def __getattr__(self, name: str):
return getattr(self._target(), name)

def __repr__(self) -> str:
return repr(self._target())


truffle = _LazySubClient("truffle")
spawn = _LazySubClient("spawn")

__version__ = "0.1.2"
__all__ = ["Client", "truffle", "spawn"]
__version__ = "0.1.3"
__all__ = [
"Client",
"truffle",
"spawn",
"SpawnClient",
"TruffleClient",
"Instance",
"InstanceType",
"SpotPrice",
"QuotaInfo",
]
24 changes: 11 additions & 13 deletions spore/spawn.py → spore/_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import time
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, List, Optional, TYPE_CHECKING
Expand All @@ -22,6 +21,8 @@ class Instance:
state: str
region: str
public_ip: str = ""
private_ip: str = ""
availability_zone: str = ""
dns: str = ""
launch_time: Optional[datetime] = None
ttl: str = ""
Expand Down Expand Up @@ -199,17 +200,12 @@ def launch(
body["active_processes"] = ",".join(active_processes)

data = self._c.post("/v1/instances", body)
inst = Instance(
instance_id=data.get("instance_id", ""),
name=data.get("name", name or ""),
instance_type=instance_type,
state=data.get("state", "pending"),
region=data.get("region", region or self._c._region),
public_ip=data.get("public_ip", ""),
private_ip=data.get("private_ip", ""),
availability_zone=data.get("availability_zone", ""),
)
inst._client = self
# Build via _parse (single source of truth for API→Instance mapping). The
# launch response echoes name/public_ip/private_ip/availability_zone/state/
# region but NOT instance_type, so fall back to the requested type.
data.setdefault("instance_type", instance_type)
data.setdefault("state", "pending")
inst = self._parse(data)

if wait:
inst.wait_running()
Expand Down Expand Up @@ -252,7 +248,7 @@ def status(self, instance_id_or_name: str) -> Instance:
def stop(self, instance_id_or_name: str, hibernate: bool = False) -> Instance:
"""Stop a running instance."""
action = "hibernate" if hibernate else "stop"
data = self._action(instance_id_or_name, action)
self._action(instance_id_or_name, action)
return self.status(instance_id_or_name)

def start(self, instance_id_or_name: str) -> Instance:
Expand Down Expand Up @@ -299,6 +295,8 @@ def _parse(self, d: dict) -> Instance:
state=d.get("state", ""),
region=d.get("region", ""),
public_ip=d.get("public_ip", ""),
private_ip=d.get("private_ip", ""),
availability_zone=d.get("availability_zone", ""),
dns=d.get("dns", ""),
launch_time=launch_time,
ttl=d.get("ttl", ""),
Expand Down
15 changes: 9 additions & 6 deletions spore/truffle.py → spore/_truffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,21 @@ def quota(
)

def _parse(self, r: dict) -> InstanceType:
# Keys match the REST API's truffleaws.InstanceTypeResult json tags:
# vcpus, memory_mib, gpus, gpu_memory_mib, availability_zones (MiB→GiB
# here). Earlier code guessed mangled keys (memory_mi_b, gpu_memory_mi_b,
# available_a_zs), so memory/GPU-memory/AZs were silently zeroed (#2).
return InstanceType(
instance_type=r.get("instance_type", ""),
region=r.get("region", ""),
vcpus=int(r.get("v_cp_us", r.get("vcpus", 0))),
memory_gib=float(r.get("memory_mi_b", r.get("memory_gib", 0))) / 1024
if r.get("memory_mi_b") else float(r.get("memory_gib", 0)),
vcpus=int(r.get("vcpus", 0)),
memory_gib=float(r.get("memory_mib", 0)) / 1024,
architecture=r.get("architecture", ""),
on_demand_price=float(r.get("on_demand_price", 0)),
gpus=int(r.get("gp_us", r.get("gpus", 0))),
gpus=int(r.get("gpus", 0)),
gpu_model=r.get("gpu_model", ""),
gpu_memory_gib=float(r.get("gpu_memory_mi_b", 0)) / 1024,
available_azs=r.get("available_a_zs", r.get("available_azs", [])),
gpu_memory_gib=float(r.get("gpu_memory_mib", 0)) / 1024,
available_azs=r.get("availability_zones", []),
)

# ── Notebook display ──────────────────────────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions spore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ def __init__(
self._session: Optional[boto3.Session] = None

# Sub-clients
from .truffle import TruffleClient
from .spawn import SpawnClient
from ._truffle import TruffleClient
from ._spawn import SpawnClient
self.truffle = TruffleClient(self)
self.spawn = SpawnClient(self)

Expand Down
Loading
Loading