From b62b509159e96099cc49bcc130fa52b3b6422887 Mon Sep 17 00:00:00 2001 From: scttfrdmn <3011922+scttfrdmn@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:55:02 -0700 Subject: [PATCH] feat(sdk): notifications API + remove dead launch(phone=) (#4, #5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5: add spore.notifications (NotificationsClient) wrapping POST/DELETE /v1/notifications/register — the one REST route with no SDK coverage. register()/deregister() take platform/workspace_id/user_id (assembled into the 'platform#workspace#user' key spore-bot uses, per spawn/cmd/bot.go) or a raw user_key. user_key is caller-supplied by design — it's a chat identity, not derivable from AWS creds. Adds Client.delete() (JSON-body DELETE). Wired as a lazy proxy + top-level re-exports, same private-module pattern as #2. #4: remove the phone= param from spawn.launch() — it was documented for SMS but silently no-op'd (launch never accepts phone; SMS is the notifications endpoint). Docstring now points to spore.notifications.register(). Tests: register key-assembly (triple + raw + missing-identity ValueError), deregister DELETE body, top-level proxy, launch rejects phone=. 17 pass, ruff clean, build + fresh-install smoke green, 3.9-safe. Bump 0.1.3 -> 0.1.4. Closes #4, closes #5 --- CHANGELOG.md | 20 +++++++++- pyproject.toml | 2 +- spore/__init__.py | 6 ++- spore/_notifications.py | 82 +++++++++++++++++++++++++++++++++++++++++ spore/_spawn.py | 5 ++- spore/client.py | 14 +++++++ tests/test_sdk.py | 73 +++++++++++++++++++++++++++++++++++- 7 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 spore/_notifications.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a68a135..0935497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index fc8f5ea..55d0d43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/spore/__init__.py b/spore/__init__.py index cfc9b35..21c1f52 100644 --- a/spore/__init__.py +++ b/spore/__init__.py @@ -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 @@ -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", diff --git a/spore/_notifications.py b/spore/_notifications.py new file mode 100644 index 0000000..a12773e --- /dev/null +++ b/spore/_notifications.py @@ -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)." + ) diff --git a/spore/_spawn.py b/spore/_spawn.py index 1caecfe..340452a 100644 --- a/spore/_spawn.py +++ b/spore/_spawn.py @@ -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: """ @@ -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. diff --git a/spore/client.py b/spore/client.py index f2dba13..9be05d9 100644 --- a/spore/client.py +++ b/spore/client.py @@ -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 ────────────────────────────────────────────────────────── @@ -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 diff --git a/tests/test_sdk.py b/tests/test_sdk.py index b394563..89df545 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -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)) @@ -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 ────────── @@ -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")