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
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ Release tags use the `python-vX.Y.Z` prefix.

## [Unreleased]

## [0.1.4] - 2026-07-10

### Added
- **`spore.notifications` — SMS notification registration.** New
`NotificationsClient` (`spore.notifications.register(...)` /
`.deregister(...)`) wrapping `POST` / `DELETE /v1/notifications/register` — the
one REST endpoint the SDK didn't cover. Supply your chat identity as
`platform=`/`workspace_id=`/`user_id=` (assembled into the
`platform#workspace#user` key spore-bot uses) or a raw `user_key=`. Also adds
`Client.delete()`. (#5)

### Fixed
- **`spawn.launch()` no longer advertises a dead `phone=` parameter.** It was
documented for SMS but silently did nothing — launch doesn't accept `phone`,
and SMS registration is a separate endpoint. Removed it; register a number via
`spore.notifications.register(...)` instead. (#4)

## [0.1.3] - 2026-07-09

### Fixed
Expand Down Expand Up @@ -45,6 +62,7 @@ Baseline. Earlier history is in the

---

[Unreleased]: https://github.com/spore-host/python-sdk/compare/python-v0.1.3...HEAD
[Unreleased]: https://github.com/spore-host/python-sdk/compare/python-v0.1.4...HEAD
[0.1.4]: https://github.com/spore-host/python-sdk/compare/python-v0.1.3...python-v0.1.4
[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.3"
version = "0.1.4"
description = "Python SDK for spore.host — ephemeral EC2 compute for researchers"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
6 changes: 5 additions & 1 deletion spore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

from .client import Client
from ._notifications import NotificationsClient
from ._spawn import Instance, SpawnClient
from ._truffle import InstanceType, QuotaInfo, SpotPrice, TruffleClient

Expand Down Expand Up @@ -63,14 +64,17 @@ def __repr__(self) -> str:

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

__version__ = "0.1.3"
__version__ = "0.1.4"
__all__ = [
"Client",
"truffle",
"spawn",
"notifications",
"SpawnClient",
"TruffleClient",
"NotificationsClient",
"Instance",
"InstanceType",
"SpotPrice",
Expand Down
82 changes: 82 additions & 0 deletions spore/_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""notifications — register/deregister a phone number for SMS lifecycle alerts."""

from __future__ import annotations

from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
from .client import Client


class NotificationsClient:
"""Manage SMS notification registration (POST/DELETE /v1/notifications/register).

A phone number is keyed by a chat-platform ``user_key`` — the same identity
spore-bot uses — of the form ``"{platform}#{workspace_id}#{user_id}"`` (e.g.
``slack#T0ABC#U0XYZ``). It is NOT derived from your AWS credentials or API
key, so you must supply it: either the three parts (``platform``,
``workspace_id``, ``user_id``) or a pre-built ``user_key``.
"""

def __init__(self, client: "Client"):
self._c = client

def register(
self,
phone: str,
*,
platform: Optional[str] = None,
workspace_id: Optional[str] = None,
user_id: Optional[str] = None,
user_key: Optional[str] = None,
) -> dict:
"""Register ``phone`` for SMS notifications.

Provide either ``user_key`` directly, or all three of ``platform`` /
``workspace_id`` / ``user_id`` (from your Slack/Teams setup).

Example:
>>> spore.notifications.register(
... "+15551234567", platform="slack",
... workspace_id="T0ABC", user_id="U0XYZ",
... )
"""
key = self._resolve_user_key(user_key, platform, workspace_id, user_id)
return self._c.post(
"/v1/notifications/register", {"phone": phone, "user_key": key}
)

def deregister(
self,
*,
platform: Optional[str] = None,
workspace_id: Optional[str] = None,
user_id: Optional[str] = None,
user_key: Optional[str] = None,
) -> dict:
"""Remove the SMS registration for a user (same identity args as register)."""
key = self._resolve_user_key(user_key, platform, workspace_id, user_id)
return self._c.delete("/v1/notifications/register", {"user_key": key})

# ── Internal ──────────────────────────────────────────────────────────────

@staticmethod
def _resolve_user_key(
user_key: Optional[str],
platform: Optional[str],
workspace_id: Optional[str],
user_id: Optional[str],
) -> str:
"""Return an explicit ``user_key`` or build it from the identity triple.

Mirrors spawn's registration key (spawn/cmd/bot.go):
``"{platform}#{workspace_id}#{user_id}"``.
"""
if user_key:
return user_key
if platform and workspace_id and user_id:
return f"{platform}#{workspace_id}#{user_id}"
raise ValueError(
"notifications require an identity: pass user_key=, or all of "
"platform=, workspace_id=, and user_id= (from your Slack/Teams setup)."
)
5 changes: 3 additions & 2 deletions spore/_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ def launch(
on_complete: str = "terminate",
slack_workspace: Optional[str] = None,
active_processes: Optional[List[str]] = None,
phone: Optional[str] = None,
wait: bool = False,
) -> Instance:
"""
Expand All @@ -167,9 +166,11 @@ def launch(
on_complete: Action on SPAWN_COMPLETE: "terminate", "stop", "hibernate".
slack_workspace: Slack workspace ID for lifecycle notifications.
active_processes: Process names that indicate active work (e.g. ["rsession"]).
phone: Phone number for SMS notifications (+1XXXXXXXXXX).
wait: If True, block until instance is running.

For SMS notifications, register your number separately via
``spore.notifications.register(...)`` — it is not a launch parameter.

Returns:
Instance object.

Expand Down
14 changes: 14 additions & 0 deletions spore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ def __init__(
# Sub-clients
from ._truffle import TruffleClient
from ._spawn import SpawnClient
from ._notifications import NotificationsClient
self.truffle = TruffleClient(self)
self.spawn = SpawnClient(self)
self.notifications = NotificationsClient(self)

# ── HTTP helpers ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -73,6 +75,18 @@ def post(self, path: str, body: dict = None) -> dict:
resp.raise_for_status()
return resp.json()

def delete(self, path: str, body: dict = None) -> dict:
# Some endpoints (e.g. DELETE /v1/notifications/register) read a JSON body,
# which HTTP permits and `requests` supports.
resp = requests.delete(
f"{self._api_url}{path}",
headers=self._headers(),
json=body or {},
timeout=30,
)
resp.raise_for_status()
return resp.json()

# ── AWS session (for direct SDK calls when needed) ────────────────────────

@property
Expand Down
73 changes: 71 additions & 2 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,29 @@

from __future__ import annotations

import pytest

import spore
from spore import Client, Instance, SpawnClient, TruffleClient
from spore import (
Client,
Instance,
NotificationsClient,
SpawnClient,
TruffleClient,
)


class FakeClient:
"""A Client stand-in that returns canned responses instead of HTTP calls."""

def __init__(self, get_return=None, post_return=None):
def __init__(self, get_return=None, post_return=None, delete_return=None):
self._get_return = get_return or {}
self._post_return = post_return or {}
self._delete_return = delete_return or {}
self._region = "us-east-1"
self.get_calls = []
self.post_calls = []
self.delete_calls = []

def get(self, path, params=None):
self.get_calls.append((path, params))
Expand All @@ -29,6 +39,10 @@ def post(self, path, body=None):
self.post_calls.append((path, body))
return self._post_return

def delete(self, path, body=None):
self.delete_calls.append((path, body))
return self._delete_return


# ── Fix #1: module shadowing — the documented quickstart must work ──────────

Expand Down Expand Up @@ -169,9 +183,64 @@ def test_spawn_list_parses_instances():
assert [i.instance_id for i in insts] == ["i-1", "i-2"]


# ── #5: notifications API (register / deregister) ──────────────────────────

def test_notifications_register_builds_user_key_from_triple():
fake = FakeClient(post_return={"status": "registered", "phone": "+15551234567"})
nc = NotificationsClient(fake)
nc.register("+15551234567", platform="slack", workspace_id="T0ABC", user_id="U0XYZ")

path, body = fake.post_calls[0]
assert path == "/v1/notifications/register"
assert body == {"phone": "+15551234567", "user_key": "slack#T0ABC#U0XYZ"}


def test_notifications_register_accepts_raw_user_key():
fake = FakeClient(post_return={"status": "registered"})
NotificationsClient(fake).register("+15551234567", user_key="teams#W1#U9")
_, body = fake.post_calls[0]
assert body["user_key"] == "teams#W1#U9"


def test_notifications_register_requires_identity():
with pytest.raises(ValueError):
NotificationsClient(FakeClient()).register("+15551234567")
# partial triple is also insufficient
with pytest.raises(ValueError):
NotificationsClient(FakeClient()).register("+1", platform="slack")


def test_notifications_deregister_uses_delete_with_user_key():
fake = FakeClient(delete_return={"status": "deregistered"})
NotificationsClient(fake).deregister(
platform="slack", workspace_id="T0ABC", user_id="U0XYZ"
)
path, body = fake.delete_calls[0]
assert path == "/v1/notifications/register"
assert body == {"user_key": "slack#T0ABC#U0XYZ"}


def test_top_level_notifications_proxy_exposes_register():
assert hasattr(spore.notifications, "register")
assert hasattr(spore.notifications, "deregister")
assert isinstance(spore.notifications._target(), NotificationsClient)


# ── #4: launch() no longer accepts the dead `phone` param ───────────────────

def test_launch_rejects_phone_kwarg():
fake = FakeClient(post_return={"instance_id": "i-0", "state": "pending"})
with pytest.raises(TypeError):
SpawnClient(fake).launch("t3.micro", phone="+15551234567")


# ── Client basics ───────────────────────────────────────────────────────────

def test_client_repr_masks_api_key():
c = Client(api_key="sk_secret_value_1234567890")
assert "sk_secre" in repr(c)
assert "secret_value" not in repr(c)


def test_client_has_delete():
assert hasattr(Client, "delete")
Loading