Files
hermes-agent/plugins/spotify/client.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

183 lines
8.5 KiB
Python

"""Thin Spotify Web API helper used by Hermes native tools.
Owns auth (token refresh/401 retry), error mapping and id/URI normalization;
endpoint paths live with their tool handlers in ``tools.py``.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional
from urllib.parse import urlparse
import httpx
from hermes_cli.auth import AuthError, resolve_spotify_runtime_credentials
class SpotifyError(RuntimeError): """Base Spotify tool error."""
class SpotifyAuthRequiredError(SpotifyError): """Raised when the user needs to authenticate with Spotify first."""
class SpotifyAPIError(SpotifyError):
"""Structured Spotify API failure."""
def __init__(self, message: str, *, status_code: Optional[int] = None, response_body: Optional[str] = None) -> None:
super().__init__(message)
self.status_code, self.response_body, self.path = status_code, response_body, None
_empty_204 = lambda message: {"status_code": 204, "empty": True, "message": message} # noqa: E731 explanatory stand-in for a bare 204
class SpotifyClient:
def __init__(self) -> None:
self._runtime = self._resolve_runtime(refresh_if_expiring=True)
def _resolve_runtime(self, *, force_refresh: bool = False, refresh_if_expiring: bool = True) -> Dict[str, Any]:
try:
return resolve_spotify_runtime_credentials(force_refresh=force_refresh, refresh_if_expiring=refresh_if_expiring)
except AuthError as exc:
raise SpotifyAuthRequiredError(str(exc)) from exc
@property
def base_url(self) -> str:
return str(self._runtime.get("base_url") or "").rstrip("/")
def request(
self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None,
allow_retry_on_401: bool = True, empty_response: Optional[Dict[str, Any]] = None,
) -> Any:
response = httpx.request(
method, f"{self.base_url}{path}",
headers={"Authorization": f"Bearer {self._runtime['access_token']}", "Content-Type": "application/json"},
params=_strip_none(params), json=_strip_none(json_body) if json_body is not None else None, timeout=30.0,
)
if response.status_code == 401 and allow_retry_on_401:
# One forced token refresh, then retry exactly once.
self._runtime = self._resolve_runtime(force_refresh=True, refresh_if_expiring=True)
return self.request(method, path, params=params, json_body=json_body, allow_retry_on_401=False)
if response.status_code >= 400:
detail = response.text.strip()
message = _friendly_spotify_error_message(
status_code=response.status_code, detail=_extract_spotify_error_detail(response, fallback=detail),
path=path, retry_after=response.headers.get("Retry-After"),
)
error = SpotifyAPIError(message, status_code=response.status_code, response_body=detail)
error.path = path
raise error
if response.status_code == 204 or not response.content:
return empty_response or {"success": True, "status_code": response.status_code, "empty": True}
if "application/json" in response.headers.get("content-type", ""):
return response.json()
return {"success": True, "text": response.text}
# -- player: reads return an explanatory payload instead of a bare 204 --------
def get_playback_state(self, *, market: Optional[str] = None) -> Any:
return self.request("GET", "/me/player", params={"market": market}, empty_response=_empty_204(
"No active Spotify playback session was found. Open Spotify on a device and start playback, or transfer playback to an available device."
))
def get_currently_playing(self, *, market: Optional[str] = None) -> Any:
return self.request("GET", "/me/player/currently-playing", params={"market": market}, empty_response=_empty_204(
"Spotify is not currently playing anything. Start playback in Spotify and try again."
))
def _extract_spotify_error_detail(response: httpx.Response, *, fallback: str) -> str:
"""Prefer Spotify's ``{"error": {"message": ...}}`` (or ``{"error": "..."}``) body over raw text."""
detail = fallback
try:
error_obj = response.json().get("error")
if isinstance(error_obj, dict):
detail = str(error_obj.get("message") or detail)
elif isinstance(error_obj, str):
detail = error_obj
except Exception:
pass
return detail.strip()
def _friendly_spotify_error_message(*, status_code: int, detail: str, path: str, retry_after: Optional[str]) -> str:
is_playback_path = path.startswith("/me/player")
if status_code == 401:
return "Spotify authentication failed or expired. Run `hermes auth spotify` again."
if status_code == 403:
if is_playback_path:
return ("Spotify rejected this playback request. Playback control usually requires a Spotify Premium account "
"and an active Spotify Connect device.")
if "scope" in detail.lower() or "permission" in detail.lower():
return "Spotify rejected the request because the current auth scope is insufficient. Re-run `hermes auth spotify` to refresh permissions."
return "Spotify rejected the request. The account may not have permission for this action."
if status_code == 404:
return "Spotify could not find an active playback device or player session for this request." if is_playback_path else "Spotify resource not found."
if status_code == 429:
return "Spotify rate limit exceeded." + (f" Retry after {retry_after} seconds." if retry_after else "")
return detail or f"Spotify API request failed with status {status_code}."
_strip_none = lambda payload: {key: value for key, value in (payload or {}).items() if value is not None} # noqa: E731
def _check_type(item_type: str, expected_type: Optional[str]) -> None:
if expected_type and item_type != expected_type:
raise SpotifyError(f"Expected a Spotify {expected_type}, got {item_type}.")
def normalize_spotify_id(value: str, expected_type: Optional[str] = None) -> str:
"""Accept a bare id, ``spotify:<type>:<id>`` URI, or open.spotify.com URL; return the id."""
cleaned = (value or "").strip()
if not cleaned:
raise SpotifyError("Spotify id/uri/url is required.")
# (type, id) segments of a URI or URL; a bare id (or malformed ref) has no segments and passes through.
parts = cleaned.split(":")[1:] if cleaned.startswith("spotify:") else []
if len(parts) < 2 and "open.spotify.com" in cleaned:
parts = [part for part in urlparse(cleaned).path.split("/") if part]
if len(parts) >= 2:
_check_type(parts[0], expected_type)
return parts[1]
return cleaned
def normalize_spotify_uri(value: str, expected_type: Optional[str] = None) -> str:
"""Like normalize_spotify_id but returns a URI; bare ids need *expected_type* to become one."""
cleaned = (value or "").strip()
if not cleaned:
raise SpotifyError("Spotify URI/url/id is required.")
if cleaned.startswith("spotify:"):
parts = cleaned.split(":")
if expected_type and len(parts) >= 3:
_check_type(parts[1], expected_type)
return cleaned
item_id = normalize_spotify_id(cleaned, expected_type)
return f"spotify:{expected_type}:{item_id}" if expected_type else cleaned
def normalize_spotify_uris(values: Iterable[str], expected_type: Optional[str] = None) -> list[str]:
"""Normalize each value, dropping duplicates while keeping first-seen order."""
uris = list(dict.fromkeys(normalize_spotify_uri(str(value), expected_type) for value in values))
if not uris:
raise SpotifyError("At least one Spotify item is required.")
return uris
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'compact_json': ('gateway.hosted_rooms_common', 'compact_json'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----