feat: add recommendation service adapters

This commit is contained in:
Justin Visser 2026-08-10 12:05:59 +02:00
parent e970bdf542
commit 751391e6a2
9 changed files with 676 additions and 26 deletions

View file

@ -0,0 +1,339 @@
"""Anthropic implementation of structured intent and streamed reranking."""
import json
import re
from collections.abc import AsyncIterator
from typing import Literal, cast
from anthropic import AsyncAnthropic
from anthropic.lib._parse._transform import transform_schema
from anthropic.types import Message, TextBlockParam, Usage
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.config import Settings
from app.domain.models import (
ConversationTurn,
Familiarity,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
TrackCandidate,
)
from app.observability.timing import record_llm_tokens
from app.ports.protocols import RecommenderOutputError
from app.prompts import INTENT_SYSTEM_PROMPT, RERANK_SYSTEM_PROMPT
Effort = Literal["low", "medium", "high", "xhigh", "max"]
_RECOMMENDATION_ARRAY = re.compile(r'"recommendations"\s*:\s*\[')
class CandidateOutput(BaseModel):
"""One bounded candidate in the intent response."""
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=200)
artist: str = Field(min_length=1, max_length=200)
class IntentOutput(BaseModel):
"""Bounded structured output for the intent call."""
model_config = ConfigDict(extra="forbid")
mood: list[str] = Field(default_factory=list, max_length=6)
activity: str | None = Field(default=None, max_length=100)
era: list[str] = Field(default_factory=list, max_length=5)
languages: list[str] = Field(default_factory=list, max_length=8)
genres: list[str] = Field(default_factory=list, max_length=8)
familiarity: Familiarity
is_refinement: bool
intent_summary: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
candidates: list[CandidateOutput] = Field(min_length=30, max_length=40)
class RerankSelectionOutput(BaseModel):
"""One validated object extracted from the rerank stream."""
model_config = ConfigDict(extra="forbid")
track_id: str = Field(min_length=1, max_length=200)
justification: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
class RerankOutput(BaseModel):
"""Complete bounded rerank output used for final validation."""
model_config = ConfigDict(extra="forbid")
recommendations: list[RerankSelectionOutput] = Field(min_length=1, max_length=50)
class AnthropicRecommender:
"""Use two Anthropic calls for intent generation and grounded ranking."""
def __init__(self, client: AsyncAnthropic, settings: Settings) -> None:
"""Bind the shared asynchronous client and immutable settings."""
self.client = client
self.settings = settings
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Interpret a request through Anthropic structured output."""
message = await self.client.messages.parse(
model=self.settings.llm_model,
max_tokens=self.settings.intent_max_tokens,
output_config={"effort": _parse_effort(self.settings.intent_effort)},
output_format=IntentOutput,
system=[_system_block(INTENT_SYSTEM_PROMPT)],
messages=[
{
"role": "user",
"content": _render_intent_input(
query,
history,
previous_recommendations,
taste_summary,
candidate_count,
),
}
],
)
_validate_stop_reason(message)
_record_usage(message.usage)
parsed = message.parsed_output
if parsed is None:
raise RecommenderOutputError("Intent response contained no structured output")
validated = IntentOutput.model_validate(parsed.model_dump())
if len(validated.candidates) != candidate_count:
raise RecommenderOutputError("Intent response returned the wrong candidate count")
return _to_intent(validated)
async def stream_rerank(
self,
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None = None,
) -> AsyncIterator[RerankSelection]:
"""Yield each complete valid selection while the JSON is streaming."""
schema = transform_schema(RerankOutput.model_json_schema())
parser = _RecommendationObjectParser()
async with self.client.messages.stream(
model=self.settings.llm_model,
max_tokens=self.settings.rerank_max_tokens,
output_config={
"effort": _parse_effort(self.settings.rerank_effort),
"format": {"type": "json_schema", "schema": schema},
},
system=[_system_block(RERANK_SYSTEM_PROMPT)],
messages=[
{
"role": "user",
"content": _render_rerank_input(
intent,
grounded_tracks,
taste_summary,
history,
selection_count,
correction,
),
}
],
) as stream:
async for text_delta in stream.text_stream:
for selection in parser.feed(text_delta):
yield RerankSelection(
track_id=selection.track_id,
justification=selection.justification,
)
final_message = await stream.get_final_message()
_validate_stop_reason(final_message)
_record_usage(final_message.usage)
try:
validated = RerankOutput.model_validate_json(parser.complete_text)
except ValidationError as error:
raise RecommenderOutputError("Rerank response failed final validation") from error
if len(validated.recommendations) > selection_count:
raise RecommenderOutputError("Rerank response returned too many selections")
class _RecommendationObjectParser:
def __init__(self) -> None:
self.complete_text = ""
self._scan_index = 0
self._object_start: int | None = None
self._object_depth = 0
self._is_in_string = False
self._is_escaped = False
self._has_found_array = False
def feed(self, text_delta: str) -> list[RerankSelectionOutput]:
self.complete_text += text_delta
if not self._has_found_array:
match = _RECOMMENDATION_ARRAY.search(self.complete_text)
if match is None:
return []
self._has_found_array = True
self._scan_index = match.end()
selections: list[RerankSelectionOutput] = []
while self._scan_index < len(self.complete_text):
character = self.complete_text[self._scan_index]
completed = self._scan_character(character)
self._scan_index += 1
if completed is not None:
selections.append(completed)
return selections
def _scan_character(self, character: str) -> RerankSelectionOutput | None:
if self._object_start is None:
if character == "{":
self._object_start = self._scan_index
self._object_depth = 1
return None
if self._is_in_string:
if self._is_escaped:
self._is_escaped = False
elif character == "\\":
self._is_escaped = True
elif character == '"':
self._is_in_string = False
return None
if character == '"':
self._is_in_string = True
elif character == "{":
self._object_depth += 1
elif character == "}":
self._object_depth -= 1
if self._object_depth == 0:
return self._finish_object()
return None
def _finish_object(self) -> RerankSelectionOutput:
assert self._object_start is not None
object_text = self.complete_text[self._object_start : self._scan_index + 1]
self._object_start = None
try:
return RerankSelectionOutput.model_validate_json(object_text)
except ValidationError as error:
raise RecommenderOutputError("Rerank item failed validation") from error
def _to_intent(output: IntentOutput) -> Intent:
return Intent(
mood=tuple(output.mood),
activity=output.activity,
era=tuple(output.era),
languages=tuple(output.languages),
genres=tuple(output.genres),
familiarity=output.familiarity,
is_refinement=output.is_refinement,
intent_summary=output.intent_summary,
candidates=tuple(
TrackCandidate(title=candidate.title, artist=candidate.artist)
for candidate in output.candidates
),
)
def _render_intent_input(
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> str:
payload = {
"query": query,
"history": [turn.__dict__ for turn in history],
"prior_recommendations": [
recommendation.__dict__ for recommendation in previous_recommendations
],
"taste_profile": taste_summary,
"required_candidate_count": candidate_count,
}
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def _render_rerank_input(
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None,
) -> str:
payload = {
"intent": {
"mood": intent.mood,
"activity": intent.activity,
"era": intent.era,
"languages": intent.languages,
"genres": intent.genres,
"familiarity": intent.familiarity,
"intent_summary": intent.intent_summary,
},
"grounded_pool": [
{"track_id": track.id, "title": track.title, "artists": track.artists}
for track in grounded_tracks
],
"taste_profile": taste_summary,
"history": [turn.__dict__ for turn in history],
"requested_selection_count": selection_count,
"correction": correction,
}
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def _system_block(prompt: str) -> TextBlockParam:
return {
"type": "text",
"text": prompt,
"cache_control": {"type": "ephemeral"},
}
def _parse_effort(value: str) -> Effort:
allowed = {"low", "medium", "high", "xhigh", "max"}
if value not in allowed:
raise ValueError(f"Unsupported Anthropic effort: {value}")
return cast(Effort, value)
def _validate_stop_reason(message: Message) -> None:
stop_reason_messages = {
None: "Anthropic response had no stop reason",
"max_tokens": "Anthropic response reached its token limit",
"stop_sequence": "Anthropic response hit an unexpected stop sequence",
"tool_use": "Anthropic response attempted tool use",
"pause_turn": "Anthropic response paused before completion",
"refusal": "Anthropic response was refused",
"model_context_window_exceeded": "Anthropic context window was exceeded",
}
if message.stop_reason == "end_turn":
return
raise RecommenderOutputError(
stop_reason_messages.get(message.stop_reason, "Anthropic response stopped unexpectedly")
)
def _record_usage(usage: Usage) -> None:
cache_creation_tokens = usage.cache_creation_input_tokens or 0
cache_read_tokens = usage.cache_read_input_tokens or 0
record_llm_tokens(
usage.input_tokens + cache_creation_tokens + cache_read_tokens,
usage.output_tokens,
)

View file

@ -12,15 +12,22 @@ from app.adapters.spotify.errors import (
SpotifyUnavailableError, SpotifyUnavailableError,
) )
from app.adapters.spotify.mapping import ( from app.adapters.spotify.mapping import (
CreatedPlaylist,
CurrentUser, CurrentUser,
parse_created_playlist, parse_created_playlist,
parse_current_user, parse_current_user,
parse_saved_track_page,
parse_search_tracks, parse_search_tracks,
parse_top_artists,
parse_track_page,
) )
from app.adapters.spotify.session import SpotifySession from app.adapters.spotify.session import SpotifySession
from app.config import Settings from app.config import Settings
from app.domain.models import Track from app.domain.models import CreatedPlaylist, Track
from app.observability.timing import increment_spotify_calls
from app.ports.protocols import TimeRange
SPOTIFY_SEARCH_LIMIT = 10
SPOTIFY_PAGE_LIMIT = 50
class SpotifyClient: class SpotifyClient:
@ -39,6 +46,8 @@ class SpotifyClient:
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Search Spotify tracks and return only valid mapped results.""" """Search Spotify tracks and return only valid mapped results."""
if not 1 <= limit <= SPOTIFY_SEARCH_LIMIT:
raise ValueError("Spotify search limit must be between 1 and 10")
response = await self._request( response = await self._request(
"GET", "GET",
"/search", "/search",
@ -46,6 +55,40 @@ class SpotifyClient:
) )
return parse_search_tracks(response.json()) return parse_search_tracks(response.json())
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Fetch the user's top artists for a supported time range."""
response = await self._request(
"GET",
"/me/top/artists",
params={"time_range": time_range, "limit": limit},
)
return parse_top_artists(response.json())
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Fetch the user's top tracks for a supported time range."""
response = await self._request(
"GET",
"/me/top/tracks",
params={"time_range": time_range, "limit": limit},
)
return parse_track_page(response.json())
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Fetch a bounded saved-track sample across Spotify pages."""
tracks: list[Track] = []
while len(tracks) < limit:
page_limit = min(SPOTIFY_PAGE_LIMIT, limit - len(tracks))
response = await self._request(
"GET",
"/me/tracks",
params={"limit": page_limit, "offset": len(tracks)},
)
page = parse_saved_track_page(response.json())
tracks.extend(page)
if len(page) < page_limit:
break
return tracks
async def fetch_current_user(self) -> CurrentUser: async def fetch_current_user(self) -> CurrentUser:
"""Fetch the authenticated Spotify user's stable identity.""" """Fetch the authenticated Spotify user's stable identity."""
response = await self._request("GET", "/me") response = await self._request("GET", "/me")
@ -110,6 +153,7 @@ class SpotifyClient:
params: dict[str, str | int] | None, params: dict[str, str | int] | None,
json: dict[str, object] | None, json: dict[str, object] | None,
) -> httpx2.Response: ) -> httpx2.Response:
increment_spotify_calls()
return await self.http.request( return await self.http.request(
method, method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}", f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",

View file

@ -1,5 +1,7 @@
"""Typed failures raised by the Spotify adapter.""" """Typed failures raised by the Spotify adapter."""
from app.ports.protocols import CatalogQuotaExhaustedError
class SpotifyError(Exception): class SpotifyError(Exception):
"""Base class for Spotify adapter failures.""" """Base class for Spotify adapter failures."""
@ -9,7 +11,7 @@ class SpotifyAuthenticationError(SpotifyError):
"""Spotify rejected authentication or token refresh.""" """Spotify rejected authentication or token refresh."""
class SpotifyRateLimitedError(SpotifyError): class SpotifyRateLimitedError(SpotifyError, CatalogQuotaExhaustedError):
"""Spotify rate limited a request that could not be retried.""" """Spotify rate limited a request that could not be retried."""
def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None: def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None:

View file

@ -4,7 +4,8 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import cast
from app.domain.models import Track from app.domain.matching import track_key
from app.domain.models import CreatedPlaylist, Track
@dataclass(frozen=True) @dataclass(frozen=True)
@ -15,14 +16,6 @@ class CurrentUser:
display_name: str display_name: str
@dataclass(frozen=True)
class CreatedPlaylist:
"""The application-owned result of creating a Spotify playlist."""
id: str
url: str
def parse_search_tracks(payload: object) -> list[Track]: def parse_search_tracks(payload: object) -> list[Track]:
"""Map valid Spotify search items and discard malformed entries.""" """Map valid Spotify search items and discard malformed entries."""
root = _as_mapping(payload) root = _as_mapping(payload)
@ -32,17 +25,54 @@ def parse_search_tracks(payload: object) -> list[Track]:
return [] return []
parsed_tracks: list[Track] = [] parsed_tracks: list[Track] = []
seen_ids: set[str] = set()
seen_keys: set[str] = set()
for item in items: for item in items:
parsed_track = _parse_track(item) parsed_track = _parse_track(item)
if parsed_track is not None: if parsed_track is None:
parsed_tracks.append(parsed_track) continue
normalized_key = track_key(parsed_track)
if parsed_track.id in seen_ids or normalized_key in seen_keys:
continue
seen_ids.add(parsed_track.id)
seen_keys.add(normalized_key)
parsed_tracks.append(parsed_track)
return parsed_tracks return parsed_tracks
def parse_top_artists(payload: object) -> list[str]:
"""Map a top-artists page to valid artist names."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
if not isinstance(items, list):
return []
return [
name for item in items if (name := _required_string(_as_mapping(item), "name")) is not None
]
def parse_track_page(payload: object) -> list[Track]:
"""Map a direct Spotify track page to domain tracks."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
return _parse_track_items(items)
def parse_saved_track_page(payload: object) -> list[Track]:
"""Map a saved-track wrapper page to domain tracks."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
if not isinstance(items, list):
return []
return _parse_track_items(
[wrapper.get("track") for item in items if (wrapper := _as_mapping(item)) is not None]
)
def parse_current_user(payload: object) -> CurrentUser: def parse_current_user(payload: object) -> CurrentUser:
"""Map a Spotify current-user response into stable identity fields.""" """Map a Spotify current-user response into stable identity fields."""
root = _as_mapping(payload) root = _as_mapping(payload)
account_id = _required_string(root, "account_id") account_id = _required_string(root, "id") or _required_string(root, "account_id")
display_name = _required_string(root, "display_name") display_name = _required_string(root, "display_name")
if account_id is None or display_name is None: if account_id is None or display_name is None:
raise ValueError("Spotify returned an invalid current-user response") raise ValueError("Spotify returned an invalid current-user response")
@ -90,6 +120,12 @@ def _parse_track(payload: object) -> Track | None:
) )
def _parse_track_items(payload: object) -> list[Track]:
if not isinstance(payload, list):
return []
return [track for item in payload if (track := _parse_track(item)) is not None]
def _parse_artists(payload: object) -> tuple[str, ...] | None: def _parse_artists(payload: object) -> tuple[str, ...] | None:
if not isinstance(payload, list) or not payload: if not isinstance(payload, list) or not payload:
return None return None

View file

@ -41,6 +41,8 @@ class Settings(BaseSettings):
llm_model: str = "claude-sonnet-5" llm_model: str = "claude-sonnet-5"
intent_effort: str = "low" intent_effort: str = "low"
rerank_effort: str = "medium" rerank_effort: str = "medium"
intent_max_tokens: int = 8192
rerank_max_tokens: int = 4096
# Pipeline shape. candidate_count is the main call-1 latency lever and # Pipeline shape. candidate_count is the main call-1 latency lever and
# the hallucination budget: at "new to you" familiarity a large share of # the hallucination budget: at "new to you" familiarity a large share of

View file

@ -25,6 +25,24 @@ class TrackCandidate:
artist: str artist: str
@dataclass(frozen=True)
class ConversationTurn:
"""One bounded user or assistant message supplied by the client."""
role: str
content: str
@dataclass(frozen=True)
class PreviousRecommendation:
"""One earlier recommendation available to a refinement request."""
rank: int
track_id: str
title: str
artists: tuple[str, ...]
class Familiarity(StrEnum): class Familiarity(StrEnum):
"""How strongly a request should favor known or unknown music.""" """How strongly a request should favor known or unknown music."""

View file

@ -0,0 +1,98 @@
"""Request timing context and the counters emitted with completion logs."""
import time
import uuid
from contextvars import ContextVar
from dataclasses import dataclass
from typing import cast
import structlog
from starlette.types import ASGIApp, Receive, Scope, Send
@dataclass
class RequestCounters:
"""Mutable counters scoped to one ASGI request context."""
spotify_calls: int = 0
cache_hits: int = 0
llm_input_tokens: int = 0
llm_output_tokens: int = 0
_COUNTERS: ContextVar[RequestCounters | None] = ContextVar("request_counters", default=None)
_REQUEST_ID: ContextVar[str | None] = ContextVar("request_id", default=None)
def increment_spotify_calls() -> None:
"""Count one Spotify HTTP request when a request context is active."""
counters = _COUNTERS.get()
if counters is not None:
counters.spotify_calls += 1
def increment_cache_hits() -> None:
"""Count one in-process cache hit when a request context is active."""
counters = _COUNTERS.get()
if counters is not None:
counters.cache_hits += 1
def record_llm_tokens(input_tokens: int, output_tokens: int) -> None:
"""Accumulate model token usage when the provider reports it."""
counters = _COUNTERS.get()
if counters is not None:
counters.llm_input_tokens += input_tokens
counters.llm_output_tokens += output_tokens
def current_request_id() -> str | None:
"""Return the active request identifier when one exists."""
return _REQUEST_ID.get()
class RequestTimingMiddleware:
"""Log duration and counters after the complete response body is sent."""
def __init__(self, app: ASGIApp) -> None:
"""Wrap an ASGI application."""
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Install request context and measure an HTTP exchange."""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request_id = uuid.uuid4().hex
state = scope.setdefault("state", {})
cast(dict[str, object], state)["request_id"] = request_id
started_at = time.monotonic()
counters = RequestCounters()
counter_token = _COUNTERS.set(counters)
request_token = _REQUEST_ID.set(request_id)
try:
await self.app(scope, receive, send)
finally:
self._log_completion(scope, request_id, started_at, counters)
_COUNTERS.reset(counter_token)
_REQUEST_ID.reset(request_token)
@staticmethod
def _log_completion(
scope: Scope,
request_id: str,
started_at: float,
counters: RequestCounters,
) -> None:
structlog.get_logger().info(
"request_complete",
request_id=request_id,
method=scope.get("method"),
path=scope.get("path"),
duration_ms=round((time.monotonic() - started_at) * 1000),
spotify_calls=counters.spotify_calls,
cache_hits=counters.cache_hits,
llm_input_tokens=counters.llm_input_tokens,
llm_output_tokens=counters.llm_output_tokens,
)

View file

@ -0,0 +1,82 @@
"""Structural ports implemented by external service adapters."""
from collections.abc import AsyncIterator
from typing import Literal, Protocol
from app.domain.models import (
ConversationTurn,
CreatedPlaylist,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
)
TimeRange = Literal["short_term", "long_term"]
class CatalogQuotaExhaustedError(Exception):
"""A catalog quota stopped further resolution attempts."""
class RecommenderOutputError(Exception):
"""The recommender returned unusable structured output."""
class MusicCatalog(Protocol):
"""Read the restricted Spotify surface used by discovery."""
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Search tracks by fielded or bare-text query."""
...
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Fetch bounded top artist names for one time range."""
...
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Fetch bounded top tracks for one time range."""
...
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Fetch a bounded sample of saved tracks."""
...
class Recommender(Protocol):
"""Interpret discovery intent and stream a grounded reranking."""
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Interpret a query and propose a bounded candidate set."""
...
def stream_rerank(
self,
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None = None,
) -> AsyncIterator[RerankSelection]:
"""Stream validated selections from the grounded pool."""
...
class PlaylistWriter(Protocol):
"""Write a Spotify playlist without retrying ambiguous mutations."""
async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
"""Create a private playlist."""
...
async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
"""Add ordered Spotify track URIs to a playlist."""
...

View file

@ -228,6 +228,31 @@ def test_search_maps_valid_fields_and_drops_malformed_item() -> None:
asyncio.run(run()) asyncio.run(run())
def test_taste_endpoints_map_supported_response_shapes() -> None:
async def run() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
if request.url.path == "/v1/me/top/artists":
assert request.url.params["time_range"] == "short_term"
return httpx2.Response(200, json={"items": [{"name": "Top Artist"}]})
if request.url.path == "/v1/me/top/tracks":
return httpx2.Response(200, json={"items": [_track_payload()]})
assert request.url.path == "/v1/me/tracks"
assert request.url.params["offset"] == "0"
return httpx2.Response(200, json={"items": [{"track": _track_payload()}]})
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = _client(http)
artists = await client.fetch_top_artists("short_term", 50)
top_tracks = await client.fetch_top_tracks("long_term", 50)
saved_tracks = await client.fetch_saved_tracks(100)
assert artists == ["Top Artist"]
assert [track.id for track in top_tracks] == ["track-1"]
assert [track.id for track in saved_tracks] == ["track-1"]
asyncio.run(run())
async def _search_with_handler(handler: TransportHandler) -> list[Track]: async def _search_with_handler(handler: TransportHandler) -> list[Track]:
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
return list(await _client(http).search_tracks("mapped")) return list(await _client(http).search_tracks("mapped"))
@ -251,18 +276,22 @@ def _search_payload() -> dict[str, object]:
"tracks": { "tracks": {
"total": 0, "total": 0,
"items": [ "items": [
{ _track_payload(),
"id": "track-1",
"uri": "spotify:track:1",
"name": "Mapped song",
"artists": [{"name": "First artist"}, {"name": "Second artist"}],
"album": {
"name": "Mapped album",
"images": [{"url": "https://images.example/cover.jpg"}],
},
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
},
{"id": "missing-required-fields"}, {"id": "missing-required-fields"},
], ],
} }
} }
def _track_payload() -> dict[str, object]:
return {
"id": "track-1",
"uri": "spotify:track:1",
"name": "Mapped song",
"artists": [{"name": "First artist"}, {"name": "Second artist"}],
"album": {
"name": "Mapped album",
"images": [{"url": "https://images.example/cover.jpg"}],
},
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
}