From 8c42c45e0c2dda4d286432d40aee18d700a43f53 Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 10:56:44 +0200 Subject: [PATCH 01/12] Add play cli command with fallback --- README.md | 136 ++++++++-- spotifyify/cli/__init__.py | 73 +++++- spotifyify/cli/_agent_help.py | 109 ++++++++ spotifyify/cli/_aliases.py | 94 +++++++ spotifyify/cli/_core.py | 329 ++++++++++++++++++++++-- spotifyify/cli/_options.py | 80 +++++- spotifyify/cli/albums.py | 86 ++++--- spotifyify/cli/artists.py | 93 +++++-- spotifyify/cli/episodes.py | 67 +++-- spotifyify/cli/library.py | 283 +++++++++++++++++--- spotifyify/cli/player.py | 363 +++++++++++++++++++++----- spotifyify/cli/playlists.py | 203 +++++++++++---- spotifyify/cli/quick.py | 141 ++++++++++ spotifyify/cli/shows.py | 77 ++++-- spotifyify/cli/tracks.py | 66 +++-- spotifyify/cli/users.py | 139 ++++++++-- spotifyify/http/response.py | 13 +- tests/http/test_response.py | 6 + tests/test_cli.py | 471 +++++++++++++++++++++++++++++++++- tests/test_cli_player.py | 116 +++++++++ 20 files changed, 2584 insertions(+), 361 deletions(-) create mode 100644 spotifyify/cli/_agent_help.py create mode 100644 spotifyify/cli/_aliases.py create mode 100644 spotifyify/cli/quick.py create mode 100644 tests/test_cli_player.py diff --git a/README.md b/README.md index 7db8c76..bb9224b 100644 --- a/README.md +++ b/README.md @@ -332,57 +332,142 @@ uv tool install --from . "spotifyify[cli]" spotifyify --help ``` +### Output contract + +Every command writes JSON to stdout and nothing else. The format depends only on +arguments and environment — never on whether stdout is a terminal — so piping +through `tee` or capturing the output cannot change its shape. + +| | | +| --- | --- | +| Format | JSON by default; `--format table` for aligned text, `--format json` to be explicit | +| Shape | A JSON array of row objects whose keys are the command's declared columns, in a fixed order | +| Encoding | UTF-8, no ANSI escapes, no pager, no prompts | +| Errors | Plain text on stderr | +| Exit codes | `0` ok, `1` API error, `2` usage error, `3` auth error, `4` no match | + +Set `SPOTIFYIFY_FORMAT=table` to change the default for an interactive shell. + +```bash +spotifyify tracks search "Ikkimel" --limit 2 +``` + +```json +[ + { + "id": "4H0ly29pj5g6vMKum5kkhu", + "name": "WHO'S THAT", + "artists": ["Ikkimel"], + "album.name": "WHO'S THAT", + "uri": "spotify:track:4H0ly29pj5g6vMKum5kkhu" + } +] +``` + +Both formats project the same fields, so `--format` changes the encoding and +nothing else. `--raw` opts out into the untouched Spotify payload when you need +paging metadata or a field that is not a declared column. + +### One-shot discoverability + +`--agent-help` dumps the entire command tree and this output contract in a single +call, small enough to paste into a system prompt: + +```bash +spotifyify --agent-help +``` + +Group names also tolerate the singular form, so `spotifyify artist get ID` works +as well as `artists`. Unknown names list the alternatives in the error itself +rather than requiring a `--help` round trip. + ### Everyday usage The CLI mirrors the public namespace API from `spotifyify.namespaces`: ```bash spotifyify tracks search "Daft Punk" --limit 5 -spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy --json +spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy spotifyify playlists list --scope playlist-read-private ``` -Search and read commands print compact tables by default. Mutating commands -print `OK` or a Spotify snapshot ID. Use `--json` when scripts or agents need -machine-readable output: +To find something and play it without a separate lookup: + +```bash +spotifyify play --artist Ikkimel --track "WHO'S THAT" +``` + +A track name (or free text) plays that one track; without one, `--album` plays +the album and `--artist` alone plays the artist. If Spotify reports no active +device, the CLI picks a controllable one and retries. + +### Mutations return the new state + +Commands that change something report the state they produced, so no follow-up +read is needed: ```bash -spotifyify playlists add PLAYLIST_ID spotify:track:TRACK_ID --json -spotifyify library check-tracks TRACK_ID_1,TRACK_ID_2 --json -spotifyify users check-following artist ARTIST_ID --json +spotifyify player play --uri spotify:track:TRACK_ID +``` + +```json +[{"state": "playing", "track": "HAMPELMANN", "artists": ["Ikkimel"], "device": "Wohnzimmer"}] ``` -### Filtering output +Playback commands briefly wait for Spotify to apply the change before reporting; +pass `--no-wait` to skip that and read immediately. Library and follow mutations +report the resulting saved/following state, and playlist mutations report the new +snapshot and length. -Use `--field`, `--fields`, or `-f` to keep only selected response fields. The -option can be repeated or passed as a comma-separated list: +### Filtering and ordering ```bash -spotifyify tracks search "Daft Punk" --limit 3 --field id --field name --field uri -spotifyify tracks search "Daft Punk" --json --fields items.0.id,items.0.name -spotifyify player state --json --fields item.name,is_playing,progress_ms -spotifyify library saved-tracks --json --fields track.id,track.name,added_at +spotifyify tracks search "Daft Punk" --limit 3 --field id,name,uri +spotifyify library saved-tracks --sort track.name +spotifyify library top-tracks --limit 50 --where "artists=Ikkimel" +spotifyify playlists tracks PLAYLIST_ID --spotify-fields "items(track(id,name))" ``` -IDs, URIs, scopes, and fields accept repeated values or comma-separated values: +| Option | Effect | +| --- | --- | +| `--field`, `-f` | Replace the declared columns with the given field paths | +| `--sort` | Stable re-sort by field path; prefix `-` for descending, repeat for tiebreakers | +| `--where` | Keep rows whose field path contains a value, case-insensitively | +| `--spotify-fields` | Server-side filter applied by Spotify before it sends the response | + +Rows otherwise keep the order Spotify returned them in. `--sort` is stable, so +ties keep that order and repeated calls are reproducible. + +### Batching + +Commands that take IDs or URIs are variadic and accept repeated or +comma-separated values. One call fans out to as many API requests as Spotify's +per-endpoint id limits require: ```bash -spotifyify tracks get-many 4uLU6hMCjMI75M1A2tKUQC,0DiWol3AO6WpXZgp0goxAV +spotifyify tracks get ID_1 ID_2 ID_3 +spotifyify albums get ID_1,ID_2 spotifyify playlists add PLAYLIST_ID spotify:track:ID_1 spotify:track:ID_2 -spotifyify playlists list --scope playlist-read-private,user-library-read +spotifyify player add-to-queue spotify:track:ID_1 spotify:track:ID_2 +spotifyify library save-tracks ID_1,ID_2,ID_3 ``` ### Common options | Option | Description | | ------ | ----------- | -| `--json` | Print the raw Pydantic response payload as JSON instead of a compact table | +| `--format` | `json` (default) or `table` | +| `--json` | Shorthand for `--format json` | +| `--raw` | Emit the untouched Spotify payload | | `--field`, `--fields`, `-f` | Include only selected field paths | +| `--sort` | Stable sort by field path | +| `--where` | Keep only matching rows | | `--scope`, `-s` | Request OAuth scopes | | `--limit`, `-l` | Number of items to fetch, capped at Spotify's per-endpoint limits | | `--offset`, `-o` | Result offset for paginated endpoints | | `--market`, `-m` | ISO 3166-1 alpha-2 market code | | `--device-id` | Target Spotify Connect device for playback commands | +| `--wait` / `--no-wait` | Whether playback mutations wait for the change to take effect | Most user-scoped commands set the matching default scope automatically. Override or extend scopes with `--scope` when you need a different authorization grant. @@ -394,20 +479,21 @@ client. | Namespace | Commands | | --------- | -------- | -| `tracks` | `search`, `get`, `get-many` | -| `artists` | `search`, `get`, `get-many`, `top-tracks`, `albums`, `related` | -| `albums` | `search`, `get`, `get-many`, `tracks`, `new-releases` | +| *(top level)* | `play` | +| `tracks` | `search`, `get` | +| `artists` | `search`, `get`, `top-tracks`, `albums`, `related` | +| `albums` | `search`, `get`, `tracks`, `new-releases` | | `playlists` | `search`, `get`, `list`, `tracks`, `create`, `update`, `add`, `replace`, `remove`, `reorder`, `cover-image` | -| `shows` | `search`, `get`, `get-many`, `episodes` | -| `episodes` | `search`, `get`, `get-many` | +| `shows` | `search`, `get`, `episodes` | +| `episodes` | `search`, `get` | | `library` | `saved-tracks`, `saved-albums`, `saved-shows`, `saved-episodes`, `top-tracks`, `top-artists`, `save-*`, `remove-*`, `check-*` for tracks/albums/shows/episodes | | `player` | `state`, `play`, `pause`, `skip`, `previous`, `seek`, `repeat`, `shuffle`, `volume`, `queue`, `add-to-queue`, `transfer`, `devices`, `recently-played` | | `users` | `me`, `get`, `following`, `follow`, `unfollow`, `check-following` | -Use Typer's built-in help to inspect exact arguments and options: +`get` replaces the former `get-many`, which is still accepted as an alias. ```bash -spotifyify --help +spotifyify --agent-help spotifyify playlists create --help spotifyify player play --help ``` diff --git a/spotifyify/cli/__init__.py b/spotifyify/cli/__init__.py index 3ae6e76..5783a0e 100644 --- a/spotifyify/cli/__init__.py +++ b/spotifyify/cli/__init__.py @@ -1,34 +1,66 @@ from __future__ import annotations +import sys +from typing import Annotated + try: import typer except ImportError: # pragma: no cover - exercised by installed package users. typer = None from ._core import ( + FORMATS, INSTALL_MESSAGE, + _apply_sort, + _apply_where, + _cell, _filter_fields, _get_path, _parse_scopes, + _playback_summary, + _resolve_format, + _rows, + _sort_items, _split_values, _table, ) __all__ = [ + "FORMATS", "INSTALL_MESSAGE", "main", "typer", + "_apply_sort", + "_apply_where", + "_cell", "_filter_fields", "_get_path", "_parse_scopes", + "_playback_summary", + "_resolve_format", + "_rows", + "_sort_items", "_split_values", "_table", ] +def _force_utf8() -> None: + """Emit UTF-8 whatever the console codepage is. + + On Windows stdout otherwise defaults to the ANSI codepage, so a captured or + redirected result would not be valid UTF-8. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8", errors="replace") + + def main() -> None: if typer is None: raise SystemExit(INSTALL_MESSAGE) + _force_utf8() app() @@ -40,12 +72,28 @@ def main() -> None: library, player, playlists, + quick, shows, tracks, users, ) + from ._agent_help import agent_help + from ._aliases import AliasGroup + + __all__ += ["agent_help", "app"] - app = typer.Typer(help="Command line tools for the spotifyify Spotify client.") + app = typer.Typer( + help="Command line tools for the spotifyify Spotify client.", + cls=AliasGroup, + # Plain click help: no ANSI, no boxes, no pager. + rich_markup_mode=None, + # Completion installers are Typer's only interactive code path. + add_completion=False, + # Rich renders tracebacks as ANSI boxes; keep failures plain text. + pretty_exceptions_enable=False, + no_args_is_help=True, + context_settings={"help_option_names": ["-h", "--help"], "color": False}, + ) app.add_typer(tracks.app, name="tracks") app.add_typer(artists.app, name="artists") app.add_typer(albums.app, name="albums") @@ -55,6 +103,29 @@ def main() -> None: app.add_typer(library.app, name="library") app.add_typer(player.app, name="player") app.add_typer(users.app, name="users") + quick.register(app) + + def _render_agent_help() -> str: + return agent_help(typer.main.get_command(app)) + + @app.command("agent-help", hidden=True) + def agent_help_command() -> None: + """Print the whole command tree and output contract in one shot.""" + typer.echo(_render_agent_help()) + + @app.callback(invoke_without_command=True) + def _root( + agent: Annotated[ + bool, + typer.Option( + "--agent-help", + help="Print the whole command tree and output contract in one shot.", + ), + ] = False, + ) -> None: + if agent: + typer.echo(_render_agent_help()) + raise typer.Exit() if __name__ == "__main__": diff --git a/spotifyify/cli/_agent_help.py b/spotifyify/cli/_agent_help.py new file mode 100644 index 0000000..3c7f7ab --- /dev/null +++ b/spotifyify/cli/_agent_help.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +import click + +# Options every read command shares. Listing them once keeps the per-command +# lines short enough that the whole tree fits in a system prompt. +GLOBAL_OPTIONS = frozenset( + { + "--format", + "--json", + "--field", + "--fields", + "-f", + "--sort", + "--where", + "--limit", + "-l", + "--offset", + "-o", + "--market", + "-m", + "--scope", + "-s", + "--help", + } +) + +PREAMBLE = """\ +spotifyify GROUP COMMAND [ARGS] [OPTIONS] + +Output contract: + JSON on stdout, always. --format table for aligned text. Never switches on isatty(). + No ANSI, no pager, no prompts. Errors go to stderr. + Exit 0 ok, 1 API error, 2 usage, 3 auth, 4 no match. + Row order is whatever Spotify returned; --sort FIELD is a stable re-sort ('-' reverses). + Mutations print the state they produced, not "ok". +Global options: --format json|table --json --field F,F --sort F,-F --where PATH=VALUE + --limit N --offset N --market XX --scope S +IDs, URIs, fields and scopes accept repeated or comma-separated values. +Group names tolerate the singular form (artist -> artists). + +Commands (ARG... means variadic):""" + + +def _is_group(command: Any) -> bool: + # Typer vendors its own click classes, so isinstance against click is unsafe. + return hasattr(command, "list_commands") + + +def _is_argument(param: Any) -> bool: + return getattr(param, "param_type_name", None) == "argument" + + +def _argument_names(command: Any) -> list[str]: + return [ + f"{param.name.upper()}..." if param.nargs == -1 else param.name.upper() + for param in command.params + if _is_argument(param) + ] + + +def _option_names(command: Any) -> list[str]: + names = [] + for param in command.params: + if _is_argument(param): + continue + long_opts = [opt for opt in param.opts if opt.startswith("--")] + if not long_opts: + continue + flag = long_opts[0] + if flag in GLOBAL_OPTIONS: + continue + names.append(flag) + return names + + +def _signature(name: str, command: Any) -> str: + parts = [name, *_argument_names(command)] + options = _option_names(command) + if options: + parts.append(f"[{' '.join(options)}]") + return " ".join(parts) + + +def _group_line(name: str, group: Any, ctx: click.Context) -> str: + signatures = [] + for command_name in group.list_commands(ctx): + command = group.get_command(ctx, command_name) + if command is None or command.hidden: + continue + signatures.append(_signature(command_name, command)) + return f"{name}: " + " | ".join(signatures) + + +def agent_help(root: Any, program_name: str = "spotifyify") -> str: + """Dump the whole command tree in one shot, straight from the real tree.""" + lines = [PREAMBLE] + with click.Context(root, info_name=program_name) as ctx: + for name in root.list_commands(ctx): + command = root.get_command(ctx, name) + if command is None or command.hidden: + continue + if _is_group(command): + lines.append(_group_line(name, command, ctx)) + else: + lines.append(_signature(name, command)) + return "\n".join(lines) diff --git a/spotifyify/cli/_aliases.py b/spotifyify/cli/_aliases.py new file mode 100644 index 0000000..cc6f6e2 --- /dev/null +++ b/spotifyify/cli/_aliases.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import Any + +from typer.core import TyperGroup + +try: + # Typer vendors its own copy of click; its UsageError is a different class + # from click.UsageError, so catching the latter would silently miss. + from typer._click.exceptions import UsageError +except ImportError: # pragma: no cover - falls back to plain click errors. + from click import UsageError + +# Names an agent is likely to reach for that are not the canonical spelling. +# Tolerating them costs nothing; failing on them costs a --help round trip. +SYNONYMS: dict[str, str] = { + "find": "search", + "query": "search", + "ls": "list", + "show": "get", + "get-many": "get", + "getmany": "get", + "song": "tracks", + "songs": "tracks", + "podcast": "shows", + "podcasts": "shows", + "playback": "player", + "status": "state", + "now-playing": "state", + "current": "state", + "next": "skip", + "forward": "skip", + "prev": "previous", + "back": "previous", + "enqueue": "add-to-queue", + "queue-add": "add-to-queue", + "resume": "play", + "start": "play", + "stop": "pause", + "profile": "me", + "whoami": "me", + "saved-track": "saved-tracks", + "saved-album": "saved-albums", + "recent": "recently-played", + "history": "recently-played", +} + + +def _candidates(name: str) -> list[str]: + """Spellings to try, in order, before giving up on a command name.""" + normalized = name.strip().lower().replace("_", "-") + tried = [normalized] + if normalized in SYNONYMS: + tried.append(SYNONYMS[normalized]) + # Plural/singular tolerance: `artist` and `artists` should both work. + tried.append(f"{normalized}s") + if normalized.endswith("s"): + tried.append(normalized[:-1]) + return tried + + +class AliasGroup(TyperGroup): + """A command group that accepts near-miss spellings instead of failing. + + Aliases resolve deterministically through an explicit table plus + plural/singular normalization. Prefix matching is deliberately not + supported: it would silently change meaning whenever a command is added. + """ + + def get_command(self, ctx: Any, name: str) -> Any: + command = super().get_command(ctx, name) + if command is not None: + return command + for candidate in _candidates(name): + command = super().get_command(ctx, candidate) + if command is not None: + return command + return None + + def resolve_command(self, ctx: Any, args: list[str]) -> Any: + try: + return super().resolve_command(ctx, args) + except UsageError as exc: + # Name the alternatives in the failure itself, so a wrong guess + # costs one call rather than a --help round trip. + message = getattr(exc, "message", None) + if args and isinstance(message, str) and "No such command" in message: + known = ", ".join( + name + for name in self.list_commands(ctx) + if not getattr(self.get_command(ctx, name), "hidden", False) + ) + exc.message = f"{message} Available: {known}" + raise diff --git a/spotifyify/cli/_core.py b/spotifyify/cli/_core.py index 58f3395..98fbb6d 100644 --- a/spotifyify/cli/_core.py +++ b/spotifyify/cli/_core.py @@ -1,6 +1,9 @@ from __future__ import annotations +import asyncio import json +import os +import re from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import Any @@ -20,6 +23,15 @@ DEFAULT_LIMIT = 10 INSTALL_MESSAGE = "Install the CLI dependencies with: uv add spotifyify[cli]" +FORMAT_JSON = "json" +FORMAT_TABLE = "table" +FORMATS = (FORMAT_JSON, FORMAT_TABLE) +FORMAT_ENV_VAR = "SPOTIFYIFY_FORMAT" + +# Table cells are single-line by construction, so anything that could forge a +# row boundary or smuggle escape sequences is folded into a single space. +_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") + def _as_jsonable(value: Jsonable) -> Any: if isinstance(value, BaseModel): @@ -54,6 +66,13 @@ def _coalesce_scopes(scope_values: Sequence[str] | None) -> Sequence[str]: return scope_values or () +def _merge_scopes(*scope_groups: Sequence[str]) -> list[str]: + merged: list[str] = [] + for group in scope_groups: + merged.extend(scope for scope in group if scope not in merged) + return merged + + def _parse_json_object( raw_value: str | None, option_name: str ) -> dict[str, Any] | None: @@ -103,6 +122,8 @@ def _format_value(value: Any) -> str: value = _as_jsonable(value) if value is None: return "" + if isinstance(value, bool): + return "true" if value else "false" if isinstance(value, list): return ", ".join(_format_value(item) for item in value) if isinstance(value, dict): @@ -114,6 +135,36 @@ def _format_value(value: Any) -> str: return str(value) +def _cell(value: Any) -> str: + """Render one field as a single-line, escape-free table cell.""" + return _CONTROL_CHARACTERS.sub(" ", _format_value(value)) + + +def _project(value: Any) -> Any: + """Reduce a field to its useful part, keeping JSON types intact. + + Nested Spotify objects collapse to their name, so a column reads + `"artists": ["Ikkimel"]` rather than two screens of artist objects, while + numbers and booleans stay numbers and booleans. + """ + value = _as_jsonable(value) + if isinstance(value, list): + return [_project(item) for item in value] + if isinstance(value, dict): + for key in ("name", "id"): + if key in value: + return value[key] + return value + + +def _rows(value: Any, columns: Sequence[str]) -> list[dict[str, Any]]: + """The command's declared columns, in order, for every item in the result.""" + return [ + {column: _project(_get_path(item, column)) for column in columns} + for item in _items(value) + ] + + def _items(value: Any) -> list[Any]: value = _as_jsonable(value) if isinstance(value, dict) and isinstance(value.get("items"), list): @@ -127,6 +178,108 @@ def _items(value: Any) -> list[Any]: return [value] +def _sort_key(value: Any) -> tuple[int, float, str]: + """Total order across mixed types so sorting never raises and never varies.""" + value = _as_jsonable(value) + if value is None: + return (2, 0.0, "") + if isinstance(value, bool): + return (0, float(value), "") + if isinstance(value, (int, float)): + return (0, float(value), "") + return (1, 0.0, _cell(value).casefold()) + + +def _sort_items(items: Sequence[Any], specs: Sequence[str]) -> list[Any]: + """Stable multi-key sort; ties keep the order Spotify returned them in.""" + ordered = list(items) + for spec in reversed(list(specs)): + descending = spec.startswith("-") + path = spec[1:] if descending else spec + if not path: + continue + ordered.sort( + key=lambda item: _sort_key(_get_path(item, path)), reverse=descending + ) + return ordered + + +def _replace_collection( + payload: Any, transform: Callable[[list[Any]], list[Any]] +) -> Any: + """Apply a row transform to the item list, wherever the envelope keeps it.""" + if isinstance(payload, dict): + for key in ("items", "queue"): + if isinstance(payload.get(key), list): + return {**payload, key: transform(payload[key])} + return payload + if isinstance(payload, list): + return transform(payload) + return payload + + +def _apply_sort(value: Any, specs: Sequence[str]) -> Any: + if not specs: + return value + return _replace_collection( + _as_jsonable(value), lambda items: _sort_items(items, specs) + ) + + +def _parse_where(specs: Sequence[str] | None) -> list[tuple[str, str]]: + predicates: list[tuple[str, str]] = [] + for spec in specs or (): + path, separator, expected = spec.partition("=") + if not separator or not path: + message = f"--where must look like PATH=VALUE, got {spec!r}" + if typer is None: + raise ValueError(message) + raise typer.BadParameter(message) + predicates.append((path, expected.casefold())) + return predicates + + +def _matches(item: Any, predicates: Sequence[tuple[str, str]]) -> bool: + return all( + expected in _cell(_get_path(item, path)).casefold() + for path, expected in predicates + ) + + +def _apply_where(value: Any, specs: Sequence[str] | None) -> Any: + """Drop rows the caller does not want before they reach the output.""" + predicates = _parse_where(specs) + if not predicates: + return value + return _replace_collection( + _as_jsonable(value), + lambda items: [item for item in items if _matches(item, predicates)], + ) + + +def _resolve_format(fmt: str | None, *, json_output: bool = False) -> str: + """Explicit --format wins, then --json, then the environment, then JSON. + + Deliberately not switched on isatty(): the shape of a command's output + depends only on its arguments and environment, so piping through `tee` or + capturing stdout cannot silently change it. + """ + if fmt: + value = fmt.strip().lower() + if value not in FORMATS: + message = f"--format must be one of: {', '.join(FORMATS)}" + if typer is None: + raise ValueError(message) + raise typer.BadParameter(message) + return value + if json_output: + return FORMAT_JSON + from_env = (os.environ.get(FORMAT_ENV_VAR) or "").strip().lower() + if from_env in FORMATS: + return from_env + return FORMAT_JSON + + def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: if not rows: return "No results." @@ -146,41 +299,167 @@ def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: return "\n".join(rendered) -def _print_json(value: Jsonable, *, fields: Sequence[str] = ()) -> None: - payload = _filter_fields(_as_jsonable(value), fields) - text = json.dumps(payload, indent=2, ensure_ascii=False) +def _echo(text: str) -> None: if typer is None: print(text) return typer.echo(text) -def _print_table( - value: Jsonable, - columns: Sequence[str], - *, - fields: Sequence[str] = (), -) -> None: - selected_columns = fields or columns - rows = [ - [_format_value(_get_path(item, column)) for column in selected_columns] - for item in _items(value) - ] - headers = [column.replace(".", " ").title() for column in selected_columns] - text = _table(headers, rows) - if typer is None: - print(text) - return - typer.echo(text) +def _print_json(value: Any) -> None: + _echo(json.dumps(_as_jsonable(value), indent=2, ensure_ascii=False)) -def _print_success(value: str | None = None) -> None: - if typer is None: - print(value or "OK") - return - typer.echo(value or "OK") +def _print_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> None: + headers = [column.replace(".", " ").title() for column in columns] + cells = [[_cell(row.get(column)) for column in columns] for row in rows] + _echo(_table(headers, cells)) async def _run(command: AsyncCommand, *, scopes: Sequence[str]) -> Jsonable: async with Spotifyify(scopes=_parse_scopes(scopes)) as spotify: return await command(spotify) + + +# --------------------------------------------------------------------------- # +# Batching — one CLI call fans out to as many API calls as Spotify's id limits +# require, instead of making the caller loop. +# --------------------------------------------------------------------------- # + +# Maximum ids Spotify accepts per request, per endpoint family. +BATCH_TRACKS = 50 +BATCH_ARTISTS = 50 +BATCH_ALBUMS = 20 +BATCH_SHOWS = 50 +BATCH_EPISODES = 50 +BATCH_FOLLOW = 50 + + +def _chunked(values: Sequence[str], size: int) -> list[list[str]]: + if not values: + return [] + return [list(values[index : index + size]) for index in range(0, len(values), size)] + + +async def _gather_batches( + action: Callable[[list[str]], Awaitable[Sequence[Any]]], + ids: Sequence[str], + size: int, +) -> list[Any]: + """Fan out reads concurrently, then concatenate in request order.""" + chunks = _chunked(ids, size) + if not chunks: + return [] + results = await asyncio.gather(*(action(chunk) for chunk in chunks)) + return [item for result in results for item in result] + + +async def _sequential_batches( + action: Callable[[list[str]], Awaitable[Any]], + ids: Sequence[str], + size: int, +) -> None: + """Apply writes chunk by chunk so partial failures stay comprehensible.""" + for chunk in _chunked(ids, size): + await action(chunk) + + +# --------------------------------------------------------------------------- # +# Playback summaries — mutations answer with the state they produced. +# --------------------------------------------------------------------------- # + +PLAYBACK_COLUMNS = ("state", "track", "artists", "device") +SETTLE_ATTEMPTS = 4 +SETTLE_DELAY_SECONDS = 0.25 + +Predicate = Callable[[Any], bool] + + +def _playback_summary(state: Any) -> dict[str, Any]: + """A flat, fixed-key view of playback that mutations and reads both emit.""" + if state is None: + return { + "state": "stopped", + "track": "", + "artists": [], + "album": "", + "device": "", + "progress_ms": None, + "duration_ms": None, + "shuffle": None, + "repeat": "", + "uri": "", + } + payload = _as_jsonable(state) + item = payload.get("item") or {} + artists = item.get("artists") or [] + show = item.get("show") or {} + album = item.get("album") or {} + return { + "state": "playing" if payload.get("is_playing") else "paused", + "track": item.get("name") or "", + # A list here too, matching how every other command reports artists. + "artists": [artist.get("name") or "" for artist in artists] + or ([show["publisher"]] if show.get("publisher") else []), + "album": album.get("name") or show.get("name") or "", + "device": (payload.get("device") or {}).get("name") or "", + "progress_ms": payload.get("progress_ms"), + "duration_ms": item.get("duration_ms"), + "shuffle": payload.get("shuffle_state"), + "repeat": payload.get("repeat_state") or "", + "uri": item.get("uri") or "", + } + + +def is_playing(state: Any) -> bool: + return bool(getattr(state, "is_playing", False)) + + +def is_paused(state: Any) -> bool: + return state is not None and not getattr(state, "is_playing", False) + + +def is_fresh_track(state: Any) -> bool: + """A just-started track: playing and barely into its runtime.""" + return is_playing(state) and (getattr(state, "progress_ms", None) or 0) < 5000 + + +def plays_uri(uri: str | None) -> Predicate: + """Wait for the track we actually asked for. + + `is_playing` alone is satisfied immediately by whatever was already playing, + which would report the previous track as if it were the new one. + """ + if uri is None: + return is_fresh_track + + def matches(state: Any) -> bool: + return ( + is_playing(state) + and getattr(getattr(state, "item", None), "uri", None) == uri + ) + + return matches + + +async def _settled_playback( + spotify: Spotifyify, + *, + until: Predicate | None = None, + wait: bool = True, +) -> dict[str, Any]: + """Read playback back after a mutation, briefly waiting for it to take effect. + + Spotify applies playback commands asynchronously, so an immediate read can + still describe the previous track. Polling here costs a fraction of a second + but saves the caller a whole second round trip. + """ + attempts = SETTLE_ATTEMPTS if (wait and until is not None) else 1 + state = None + for attempt in range(attempts): + state = await spotify.player.state() + if until is None or until(state): + break + if attempt < attempts - 1: + await asyncio.sleep(SETTLE_DELAY_SECONDS) + return _playback_summary(state) diff --git a/spotifyify/cli/_options.py b/spotifyify/cli/_options.py index 85062d4..f781589 100644 --- a/spotifyify/cli/_options.py +++ b/spotifyify/cli/_options.py @@ -9,16 +9,25 @@ from spotifyify import SpotifyAPIError, SpotifyAuthError from ._core import ( + FORMAT_JSON, + FORMATS, AsyncCommand, Jsonable, + _apply_sort, + _apply_where, _print_json, _print_table, + _resolve_format, + _rows, _run, _split_values, ) DEFAULT_LIMIT = 10 +EXIT_API_ERROR = 1 +EXIT_AUTH_ERROR = 3 + ScopeOption = Annotated[ list[str] | None, typer.Option( @@ -27,9 +36,23 @@ help="OAuth scope. Can be repeated or comma-separated.", ), ] +FormatOption = Annotated[ + str | None, + typer.Option( + "--format", + help=f"Output format: {'|'.join(FORMATS)}. Defaults to json.", + ), +] JsonOption = Annotated[ bool, - typer.Option("--json", help="Print JSON instead of a compact table."), + typer.Option("--json", help="Shorthand for --format json."), +] +RawOption = Annotated[ + bool, + typer.Option( + "--raw", + help="Emit the untouched Spotify payload instead of the declared columns.", + ), ] FieldsOption = Annotated[ list[str] | None, @@ -40,6 +63,20 @@ help="Field path to include. Can be repeated or comma-separated.", ), ] +SortOption = Annotated[ + list[str] | None, + typer.Option( + "--sort", + help="Field path to sort by; prefix with '-' for descending. Stable, repeatable.", + ), +] +WhereOption = Annotated[ + list[str] | None, + typer.Option( + "--where", + help="Keep rows where PATH contains VALUE (case-insensitive). Repeatable, AND-ed.", + ), +] LimitOption = Annotated[ int, typer.Option("--limit", "-l", min=1, max=50, help="Number of items to fetch."), @@ -56,6 +93,13 @@ str | None, typer.Option("--device-id", help="Spotify device ID."), ] +WaitOption = Annotated[ + bool, + typer.Option( + "--wait/--no-wait", + help="Wait for playback to reflect the change before reporting state.", + ), +] IdsArgument = Annotated[ list[str], typer.Argument(help="One or more IDs. Values can also be comma-separated."), @@ -69,20 +113,38 @@ def _handle(command: AsyncCommand, *, scopes: Sequence[str]) -> Jsonable: try: return asyncio.run(_run(command, scopes=scopes)) - except (SpotifyAPIError, SpotifyAuthError) as exc: + except SpotifyAuthError as exc: typer.echo(str(exc), err=True) - raise typer.Exit(1) from exc + raise typer.Exit(EXIT_AUTH_ERROR) from exc + except SpotifyAPIError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(EXIT_API_ERROR) from exc def _render( result: Jsonable, *, - json_output: bool, - fields: Sequence[str] | None, columns: Sequence[str], + fmt: str | None = None, + json_output: bool = False, + raw: bool = False, + fields: Sequence[str] | None = None, + sort: Sequence[str] | None = None, + where: Sequence[str] | None = None, ) -> None: - selected_fields = _split_values(fields) - if json_output: - _print_json(result, fields=selected_fields) + """Emit the command's declared columns, in order, in the chosen format. + + Both formats project the same fixed set of fields, so switching format + changes the encoding and nothing else. --raw opts out into the untouched + Spotify payload. + """ + payload = _apply_sort(_apply_where(result, where), _split_values(sort)) + if raw: + _print_json(payload) + return + selected_columns = _split_values(fields) or list(columns) + rows = _rows(payload, selected_columns) + if _resolve_format(fmt, json_output=json_output) == FORMAT_JSON: + _print_json(rows) return - _print_table(result, columns, fields=selected_fields) + _print_table(rows, selected_columns) diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index 24f9640..6c6495d 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -4,21 +4,33 @@ import typer -from ._core import _coalesce_scopes, _split_values +from ._aliases import AliasGroup +from ._core import BATCH_ALBUMS, _coalesce_scopes, _gather_batches, _split_values from ._options import ( DEFAULT_LIMIT, FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify albums.") +app = typer.Typer( + help="Work with Spotify albums.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "artists", "uri") @app.command("search") @@ -27,8 +39,12 @@ def search_albums( limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -39,51 +55,47 @@ def search_albums( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) @app.command("get") -def get_album( - album_id: Annotated[str, typer.Argument(help="Spotify album ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.albums.get(album_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "artists", "uri"), - ) - - -@app.command("get-many") -def get_many_albums( +def get_albums( album_ids: IdsArgument, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: + """Fetch one or many albums in a single call.""" + ids = _split_values(album_ids) result = _handle( - lambda spotify: spotify.albums.get_many( - _split_values(album_ids), market=market + lambda spotify: _gather_batches( + lambda chunk: spotify.albums.get_many(chunk, market=market), + ids, + BATCH_ALBUMS, ), scopes=_coalesce_scopes(scope), ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) @@ -93,8 +105,12 @@ def album_tracks( limit: LimitOption = 50, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -105,9 +121,13 @@ def album_tracks( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) @@ -116,8 +136,12 @@ def new_releases( country: Annotated[str | None, typer.Option("--country", "-c")] = None, limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -128,7 +152,11 @@ def new_releases( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index c1b5ae7..4eadefa 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -4,21 +4,35 @@ import typer -from ._core import _coalesce_scopes, _split_values +from ._aliases import AliasGroup +from ._core import BATCH_ARTISTS, _coalesce_scopes, _gather_batches, _split_values from ._options import ( DEFAULT_LIMIT, FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify artists.") +app = typer.Typer( + help="Work with Spotify artists.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "uri") +TRACK_COLUMNS = ("id", "name", "artists", "uri") +ALBUM_COLUMNS = ("id", "name", "album_type", "uri") @app.command("search") @@ -26,8 +40,12 @@ def search_artists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -36,47 +54,42 @@ def search_artists( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "uri"), + sort=sort, + where=where, ) @app.command("get") -def get_artist( - artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.artists.get(artist_id), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "uri"), - ) - - -@app.command("get-many") -def get_many_artists( +def get_artists( artist_ids: IdsArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: + """Fetch one or many artists in a single call.""" + ids = _split_values(artist_ids) result = _handle( - lambda spotify: spotify.artists.get_many(_split_values(artist_ids)), + lambda spotify: _gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS), scopes=_coalesce_scopes(scope), ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "uri"), + sort=sort, + where=where, ) @@ -84,8 +97,12 @@ def get_many_artists( def artist_top_tracks( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], market: Annotated[str, typer.Option("--market", "-m")] = "US", + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -94,9 +111,13 @@ def artist_top_tracks( ) _render( result, + columns=TRACK_COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) @@ -110,8 +131,12 @@ def artist_albums( market: MarketOption = None, limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -126,17 +151,25 @@ def artist_albums( ) _render( result, + columns=ALBUM_COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "album_type", "uri"), + sort=sort, + where=where, ) @app.command("related") def related_artists( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -145,7 +178,11 @@ def related_artists( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "uri"), + sort=sort, + where=where, ) diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index 32d8e5c..6109c93 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -4,21 +4,34 @@ import typer -from ._core import _coalesce_scopes, _split_values +from ._aliases import AliasGroup +from ._core import BATCH_EPISODES, _coalesce_scopes, _gather_batches, _split_values from ._options import ( DEFAULT_LIMIT, FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify episodes.") +app = typer.Typer( + help="Work with Spotify episodes.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +SEARCH_COLUMNS = ("id", "name", "release_date", "uri") +COLUMNS = ("id", "name", "show.name", "uri") @app.command("search") @@ -27,8 +40,12 @@ def search_episodes( limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -39,49 +56,45 @@ def search_episodes( ) _render( result, + columns=SEARCH_COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "release_date", "uri"), + sort=sort, + where=where, ) @app.command("get") -def get_episode( - episode_id: Annotated[str, typer.Argument(help="Spotify episode ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.episodes.get(episode_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "show.name", "uri"), - ) - - -@app.command("get-many") -def get_many_episodes( +def get_episodes( episode_ids: IdsArgument, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: + """Fetch one or many episodes in a single call.""" + ids = _split_values(episode_ids) result = _handle( - lambda spotify: spotify.episodes.get_many( - _split_values(episode_ids), market=market + lambda spotify: _gather_batches( + lambda chunk: spotify.episodes.get_many(chunk, market=market), + ids, + BATCH_EPISODES, ), scopes=_coalesce_scopes(scope), ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "show.name", "uri"), + sort=sort, + where=where, ) diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 4ce08eb..81ef131 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -1,54 +1,93 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Sequence -from typing import Annotated +from typing import Annotated, Any import typer from spotifyify import Spotifyify, SpotifyScope +from ._aliases import AliasGroup from ._core import ( + BATCH_ALBUMS, + BATCH_EPISODES, + BATCH_SHOWS, + BATCH_TRACKS, Jsonable, _coalesce_scopes, - _print_json, - _print_success, - _print_table, + _merge_scopes, + _sequential_batches, _split_values, ) from ._options import ( FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with the current user's library.") +app = typer.Typer( + help="Work with the current user's library.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) +SAVED_COLUMNS = ("id", "saved") -def _saved_scope(scope: Sequence[str] | None) -> Sequence[str]: - return _coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_READ.value] +READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ.value] +MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY.value] +# Save/remove report the resulting saved state, so they read it back too. +WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) -def _modify_library_scope(scope: Sequence[str] | None) -> Sequence[str]: - return _coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_MODIFY.value] +def _saved_scope(scope: Sequence[str] | None) -> Sequence[str]: + return _coalesce_scopes(scope) or READ_SCOPES def _library_action( ids: Sequence[str], *, - action: Callable[[Spotifyify, list[str]], Awaitable[Jsonable]], + action: Callable[[Spotifyify, list[str]], Awaitable[Any]], + check: Callable[[Spotifyify, list[str]], Awaitable[list[bool]]], + batch_size: int, scope: Sequence[str] | None, + fmt: str | None, + json_output: bool, + raw: bool, + fields: Sequence[str] | None, ) -> None: - _handle( - lambda spotify: action(spotify, _split_values(ids)), - scopes=_modify_library_scope(scope), + """Apply a library mutation and report the saved state it produced.""" + item_ids = _split_values(ids) + + async def command(spotify: Spotifyify) -> Jsonable: + await _sequential_batches( + lambda chunk: action(spotify, chunk), item_ids, batch_size + ) + saved = await check(spotify, item_ids) + return [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(item_ids, saved, strict=False) + ] + + result = _handle(command, scopes=_coalesce_scopes(scope) or WRITE_SCOPES) + _render( + result, + columns=SAVED_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() def _library_check( @@ -56,7 +95,10 @@ def _library_check( *, action: Callable[[Spotifyify, list[str]], Awaitable[list[bool]]], scope: Sequence[str] | None, + fmt: str | None, json_output: bool, + raw: bool, + fields: Sequence[str] | None, ) -> None: item_ids = _split_values(ids) result = _handle( @@ -66,7 +108,14 @@ def _library_check( {"id": item_id, "saved": saved} for item_id, saved in zip(item_ids, result, strict=False) ] - _print_json(payload) if json_output else _print_table(payload, ("id", "saved")) + _render( + payload, + columns=SAVED_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, + ) @app.command("saved-tracks") @@ -74,8 +123,12 @@ def saved_tracks( limit: LimitOption = 20, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -86,9 +139,13 @@ def saved_tracks( ) _render( result, + columns=("track.id", "track.name", "track.artists", "added_at"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("track.id", "track.name", "track.artists", "added_at"), + sort=sort, + where=where, ) @@ -97,8 +154,12 @@ def saved_albums( limit: LimitOption = 20, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -109,9 +170,13 @@ def saved_albums( ) _render( result, + columns=("album.id", "album.name", "album.artists", "added_at"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("album.id", "album.name", "album.artists", "added_at"), + sort=sort, + where=where, ) @@ -119,8 +184,12 @@ def saved_albums( def saved_shows( limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -129,9 +198,13 @@ def saved_shows( ) _render( result, + columns=("show.id", "show.name", "show.publisher", "added_at"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("show.id", "show.name", "show.publisher", "added_at"), + sort=sort, + where=where, ) @@ -139,8 +212,12 @@ def saved_shows( def saved_episodes( limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -149,9 +226,13 @@ def saved_episodes( ) _render( result, + columns=("episode.id", "episode.name", "added_at"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("episode.id", "episode.name", "added_at"), + sort=sort, + where=where, ) @@ -160,8 +241,12 @@ def library_top_tracks( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -172,9 +257,13 @@ def library_top_tracks( ) _render( result, + columns=("id", "name", "artists", "uri"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) @@ -183,8 +272,12 @@ def library_top_artists( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -195,135 +288,267 @@ def library_top_artists( ) _render( result, + columns=("id", "name", "uri"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "uri"), + sort=sort, + where=where, ) @app.command("save-tracks") -def save_tracks(track_ids: IdsArgument, scope: ScopeOption = None) -> None: +def save_tracks( + track_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( track_ids, action=lambda spotify, ids: spotify.library.save_tracks(ids), + check=lambda spotify, ids: spotify.library.check_tracks(ids), + batch_size=BATCH_TRACKS, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("remove-tracks") -def remove_tracks(track_ids: IdsArgument, scope: ScopeOption = None) -> None: +def remove_tracks( + track_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( track_ids, action=lambda spotify, ids: spotify.library.remove_tracks(ids), + check=lambda spotify, ids: spotify.library.check_tracks(ids), + batch_size=BATCH_TRACKS, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("check-tracks") def check_tracks( track_ids: IdsArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: _library_check( track_ids, action=lambda spotify, ids: spotify.library.check_tracks(ids), scope=scope, + fmt=fmt, json_output=json_output, + raw=raw, + fields=fields, ) @app.command("save-albums") -def save_albums(album_ids: IdsArgument, scope: ScopeOption = None) -> None: +def save_albums( + album_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( album_ids, action=lambda spotify, ids: spotify.library.save_albums(ids), + check=lambda spotify, ids: spotify.library.check_albums(ids), + batch_size=BATCH_ALBUMS, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("remove-albums") -def remove_albums(album_ids: IdsArgument, scope: ScopeOption = None) -> None: +def remove_albums( + album_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( album_ids, action=lambda spotify, ids: spotify.library.remove_albums(ids), + check=lambda spotify, ids: spotify.library.check_albums(ids), + batch_size=BATCH_ALBUMS, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("check-albums") def check_albums( album_ids: IdsArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: _library_check( album_ids, action=lambda spotify, ids: spotify.library.check_albums(ids), scope=scope, + fmt=fmt, json_output=json_output, + raw=raw, + fields=fields, ) @app.command("save-shows") -def save_shows(show_ids: IdsArgument, scope: ScopeOption = None) -> None: +def save_shows( + show_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( show_ids, action=lambda spotify, ids: spotify.library.save_shows(ids), + check=lambda spotify, ids: spotify.library.check_shows(ids), + batch_size=BATCH_SHOWS, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("remove-shows") -def remove_shows(show_ids: IdsArgument, scope: ScopeOption = None) -> None: +def remove_shows( + show_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( show_ids, action=lambda spotify, ids: spotify.library.remove_shows(ids), + check=lambda spotify, ids: spotify.library.check_shows(ids), + batch_size=BATCH_SHOWS, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("check-shows") def check_shows( show_ids: IdsArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: _library_check( show_ids, action=lambda spotify, ids: spotify.library.check_shows(ids), scope=scope, + fmt=fmt, json_output=json_output, + raw=raw, + fields=fields, ) @app.command("save-episodes") -def save_episodes(episode_ids: IdsArgument, scope: ScopeOption = None) -> None: +def save_episodes( + episode_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( episode_ids, action=lambda spotify, ids: spotify.library.save_episodes(ids), + check=lambda spotify, ids: spotify.library.check_episodes(ids), + batch_size=BATCH_EPISODES, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("remove-episodes") -def remove_episodes(episode_ids: IdsArgument, scope: ScopeOption = None) -> None: +def remove_episodes( + episode_ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: _library_action( episode_ids, action=lambda spotify, ids: spotify.library.remove_episodes(ids), + check=lambda spotify, ids: spotify.library.check_episodes(ids), + batch_size=BATCH_EPISODES, scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("check-episodes") def check_episodes( episode_ids: IdsArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: _library_check( episode_ids, action=lambda spotify, ids: spotify.library.check_episodes(ids), scope=scope, + fmt=fmt, json_output=json_output, + raw=raw, + fields=fields, ) diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index 9099f36..38fb6a7 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -1,23 +1,132 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any import typer -from spotifyify import SpotifyScope - -from ._core import _coalesce_scopes, _parse_json_object, _print_success, _split_values +from spotifyify import Spotifyify, SpotifyScope +from spotifyify.exceptions import SpotifyAPIError +from spotifyify.schemas import Device + +from ._aliases import AliasGroup +from ._core import ( + PLAYBACK_COLUMNS, + Predicate, + _coalesce_scopes, + _merge_scopes, + _parse_json_object, + _playback_summary, + _settled_playback, + _sort_items, + _split_values, + is_fresh_track, + is_paused, + is_playing, + plays_uri, +) from ._options import ( DeviceOption, FieldsOption, + FormatOption, JsonOption, LimitOption, + RawOption, ScopeOption, + SortOption, + UrisArgument, + WaitOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Control and inspect Spotify playback.") +app = typer.Typer( + help="Control and inspect Spotify playback.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +READ_SCOPES = [SpotifyScope.USER_READ_PLAYBACK_STATE.value] +MODIFY_SCOPES = [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value] +# Playback mutations report the state they produced, so they need the read +# scope too — otherwise the caller pays a second round trip to learn what +# actually happened. +CONTROL_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) + + +def _select_fallback_device(devices: list[Device]) -> Device | None: + """Choose a controllable device reproducibly, preferring computers.""" + candidates = [ + device for device in devices if device.id and not device.is_restricted + ] + if not candidates: + return None + return min( + candidates, + key=lambda device: ( + not bool(device.is_active), + (device.type or "").casefold() != "computer", + (device.name or "").casefold(), + device.id or "", + ), + ) + + +async def _play_with_device_fallback( + spotify: Spotifyify, + *, + device_id: str | None, + context_uri: str | None = None, + uris: list[str] | None = None, + offset: dict[str, Any] | None = None, + position_ms: int | None = None, +) -> None: + """Play normally, discovering a target only when Spotify has no active one.""" + playback = { + "context_uri": context_uri, + "uris": uris, + "offset": offset, + "position_ms": position_ms, + } + try: + await spotify.player.play(device_id=device_id, **playback) + except SpotifyAPIError as error: + if device_id is not None or "no active device" not in error.message.casefold(): + raise + + fallback = _select_fallback_device(await spotify.player.devices()) + if fallback is None: + raise + await spotify.player.play(device_id=fallback.id, **playback) + + +def _control( + action, + *, + scope, + until: Predicate | None = None, + wait: bool = True, + fmt: str | None, + json_output: bool, + raw: bool, + fields, +) -> None: + """Run a playback mutation and print the state it produced.""" + + async def command(spotify: Spotifyify) -> Any: + await action(spotify) + return await _settled_playback(spotify, until=until, wait=wait) + + result = _handle(command, scopes=_coalesce_scopes(scope) or CONTROL_SCOPES) + _render( + result, + columns=PLAYBACK_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, + ) @app.command("state") @@ -26,19 +135,23 @@ def player_state( str | None, typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 market code."), ] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: result = _handle( lambda spotify: spotify.player.state(market=market), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_READ_PLAYBACK_STATE.value], + scopes=_coalesce_scopes(scope) or READ_SCOPES, ) _render( - result, + _playback_summary(result), + columns=PLAYBACK_COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("item.id", "item.name", "is_playing", "progress_ms"), ) @@ -49,169 +162,289 @@ def player_play( uri: Annotated[list[str] | None, typer.Option("--uri")] = None, offset: Annotated[str | None, typer.Option("--offset-json")] = None, position_ms: Annotated[int | None, typer.Option("--position-ms", min=0)] = None, + wait: WaitOption = True, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.play( + uris = _split_values(uri) or None + if uris: + # Wait for the requested track, not merely for "something is playing". + until = plays_uri(uris[0]) + elif context_uri: + until = is_fresh_track + else: + until = is_playing + _control( + lambda spotify: _play_with_device_fallback( + spotify, device_id=device_id, context_uri=context_uri, - uris=_split_values(uri) or None, + uris=uris, offset=_parse_json_object(offset, "--offset-json"), position_ms=position_ms, ), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + until=until, + wait=wait, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("pause") -def player_pause(device_id: DeviceOption = None, scope: ScopeOption = None) -> None: - _handle( +def player_pause( + device_id: DeviceOption = None, + wait: WaitOption = True, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: + _control( lambda spotify: spotify.player.pause(device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + until=is_paused, + wait=wait, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("skip") -def player_skip(device_id: DeviceOption = None, scope: ScopeOption = None) -> None: - _handle( +def player_skip( + device_id: DeviceOption = None, + wait: WaitOption = True, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: + _control( lambda spotify: spotify.player.skip(device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + until=is_fresh_track, + wait=wait, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("previous") -def player_previous(device_id: DeviceOption = None, scope: ScopeOption = None) -> None: - _handle( +def player_previous( + device_id: DeviceOption = None, + wait: WaitOption = True, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, +) -> None: + _control( lambda spotify: spotify.player.previous(device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + until=is_fresh_track, + wait=wait, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("seek") def player_seek( position_ms: Annotated[int, typer.Argument(min=0)], device_id: DeviceOption = None, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( + _control( lambda spotify: spotify.player.seek(position_ms, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("repeat") def player_repeat( state: Annotated[str, typer.Argument(help="track, context, or off")], device_id: DeviceOption = None, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( + _control( lambda spotify: spotify.player.repeat(state, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("shuffle") def player_shuffle( state: Annotated[bool, typer.Argument()], device_id: DeviceOption = None, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( + _control( lambda spotify: spotify.player.shuffle(state, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("volume") def player_volume( volume_percent: Annotated[int, typer.Argument(min=0, max=100)], device_id: DeviceOption = None, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( + _control( lambda spotify: spotify.player.volume(volume_percent, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("queue") def player_queue( + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( lambda spotify: spotify.player.queue(), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_READ_PLAYBACK_STATE.value], + scopes=_coalesce_scopes(scope) or READ_SCOPES, ) _render( result, + columns=("id", "name", "artists", "uri"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "uri"), + sort=sort, + where=where, ) @app.command("add-to-queue") def add_to_queue( - uri: Annotated[str, typer.Argument(help="Track or episode URI.")], + uris: UrisArgument, device_id: DeviceOption = None, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( - lambda spotify: spotify.player.add_to_queue(uri, device_id=device_id), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + """Queue one or many tracks or episodes in a single call.""" + queued = _split_values(uris) + + async def enqueue(spotify: Spotifyify) -> None: + # Spotify has no bulk queue endpoint and the queue is ordered, so these + # must stay sequential. + for uri in queued: + await spotify.player.add_to_queue(uri, device_id=device_id) + + _control( + enqueue, + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("transfer") def transfer_playback( device_id: Annotated[str, typer.Argument(help="Spotify device ID.")], play: Annotated[bool, typer.Option("--play/--no-play")] = False, + wait: WaitOption = True, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( + _control( lambda spotify: spotify.player.transfer(device_id, play=play), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value], + scope=scope, + until=is_playing if play else None, + wait=wait, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("devices") def player_devices( + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( lambda spotify: spotify.player.devices(), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_READ_PLAYBACK_STATE.value], + scopes=_coalesce_scopes(scope) or READ_SCOPES, ) + # Spotify returns devices in no defined order; sort so repeated calls and + # any caching built on top of them stay reproducible. _render( - result, + _sort_items(result or [], ("id",)), + columns=("id", "name", "type", "is_active", "volume_percent"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "type", "is_active", "volume_percent"), + sort=sort, + where=where, ) @@ -220,8 +453,12 @@ def recently_played( limit: LimitOption = 20, after: Annotated[int | None, typer.Option("--after")] = None, before: Annotated[int | None, typer.Option("--before")] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -233,7 +470,11 @@ def recently_played( ) _render( result, + columns=("track.id", "track.name", "track.artists", "played_at"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("track.id", "track.name", "played_at"), + sort=sort, + where=where, ) diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index 1f3621c..1c87a95 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -1,26 +1,39 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any import typer -from spotifyify import SpotifyScope +from spotifyify import Spotifyify, SpotifyScope -from ._core import _coalesce_scopes, _print_json, _print_success, _split_values +from ._aliases import AliasGroup +from ._core import _coalesce_scopes, _split_values from ._options import ( DEFAULT_LIMIT, FieldsOption, + FormatOption, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, UrisArgument, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify playlists.") +app = typer.Typer( + help="Work with Spotify playlists.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "owner", "uri") +SNAPSHOT_COLUMNS = ("playlist_id", "snapshot_id", "total") _MODIFY_SCOPES = [ SpotifyScope.PLAYLIST_MODIFY_PUBLIC.value, @@ -28,13 +41,50 @@ ] +def _mutate_items( + action, + *, + playlist_id: str, + scope, + fmt: str | None, + json_output: bool, + raw: bool, + fields, +) -> None: + """Run a playlist mutation and report the resulting snapshot and length.""" + + async def command(spotify: Spotifyify) -> Any: + snapshot_id = await action(spotify) + playlist = await spotify.playlists.get(playlist_id) + return { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + + result = _handle(command, scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES) + _render( + result, + columns=SNAPSHOT_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, + ) + + @app.command("search") def search_playlists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -43,9 +93,13 @@ def search_playlists( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "owner", "uri"), + sort=sort, + where=where, ) @@ -53,7 +107,9 @@ def search_playlists( def get_playlist( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: @@ -63,9 +119,11 @@ def get_playlist( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "owner", "uri"), ) @@ -74,8 +132,12 @@ def list_playlists( user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -86,9 +148,13 @@ def list_playlists( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "owner", "uri"), + sort=sort, + where=where, ) @@ -96,14 +162,24 @@ def list_playlists( def playlist_tracks( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], market: MarketOption = None, - fields_query: Annotated[str | None, typer.Option("--spotify-fields")] = None, + fields_query: Annotated[ + str | None, + typer.Option( + "--spotify-fields", + help="Server-side field filter applied by Spotify before the response is sent.", + ), + ] = None, limit: LimitOption = 20, offset: OffsetOption = 0, additional_types: Annotated[ list[str] | None, typer.Option("--additional-type") ] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -119,9 +195,13 @@ def playlist_tracks( ) _render( result, + columns=("track.id", "track.name", "track.artists", "added_at"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("item.id", "item.name", "item.artists", "added_at"), + sort=sort, + where=where, ) @@ -134,7 +214,9 @@ def create_playlist( ] = False, description: Annotated[str, typer.Option("--description", "-d")] = "", user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: @@ -150,9 +232,11 @@ def create_playlist( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "uri"), ) @@ -165,19 +249,31 @@ def update_playlist( bool | None, typer.Option("--collaborative/--not-collaborative") ] = None, description: Annotated[str | None, typer.Option("--description", "-d")] = None, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( - lambda spotify: spotify.playlists.update( + async def command(spotify: Spotifyify) -> Any: + await spotify.playlists.update( playlist_id, name=name, public=public, collaborative=collaborative, description=description, - ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, + ) + return await spotify.playlists.get(playlist_id) + + result = _handle(command, scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES) + _render( + result, + columns=("id", "name", "public", "collaborative", "description"), + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("add") @@ -185,21 +281,23 @@ def add_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, position: Annotated[int | None, typer.Option("--position")] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( + """Add one or many URIs in a single call.""" + _mutate_items( lambda spotify: spotify.playlists.add( playlist_id, _split_values(uris), position=position ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + playlist_id=playlist_id, + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @@ -207,19 +305,20 @@ def add_playlist_items( def replace_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( + _mutate_items( lambda spotify: spotify.playlists.replace(playlist_id, _split_values(uris)), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + playlist_id=playlist_id, + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @@ -227,19 +326,20 @@ def replace_playlist_items( def remove_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( + _mutate_items( lambda spotify: spotify.playlists.remove(playlist_id, _split_values(uris)), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + playlist_id=playlist_id, + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @@ -250,11 +350,13 @@ def reorder_playlist_items( insert_before: Annotated[int, typer.Option("--insert-before", min=0)], range_length: Annotated[int, typer.Option("--range-length", min=1)] = 1, snapshot_id: Annotated[str | None, typer.Option("--snapshot-id")] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( + _mutate_items( lambda spotify: spotify.playlists.reorder( playlist_id, range_start=range_start, @@ -262,21 +364,23 @@ def reorder_playlist_items( range_length=range_length, snapshot_id=snapshot_id, ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - payload = {"snapshot_id": result} - ( - _print_json(payload, fields=_split_values(fields)) - if json_output - else _print_success(result) + playlist_id=playlist_id, + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) @app.command("cover-image") def playlist_cover_image( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -285,7 +389,10 @@ def playlist_cover_image( ) _render( result, + columns=("url", "width", "height"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("url", "width", "height"), + sort=sort, ) diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py new file mode 100644 index 0000000..eaeb68f --- /dev/null +++ b/spotifyify/cli/quick.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from typing import Annotated, Any + +import typer + +from spotifyify import Spotifyify + +from ._core import ( + PLAYBACK_COLUMNS, + _coalesce_scopes, + _settled_playback, + is_fresh_track, + plays_uri, +) +from ._options import ( + DeviceOption, + FieldsOption, + FormatOption, + JsonOption, + RawOption, + ScopeOption, + WaitOption, + _handle, + _render, +) +from .player import CONTROL_SCOPES, _play_with_device_fallback + +EXIT_NO_MATCH = 4 + + +def _quoted(value: str) -> str: + """Wrap a filter value for Spotify's search grammar.""" + return '"{}"'.format(value.replace('"', " ").strip()) + + +def _build_query( + words: list[str] | None, + *, + track: str | None, + artist: str | None, + album: str | None, +) -> str: + """Turn the flags into a Spotify field-filtered search query.""" + parts: list[str] = [] + if track: + parts.append(f"track:{_quoted(track)}") + if artist: + parts.append(f"artist:{_quoted(artist)}") + if album: + parts.append(f"album:{_quoted(album)}") + parts.extend(words or []) + return " ".join(part for part in parts if part).strip() + + +def _first(paging: Any) -> Any: + items = getattr(paging, "items", None) or [] + return items[0] if items else None + + +def register(app: typer.Typer) -> None: + """Attach the top-level resolve-and-play command.""" + + @app.command("play") + def play( + words: Annotated[ + list[str] | None, + typer.Argument(help="Free-text search terms, added to the query as-is."), + ] = None, + track: Annotated[ + str | None, typer.Option("--track", "-t", help="Track name to match.") + ] = None, + artist: Annotated[ + str | None, typer.Option("--artist", "-a", help="Artist name to match.") + ] = None, + album: Annotated[ + str | None, typer.Option("--album", help="Album name to match.") + ] = None, + market: Annotated[ + str | None, typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 code.") + ] = None, + device_id: DeviceOption = None, + wait: WaitOption = True, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, + scope: ScopeOption = None, + ) -> None: + """Find something and play it in one call. + + Resolves the top search hit and starts it, so no separate search-then-play + round trip is needed: + + spotifyify play --artist Ikkimel --track "WHO'S THAT" + + A track name (or free text) plays that track. Without one, --album plays + the album and --artist alone plays the artist. + """ + query = _build_query(words, track=track, artist=artist, album=album) + if not query: + raise typer.BadParameter( + "Give search terms or at least one of --track, --artist, --album." + ) + # A track name, or bare free text, means "play this one thing". + # Otherwise the broadest given filter becomes the playback context. + wants_track = bool(track or words) + wants_album = bool(album) and not wants_track + + async def command(spotify: Spotifyify) -> Any: + if wants_track: + hit = _first(await spotify.tracks.find(query, limit=1, market=market)) + uris, context_uri = ([hit.uri] if hit else None), None + elif wants_album: + hit = _first(await spotify.albums.find(query, limit=1, market=market)) + uris, context_uri = None, (hit.uri if hit else None) + else: + hit = _first(await spotify.artists.find(query, limit=1)) + uris, context_uri = None, (hit.uri if hit else None) + + if hit is None or not (uris or context_uri): + typer.echo(f"No match for {query}", err=True) + raise typer.Exit(EXIT_NO_MATCH) + + await _play_with_device_fallback( + spotify, device_id=device_id, uris=uris, context_uri=context_uri + ) + # Wait for the thing we resolved, so the reported state is not the + # track that happened to be playing already. + until = plays_uri(uris[0]) if uris else is_fresh_track + return await _settled_playback(spotify, until=until, wait=wait) + + result = _handle(command, scopes=_coalesce_scopes(scope) or CONTROL_SCOPES) + _render( + result, + columns=PLAYBACK_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, + ) diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index 74e629d..fb53f62 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -4,21 +4,34 @@ import typer -from ._core import _coalesce_scopes, _split_values +from ._aliases import AliasGroup +from ._core import BATCH_SHOWS, _coalesce_scopes, _gather_batches, _split_values from ._options import ( DEFAULT_LIMIT, FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify shows.") +app = typer.Typer( + help="Work with Spotify shows.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "publisher", "uri") +EPISODE_COLUMNS = ("id", "name", "release_date", "uri") @app.command("search") @@ -27,8 +40,12 @@ def search_shows( limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -39,49 +56,47 @@ def search_shows( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "publisher", "uri"), + sort=sort, + where=where, ) @app.command("get") -def get_show( - show_id: Annotated[str, typer.Argument(help="Spotify show ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.shows.get(show_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "publisher", "uri"), - ) - - -@app.command("get-many") -def get_many_shows( +def get_shows( show_ids: IdsArgument, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: + """Fetch one or many shows in a single call.""" + ids = _split_values(show_ids) result = _handle( - lambda spotify: spotify.shows.get_many(_split_values(show_ids), market=market), + lambda spotify: _gather_batches( + lambda chunk: spotify.shows.get_many(chunk, market=market), + ids, + BATCH_SHOWS, + ), scopes=_coalesce_scopes(scope), ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "publisher", "uri"), + sort=sort, + where=where, ) @@ -91,8 +106,12 @@ def show_episodes( market: MarketOption = None, limit: LimitOption = 20, offset: OffsetOption = 0, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -103,7 +122,11 @@ def show_episodes( ) _render( result, + columns=EPISODE_COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "release_date", "uri"), + sort=sort, + where=where, ) diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index 1e0e484..9d8a24a 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -4,21 +4,33 @@ import typer -from ._core import _coalesce_scopes, _split_values +from ._aliases import AliasGroup +from ._core import BATCH_TRACKS, _coalesce_scopes, _gather_batches, _split_values from ._options import ( DEFAULT_LIMIT, FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, MarketOption, OffsetOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify tracks.") +app = typer.Typer( + help="Work with Spotify tracks.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "name", "artists", "album.name", "uri") @app.command("search") @@ -27,8 +39,12 @@ def search_tracks( limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( @@ -39,49 +55,45 @@ def search_tracks( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "album.name", "uri"), + sort=sort, + where=where, ) @app.command("get") -def get_track( - track_id: Annotated[str, typer.Argument(help="Spotify track ID.")], - market: MarketOption = None, - json_output: JsonOption = False, - fields: FieldsOption = None, - scope: ScopeOption = None, -) -> None: - result = _handle( - lambda spotify: spotify.tracks.get(track_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( - result, - json_output=json_output, - fields=fields, - columns=("id", "name", "artists", "album.name", "uri"), - ) - - -@app.command("get-many") -def get_many_tracks( +def get_tracks( track_ids: IdsArgument, market: MarketOption = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: + """Fetch one or many tracks in a single call.""" + ids = _split_values(track_ids) result = _handle( - lambda spotify: spotify.tracks.get_many( - _split_values(track_ids), market=market + lambda spotify: _gather_batches( + lambda chunk: spotify.tracks.get_many(chunk, market=market), + ids, + BATCH_TRACKS, ), scopes=_coalesce_scopes(scope), ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "artists", "album.name", "uri"), + sort=sort, + where=where, ) diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index fc37b2b..c2e48ad 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -1,50 +1,111 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable, Sequence from typing import Annotated import typer -from spotifyify import SpotifyScope +from spotifyify import Spotifyify, SpotifyScope +from ._aliases import AliasGroup from ._core import ( + BATCH_FOLLOW, + Jsonable, _coalesce_scopes, - _print_json, - _print_success, - _print_table, + _merge_scopes, + _sequential_batches, _split_values, ) from ._options import ( FieldsOption, + FormatOption, IdsArgument, JsonOption, LimitOption, + RawOption, ScopeOption, + SortOption, + WhereOption, _handle, _render, ) -app = typer.Typer(help="Work with Spotify users and following.") +app = typer.Typer( + help="Work with Spotify users and following.", + cls=AliasGroup, + rich_markup_mode=None, + no_args_is_help=True, +) + +COLUMNS = ("id", "display_name", "uri") +FOLLOWING_COLUMNS = ("id", "following") + +READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ.value] +MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY.value] +# follow/unfollow report the resulting state, so they read it back too. +WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) + + +def _follow_action( + type: str, + ids: Sequence[str], + *, + action: Callable[[Spotifyify, list[str]], Awaitable[None]], + scope: Sequence[str] | None, + fmt: str | None, + json_output: bool, + raw: bool, + fields: Sequence[str] | None, +) -> None: + """Apply a follow mutation and report the resulting following state.""" + item_ids = _split_values(ids) + + async def command(spotify: Spotifyify) -> Jsonable: + await _sequential_batches( + lambda chunk: action(spotify, chunk), item_ids, BATCH_FOLLOW + ) + following = await spotify.users.check_following(type, item_ids) + return [ + {"id": item_id, "following": is_following} + for item_id, is_following in zip(item_ids, following, strict=False) + ] + + result = _handle(command, scopes=_coalesce_scopes(scope) or WRITE_SCOPES) + _render( + result, + columns=FOLLOWING_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, + ) @app.command("me") def users_me( + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: result = _handle(lambda spotify: spotify.users.me(), scopes=_coalesce_scopes(scope)) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "display_name", "uri"), ) @app.command("get") def users_get( user_id: Annotated[str, typer.Argument(help="Spotify user ID.")], + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: @@ -53,9 +114,11 @@ def users_get( ) _render( result, + columns=COLUMNS, + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "display_name", "uri"), ) @@ -64,19 +127,27 @@ def users_following( type: Annotated[str, typer.Option("--type")] = "artist", limit: LimitOption = 20, after: Annotated[str | None, typer.Option("--after")] = None, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, fields: FieldsOption = None, + sort: SortOption = None, + where: WhereOption = None, scope: ScopeOption = None, ) -> None: result = _handle( lambda spotify: spotify.users.following(type=type, limit=limit, after=after), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_READ.value], + scopes=_coalesce_scopes(scope) or READ_SCOPES, ) _render( result, + columns=("id", "name", "uri"), + fmt=fmt, json_output=json_output, + raw=raw, fields=fields, - columns=("id", "name", "uri"), + sort=sort, + where=where, ) @@ -84,46 +155,70 @@ def users_following( def users_follow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( - lambda spotify: spotify.users.follow(type, _split_values(ids)), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_MODIFY.value], + _follow_action( + type, + ids, + action=lambda spotify, chunk: spotify.users.follow(type, chunk), + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("unfollow") def users_unfollow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, + fmt: FormatOption = None, + json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _handle( - lambda spotify: spotify.users.unfollow(type, _split_values(ids)), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_MODIFY.value], + _follow_action( + type, + ids, + action=lambda spotify, chunk: spotify.users.unfollow(type, chunk), + scope=scope, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) - _print_success() @app.command("check-following") def users_check_following( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, + fmt: FormatOption = None, json_output: JsonOption = False, + raw: RawOption = False, + fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) result = _handle( lambda spotify: spotify.users.check_following(type, item_ids), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_LIBRARY_READ.value], + scopes=_coalesce_scopes(scope) or READ_SCOPES, ) payload = [ {"id": item_id, "following": following} for item_id, following in zip(item_ids, result, strict=False) ] - ( - _print_json(payload) - if json_output - else _print_table(payload, ("id", "following")) + _render( + payload, + columns=FOLLOWING_COLUMNS, + fmt=fmt, + json_output=json_output, + raw=raw, + fields=fields, ) diff --git a/spotifyify/http/response.py b/spotifyify/http/response.py index 9c1535f..66494c8 100644 --- a/spotifyify/http/response.py +++ b/spotifyify/http/response.py @@ -55,7 +55,18 @@ def parse_response(response: httpx.Response) -> JsonResponse: if not response.content: return None - return response.json() + try: + return response.json() + except ValueError: + # Some playback endpoints answer 200 with an opaque, non-JSON body and + # no content type. There is nothing structured to hand back, but it is + # a success and must not surface as a decoding error. + logger.debug( + "Ignoring non-JSON success body: status_code=%d content_type=%s", + response.status_code, + response.headers.get("Content-Type"), + ) + return None def validate_response_model( diff --git a/tests/http/test_response.py b/tests/http/test_response.py index 47014de..f9bb20e 100644 --- a/tests/http/test_response.py +++ b/tests/http/test_response.py @@ -35,6 +35,12 @@ def test_204_returns_none(self): def test_empty_content_returns_none(self): self.assertIsNone(parse_response(self._make_response(200))) + def test_non_json_success_body_is_not_an_error(self): + # Some playback endpoints answer 200 with an opaque, non-JSON body. + response = self._make_response(200, content=b"0YcilXzZl6EEW9kWEytjWC7bsX4") + + self.assertIsNone(parse_response(response)) + def test_successful_json_is_returned(self): self.assertEqual( parse_response(self._make_response(200, json_data={"tracks": []})), diff --git a/tests/test_cli.py b/tests/test_cli.py index 2da09cd..c07df07 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,12 @@ +import json import pathlib import tomllib import unittest +from types import SimpleNamespace from unittest.mock import patch -from spotifyify import SpotifyScope -from spotifyify import cli +from spotifyify import SpotifyScope, cli +from spotifyify.schemas import PlaybackState class TestCli(unittest.TestCase): @@ -101,3 +103,468 @@ def test_typer_app_registers_all_namespace_groups_when_available(self): "users", }, ) + + +class TestOutputContract(unittest.TestCase): + """The format a command emits depends only on arguments and environment.""" + + def test_json_is_the_default_format(self): + with patch.dict("os.environ", {}, clear=True): + self.assertEqual(cli._resolve_format(None), "json") + + def test_explicit_format_wins_over_everything(self): + with patch.dict("os.environ", {"SPOTIFYIFY_FORMAT": "json"}, clear=True): + self.assertEqual( + cli._resolve_format("table", json_output=True), + "table", + ) + + def test_environment_overrides_the_default(self): + with patch.dict("os.environ", {"SPOTIFYIFY_FORMAT": "table"}, clear=True): + self.assertEqual(cli._resolve_format(None), "table") + + def test_json_flag_is_shorthand_for_json_format(self): + with patch.dict("os.environ", {"SPOTIFYIFY_FORMAT": "table"}, clear=True): + self.assertEqual(cli._resolve_format(None, json_output=True), "json") + + def test_unknown_format_is_rejected(self): + with self.assertRaises(Exception) as raised: + cli._resolve_format("yaml") + + self.assertIn("--format", str(raised.exception)) + + def test_rows_keep_declared_column_order_and_flatten_nested_objects(self): + payload = { + "items": [ + { + "id": "t1", + "name": "Track", + "artists": [{"name": "A"}, {"name": "B"}], + "album": {"name": "Album"}, + "duration_ms": 1000, + } + ] + } + + rows = cli._rows( + payload, ("id", "name", "artists", "album.name", "duration_ms") + ) + + self.assertEqual( + list(rows[0]), + ["id", "name", "artists", "album.name", "duration_ms"], + ) + self.assertEqual(rows[0]["artists"], ["A", "B"]) + self.assertEqual(rows[0]["album.name"], "Album") + # Numbers stay numbers rather than being stringified. + self.assertEqual(rows[0]["duration_ms"], 1000) + + def test_cells_never_contain_control_characters(self): + rows = cli._rows([{"name": "a\tb\nc\x1b[31m"}], ("name",)) + + self.assertNotIn("\t", cli._cell(rows[0]["name"])) + self.assertNotIn("\n", cli._cell(rows[0]["name"])) + self.assertNotIn("\x1b", cli._cell(rows[0]["name"])) + + +class TestStableOrdering(unittest.TestCase): + def test_sort_is_stable_so_ties_keep_api_order(self): + items = [ + {"name": "same", "id": "first"}, + {"name": "same", "id": "second"}, + {"name": "same", "id": "third"}, + ] + + ordered = cli._sort_items(items, ["name"]) + + self.assertEqual([item["id"] for item in ordered], ["first", "second", "third"]) + + def test_sort_handles_missing_values_without_raising(self): + items = [{"n": 2}, {"n": None}, {"n": 1}, {}] + + ordered = cli._sort_items(items, ["n"]) + + # Numbers first in order, absent values last. + self.assertEqual([item.get("n") for item in ordered], [1, 2, None, None]) + + def test_descending_sort_uses_a_leading_dash(self): + items = [{"n": 1}, {"n": 3}, {"n": 2}] + + ordered = cli._sort_items(items, ["-n"]) + + self.assertEqual([item["n"] for item in ordered], [3, 2, 1]) + + def test_sort_applies_inside_a_paging_envelope(self): + payload = {"total": 2, "items": [{"n": 2}, {"n": 1}]} + + result = cli._apply_sort(payload, ["n"]) + + self.assertEqual(result["items"], [{"n": 1}, {"n": 2}]) + self.assertEqual(result["total"], 2) + + +class TestWhereFilter(unittest.TestCase): + def test_where_keeps_matching_rows_case_insensitively(self): + payload = {"items": [{"name": "Alpha"}, {"name": "Beta"}]} + + result = cli._apply_where(payload, ["name=alp"]) + + self.assertEqual(result["items"], [{"name": "Alpha"}]) + + def test_where_predicates_are_combined_with_and(self): + payload = {"items": [{"a": "x", "b": "y"}, {"a": "x", "b": "z"}]} + + result = cli._apply_where(payload, ["a=x", "b=z"]) + + self.assertEqual(result["items"], [{"a": "x", "b": "z"}]) + + def test_where_rejects_a_missing_separator(self): + with self.assertRaises(Exception) as raised: + cli._apply_where([{"a": 1}], ["nope"]) + + self.assertIn("PATH=VALUE", str(raised.exception)) + + +class TestPlaybackSummary(unittest.TestCase): + def test_summary_of_no_playback_is_stopped(self): + summary = cli._playback_summary(None) + + self.assertEqual(summary["state"], "stopped") + self.assertEqual(summary["artists"], []) + + def test_summary_reports_state_track_artists_and_device(self): + state = PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + summary = cli._playback_summary(state) + + self.assertEqual(summary["state"], "playing") + self.assertEqual(summary["track"], "HAMPELMANN") + self.assertEqual(summary["artists"], ["Ikkimel"]) + self.assertEqual(summary["device"], "Wohnzimmer") + + def test_paused_playback_is_reported_as_paused(self): + state = PlaybackState.model_validate({"is_playing": False}) + + self.assertEqual(cli._playback_summary(state)["state"], "paused") + + +class TestBatching(unittest.TestCase): + def test_ids_are_chunked_to_the_endpoint_limit(self): + from spotifyify.cli._core import _chunked + + self.assertEqual(_chunked(["a", "b", "c"], 2), [["a", "b"], ["c"]]) + self.assertEqual(_chunked([], 2), []) + + +class TestAliasTolerance(unittest.TestCase): + def setUp(self): + if cli.typer is None: + self.skipTest("typer is optional") + import click + + self.root = cli.typer.main.get_command(cli.app) + self.ctx = click.Context(self.root) + + def _resolves(self, name): + return self.root.get_command(self.ctx, name) + + def test_singular_group_names_resolve_to_the_plural_group(self): + self.assertIsNotNone(self._resolves("artist")) + self.assertIsNotNone(self._resolves("track")) + + def test_canonical_names_still_resolve(self): + self.assertIsNotNone(self._resolves("artists")) + + def test_unknown_names_still_fail(self): + self.assertIsNone(self._resolves("definitely-not-a-command")) + + def test_get_many_is_accepted_as_get(self): + import click + + artists = self._resolves("artists") + command = artists.get_command(click.Context(artists), "get-many") + + self.assertIsNotNone(command) + self.assertEqual(command.name, "get") + + +class TestAgentHelp(unittest.TestCase): + def setUp(self): + if cli.typer is None: + self.skipTest("typer is optional") + self.text = cli.agent_help(cli.typer.main.get_command(cli.app)) + + def test_every_group_appears(self): + for group in ("tracks", "artists", "albums", "playlists", "player", "library"): + self.assertIn(f"{group}: ", self.text) + + def test_the_output_contract_is_stated(self): + self.assertIn("JSON on stdout", self.text) + self.assertIn("--sort", self.text) + self.assertIn("Mutations print the state they produced", self.text) + + def test_variadic_arguments_are_marked(self): + self.assertIn("get TRACK_IDS...", self.text) + + def test_it_stays_small_enough_for_a_system_prompt(self): + self.assertLess(len(self.text), 4000) + + +class FakeSpotifyify: + """Stands in for the real client inside the CLI's async runner.""" + + namespaces: dict = {} + + def __init__(self, **kwargs): + self.scopes = kwargs.get("scopes") + for name, value in self.namespaces.items(): + setattr(self, name, value) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + +class TestEndToEnd(unittest.TestCase): + def setUp(self): + if cli.typer is None: + self.skipTest("typer is optional") + from typer.testing import CliRunner + + self.runner = CliRunner() + + def _run(self, args, namespaces): + FakeSpotifyify.namespaces = namespaces + with patch("spotifyify.cli._core.Spotifyify", FakeSpotifyify): + return self.runner.invoke(cli.app, args) + + def test_search_prints_declared_columns_as_json_by_default(self): + async def find(query, **kwargs): + return { + "items": [ + { + "id": "t1", + "name": "WHO'S THAT", + "artists": [{"name": "Ikkimel"}], + "album": {"name": "WHO'S THAT"}, + "uri": "spotify:track:t1", + "available_markets": ["DE"] * 100, + } + ] + } + + result = self._run( + ["tracks", "search", "Ikkimel"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + rows = json.loads(result.output) + self.assertEqual(list(rows[0]), ["id", "name", "artists", "album.name", "uri"]) + # The noisy payload fields never reach the caller. + self.assertNotIn("available_markets", result.output) + + def test_raw_returns_the_untouched_payload(self): + async def find(query, **kwargs): + return {"items": [{"id": "t1", "available_markets": ["DE"]}], "total": 1} + + result = self._run( + ["tracks", "search", "x", "--raw"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("available_markets", result.output) + self.assertEqual(json.loads(result.output)["total"], 1) + + def test_a_playback_mutation_prints_the_resulting_state(self): + played = {} + + async def play(**kwargs): + played.update(kwargs) + + async def state(**kwargs): + return PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + result = self._run( + ["player", "play", "--uri", "spotify:track:t1"], + {"player": SimpleNamespace(play=play, state=state)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + rows = json.loads(result.output) + self.assertEqual( + rows, + [ + { + "state": "playing", + "track": "HAMPELMANN", + "artists": ["Ikkimel"], + "device": "Wohnzimmer", + } + ], + ) + self.assertEqual(played["uris"], ["spotify:track:t1"]) + + def test_get_accepts_many_ids_in_one_call(self): + calls = [] + + async def get_many(ids, **kwargs): + calls.append(list(ids)) + return [{"id": item_id, "name": item_id} for item_id in ids] + + result = self._run( + ["tracks", "get", "a,b", "c"], + {"tracks": SimpleNamespace(get_many=get_many)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(calls, [["a", "b", "c"]]) + self.assertEqual(len(json.loads(result.output)), 3) + + def test_top_level_play_resolves_then_plays(self): + played = {} + + async def find(query, **kwargs): + find.query = query + return SimpleNamespace(items=[SimpleNamespace(uri="spotify:track:t1")]) + + async def play(**kwargs): + played.update(kwargs) + + async def state(**kwargs): + return PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "WHO'S THAT", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + result = self._run( + ["play", "--artist", "Ikkimel", "--track", "WHO'S THAT"], + { + "tracks": SimpleNamespace(find=find), + "player": SimpleNamespace(play=play, state=state), + }, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(find.query, 'track:"WHO\'S THAT" artist:"Ikkimel"') + self.assertEqual(played["uris"], ["spotify:track:t1"]) + self.assertEqual(json.loads(result.output)[0]["track"], "WHO'S THAT") + + def test_play_waits_for_the_requested_track_not_the_previous_one(self): + # Spotify applies playback asynchronously, so the first read can still + # describe whatever was playing before. + states = [ + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "Amber Dusk", + "uri": "spotify:track:old", + "artists": [{"name": "Caelestis Nati"}], + }, + }, + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "uri": "spotify:track:new", + "artists": [{"name": "Ikkimel"}], + }, + }, + ] + + async def play(**kwargs): + pass + + async def state(**kwargs): + return PlaybackState.model_validate(states.pop(0) if states else states) + + with patch("spotifyify.cli._core.SETTLE_DELAY_SECONDS", 0): + result = self._run( + ["player", "play", "--uri", "spotify:track:new"], + {"player": SimpleNamespace(play=play, state=state)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output)[0]["track"], "HAMPELMANN") + + def test_no_wait_reports_immediately(self): + reads = [] + + async def play(**kwargs): + pass + + async def state(**kwargs): + reads.append(1) + return PlaybackState.model_validate( + {"is_playing": True, "item": {"type": "track", "name": "Old"}} + ) + + result = self._run( + ["player", "play", "--uri", "spotify:track:new", "--no-wait"], + {"player": SimpleNamespace(play=play, state=state)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(len(reads), 1) + + def test_top_level_play_needs_something_to_search_for(self): + result = self._run(["play"], {}) + + self.assertNotEqual(result.exit_code, 0) + + def test_unknown_command_names_the_alternatives(self): + result = self.runner.invoke(cli.app, ["artists", "lookup"]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("Available:", result.output) + self.assertIn("top-tracks", result.output) + + def test_output_carries_no_ansi_escapes(self): + async def find(query, **kwargs): + return {"items": [{"id": "t1", "name": "Track"}]} + + result = self._run( + ["tracks", "search", "x", "--format", "table"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertNotIn("\x1b", result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_player.py b/tests/test_cli_player.py new file mode 100644 index 0000000..4bbdccc --- /dev/null +++ b/tests/test_cli_player.py @@ -0,0 +1,116 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, call + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli.player import _play_with_device_fallback, _select_fallback_device +from spotifyify.exceptions import SpotifyAPIError +from spotifyify.schemas import Device + + +class TestDeviceFallback(unittest.IsolatedAsyncioTestCase): + def test_selects_active_then_computer_then_name(self): + devices = [ + Device(id="tv", name="Living Room", type="TV"), + Device(id="z", name="Workstation", type="Computer"), + Device(id="a", name="Desktop", type="Computer"), + Device( + id="restricted", + name="Restricted", + type="Computer", + is_restricted=True, + ), + ] + + self.assertEqual(_select_fallback_device(devices).id, "a") + + devices[0].is_active = True + self.assertEqual(_select_fallback_device(devices).id, "tv") + + def test_returns_none_without_a_controllable_device(self): + devices = [ + Device(id=None, name="Missing ID"), + Device(id="restricted", is_restricted=True), + ] + + self.assertIsNone(_select_fallback_device(devices)) + + async def test_discovers_and_retries_on_no_active_device(self): + player = SimpleNamespace( + play=AsyncMock( + side_effect=[ + SpotifyAPIError( + 404, "Player command failed: No active device found" + ), + None, + ] + ), + devices=AsyncMock( + return_value=[ + Device(id="tv", name="Living Room", type="TV"), + Device(id="computer", name="Desktop", type="Computer"), + ] + ), + ) + spotify = SimpleNamespace(player=player) + + await _play_with_device_fallback( + spotify, + device_id=None, + uris=["spotify:track:123"], + ) + + player.devices.assert_awaited_once_with() + self.assertEqual( + player.play.await_args_list, + [ + call( + device_id=None, + context_uri=None, + uris=["spotify:track:123"], + offset=None, + position_ms=None, + ), + call( + device_id="computer", + context_uri=None, + uris=["spotify:track:123"], + offset=None, + position_ms=None, + ), + ], + ) + + async def test_does_not_override_an_explicit_device(self): + error = SpotifyAPIError(404, "No active device found") + player = SimpleNamespace( + play=AsyncMock(side_effect=error), + devices=AsyncMock(), + ) + spotify = SimpleNamespace(player=player) + + with self.assertRaises(SpotifyAPIError) as raised: + await _play_with_device_fallback(spotify, device_id="chosen") + + self.assertIs(raised.exception, error) + player.devices.assert_not_awaited() + + async def test_reraises_when_discovery_has_no_controllable_device(self): + error = SpotifyAPIError(404, "No active device found") + player = SimpleNamespace( + play=AsyncMock(side_effect=error), + devices=AsyncMock( + return_value=[Device(id="restricted", is_restricted=True)] + ), + ) + spotify = SimpleNamespace(player=player) + + with self.assertRaises(SpotifyAPIError) as raised: + await _play_with_device_fallback(spotify, device_id=None) + + self.assertIs(raised.exception, error) + self.assertEqual(player.play.await_count, 1) From cf0bf87811b0a253416ed3df323d73a94b711b53 Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 11:00:45 +0200 Subject: [PATCH 02/12] Fix forward reference error on python 3.13 --- .github/workflows/ci.yml | 10 +++++++--- spotifyify/namespaces/playlists.py | 2 ++ uv.lock | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e5daa..3e4c468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,11 @@ name: CI on: push: + branches: + - main pull_request: + branches: + - main jobs: test: @@ -16,10 +20,10 @@ jobs: enable-cache: true - name: Set up Python - run: uv python install 3.14 + run: uv python install 3.13 - name: Install dependencies - run: uv sync --group dev + run: uv sync --frozen --python 3.13 --group dev - name: Run tests - run: uv run pytest + run: uv run --frozen --python 3.13 pytest diff --git a/spotifyify/namespaces/playlists.py b/spotifyify/namespaces/playlists.py index adf72ac..745fcf8 100644 --- a/spotifyify/namespaces/playlists.py +++ b/spotifyify/namespaces/playlists.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any from collections.abc import Iterable diff --git a/uv.lock b/uv.lock index 11fa526..7afd90b 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "spotifyify" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 50978138b1997ca40b3c015d93449bbf8eb7320d Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 11:30:24 +0200 Subject: [PATCH 03/12] Deslop cli --- spotifyify/cli/_core.py | 15 +- spotifyify/cli/_options.py | 42 ++-- spotifyify/cli/albums.py | 64 +++--- spotifyify/cli/artists.py | 69 +++---- spotifyify/cli/episodes.py | 38 ++-- spotifyify/cli/library.py | 385 ++++++++++++++++++------------------ spotifyify/cli/player.py | 236 +++++++++++----------- spotifyify/cli/playlists.py | 208 ++++++++++--------- spotifyify/cli/quick.py | 21 +- spotifyify/cli/shows.py | 51 ++--- spotifyify/cli/tracks.py | 38 ++-- spotifyify/cli/users.py | 132 ++++++------- tests/test_cli.py | 25 +++ 13 files changed, 689 insertions(+), 635 deletions(-) diff --git a/spotifyify/cli/_core.py b/spotifyify/cli/_core.py index 98fbb6d..431c8b9 100644 --- a/spotifyify/cli/_core.py +++ b/spotifyify/cli/_core.py @@ -4,7 +4,8 @@ import json import os import re -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from contextlib import asynccontextmanager from typing import Any from pydantic import BaseModel @@ -18,8 +19,6 @@ Jsonable = BaseModel | list[Any] | dict[str, Any] | str | int | float | bool | None -AsyncCommand = Callable[[Spotifyify], Awaitable[Jsonable]] - DEFAULT_LIMIT = 10 INSTALL_MESSAGE = "Install the CLI dependencies with: uv add spotifyify[cli]" @@ -316,9 +315,11 @@ def _print_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> N _echo(_table(headers, cells)) -async def _run(command: AsyncCommand, *, scopes: Sequence[str]) -> Jsonable: +@asynccontextmanager +async def spotify_client(scopes: Sequence[str]) -> AsyncIterator[Spotifyify]: + """Open the async Spotify client used by one explicit CLI command.""" async with Spotifyify(scopes=_parse_scopes(scopes)) as spotify: - return await command(spotify) + yield spotify # --------------------------------------------------------------------------- # @@ -447,7 +448,7 @@ async def _settled_playback( *, until: Predicate | None = None, wait: bool = True, -) -> dict[str, Any]: +) -> Any: """Read playback back after a mutation, briefly waiting for it to take effect. Spotify applies playback commands asynchronously, so an immediate read can @@ -462,4 +463,4 @@ async def _settled_playback( break if attempt < attempts - 1: await asyncio.sleep(SETTLE_DELAY_SECONDS) - return _playback_summary(state) + return state diff --git a/spotifyify/cli/_options.py b/spotifyify/cli/_options.py index f781589..6c4044f 100644 --- a/spotifyify/cli/_options.py +++ b/spotifyify/cli/_options.py @@ -2,7 +2,9 @@ import asyncio from collections.abc import Sequence -from typing import Annotated +from functools import wraps +from typing import Annotated, Any, Callable, ParamSpec +from collections.abc import Awaitable import typer @@ -11,15 +13,14 @@ from ._core import ( FORMAT_JSON, FORMATS, - AsyncCommand, Jsonable, + _as_jsonable, _apply_sort, _apply_where, _print_json, _print_table, _resolve_format, _rows, - _run, _split_values, ) @@ -110,18 +111,27 @@ ] -def _handle(command: AsyncCommand, *, scopes: Sequence[str]) -> Jsonable: - try: - return asyncio.run(_run(command, scopes=scopes)) - except SpotifyAuthError as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(EXIT_AUTH_ERROR) from exc - except SpotifyAPIError as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(EXIT_API_ERROR) from exc +P = ParamSpec("P") -def _render( +def async_command(command: Callable[P, Awaitable[None]]) -> Callable[P, None]: + """Bridge one async command into Typer and translate expected API failures.""" + + @wraps(command) + def run(*args: P.args, **kwargs: P.kwargs) -> None: + try: + asyncio.run(command(*args, **kwargs)) + except SpotifyAuthError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(EXIT_AUTH_ERROR) from exc + except SpotifyAPIError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(EXIT_API_ERROR) from exc + + return run + + +def print_result( result: Jsonable, *, columns: Sequence[str], @@ -131,6 +141,7 @@ def _render( fields: Sequence[str] | None = None, sort: Sequence[str] | None = None, where: Sequence[str] | None = None, + project: Callable[[Any], Any] | None = None, ) -> None: """Emit the command's declared columns, in order, in the chosen format. @@ -138,10 +149,11 @@ def _render( changes the encoding and nothing else. --raw opts out into the untouched Spotify payload. """ - payload = _apply_sort(_apply_where(result, where), _split_values(sort)) if raw: - _print_json(payload) + _print_json(_as_jsonable(result)) return + payload = project(result) if project is not None else result + payload = _apply_sort(_apply_where(payload, where), _split_values(sort)) selected_columns = _split_values(fields) or list(columns) rows = _rows(payload, selected_columns) if _resolve_format(fmt, json_output=json_output) == FORMAT_JSON: diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index 6c6495d..6b475de 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -5,7 +5,13 @@ import typer from ._aliases import AliasGroup -from ._core import BATCH_ALBUMS, _coalesce_scopes, _gather_batches, _split_values +from ._core import ( + BATCH_ALBUMS, + _coalesce_scopes, + _gather_batches, + _split_values, + spotify_client, +) from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -19,8 +25,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -34,7 +40,8 @@ @app.command("search") -def search_albums( +@async_command +async def search_albums( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, @@ -47,13 +54,11 @@ def search_albums( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.find( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.albums.find( query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -66,7 +71,8 @@ def search_albums( @app.command("get") -def get_albums( +@async_command +async def get_albums( album_ids: IdsArgument, market: MarketOption = None, fmt: FormatOption = None, @@ -79,15 +85,13 @@ def get_albums( ) -> None: """Fetch one or many albums in a single call.""" ids = _split_values(album_ids) - result = _handle( - lambda spotify: _gather_batches( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await _gather_batches( lambda chunk: spotify.albums.get_many(chunk, market=market), ids, BATCH_ALBUMS, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -100,7 +104,8 @@ def get_albums( @app.command("tracks") -def album_tracks( +@async_command +async def album_tracks( album_id: Annotated[str, typer.Argument(help="Spotify album ID.")], limit: LimitOption = 50, offset: OffsetOption = 0, @@ -113,13 +118,11 @@ def album_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.tracks( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.albums.tracks( album_id, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -132,7 +135,8 @@ def album_tracks( @app.command("new-releases") -def new_releases( +@async_command +async def new_releases( country: Annotated[str | None, typer.Option("--country", "-c")] = None, limit: LimitOption = 20, offset: OffsetOption = 0, @@ -144,13 +148,11 @@ def new_releases( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.albums.new_releases( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.albums.new_releases( country=country, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index 4eadefa..c44e67d 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -5,7 +5,13 @@ import typer from ._aliases import AliasGroup -from ._core import BATCH_ARTISTS, _coalesce_scopes, _gather_batches, _split_values +from ._core import ( + BATCH_ARTISTS, + _coalesce_scopes, + _gather_batches, + _split_values, + spotify_client, +) from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -19,8 +25,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -36,7 +42,8 @@ @app.command("search") -def search_artists( +@async_command +async def search_artists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, @@ -48,11 +55,9 @@ def search_artists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.find(query, limit=limit, offset=offset), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.artists.find(query, limit=limit, offset=offset) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -65,7 +70,8 @@ def search_artists( @app.command("get") -def get_artists( +@async_command +async def get_artists( artist_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -77,11 +83,9 @@ def get_artists( ) -> None: """Fetch one or many artists in a single call.""" ids = _split_values(artist_ids) - result = _handle( - lambda spotify: _gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await _gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -94,7 +98,8 @@ def get_artists( @app.command("top-tracks") -def artist_top_tracks( +@async_command +async def artist_top_tracks( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], market: Annotated[str, typer.Option("--market", "-m")] = "US", fmt: FormatOption = None, @@ -105,11 +110,9 @@ def artist_top_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.top_tracks(artist_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.artists.top_tracks(artist_id, market=market) + print_result( result, columns=TRACK_COLUMNS, fmt=fmt, @@ -122,7 +125,8 @@ def artist_top_tracks( @app.command("albums") -def artist_albums( +@async_command +async def artist_albums( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], include_groups: Annotated[ str | None, @@ -139,17 +143,15 @@ def artist_albums( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.albums( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.artists.albums( artist_id, include_groups=include_groups, market=market, limit=limit, offset=offset, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=ALBUM_COLUMNS, fmt=fmt, @@ -162,7 +164,8 @@ def artist_albums( @app.command("related") -def related_artists( +@async_command +async def related_artists( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], fmt: FormatOption = None, json_output: JsonOption = False, @@ -172,11 +175,9 @@ def related_artists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.artists.related(artist_id), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.artists.related(artist_id) + print_result( result, columns=COLUMNS, fmt=fmt, diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index 6109c93..ed84017 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -5,7 +5,13 @@ import typer from ._aliases import AliasGroup -from ._core import BATCH_EPISODES, _coalesce_scopes, _gather_batches, _split_values +from ._core import ( + BATCH_EPISODES, + _coalesce_scopes, + _gather_batches, + _split_values, + spotify_client, +) from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -19,8 +25,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -35,7 +41,8 @@ @app.command("search") -def search_episodes( +@async_command +async def search_episodes( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, @@ -48,13 +55,11 @@ def search_episodes( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.episodes.find( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.episodes.find( query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=SEARCH_COLUMNS, fmt=fmt, @@ -67,7 +72,8 @@ def search_episodes( @app.command("get") -def get_episodes( +@async_command +async def get_episodes( episode_ids: IdsArgument, market: MarketOption = None, fmt: FormatOption = None, @@ -80,15 +86,13 @@ def get_episodes( ) -> None: """Fetch one or many episodes in a single call.""" ids = _split_values(episode_ids) - result = _handle( - lambda spotify: _gather_batches( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await _gather_batches( lambda chunk: spotify.episodes.get_many(chunk, market=market), ids, BATCH_EPISODES, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 81ef131..751960b 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -1,11 +1,10 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence -from typing import Annotated, Any +from typing import Annotated import typer -from spotifyify import Spotifyify, SpotifyScope +from spotifyify import SpotifyScope from ._aliases import AliasGroup from ._core import ( @@ -13,11 +12,11 @@ BATCH_EPISODES, BATCH_SHOWS, BATCH_TRACKS, - Jsonable, _coalesce_scopes, _merge_scopes, _sequential_batches, _split_values, + spotify_client, ) from ._options import ( FieldsOption, @@ -31,8 +30,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -50,76 +49,9 @@ WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) -def _saved_scope(scope: Sequence[str] | None) -> Sequence[str]: - return _coalesce_scopes(scope) or READ_SCOPES - - -def _library_action( - ids: Sequence[str], - *, - action: Callable[[Spotifyify, list[str]], Awaitable[Any]], - check: Callable[[Spotifyify, list[str]], Awaitable[list[bool]]], - batch_size: int, - scope: Sequence[str] | None, - fmt: str | None, - json_output: bool, - raw: bool, - fields: Sequence[str] | None, -) -> None: - """Apply a library mutation and report the saved state it produced.""" - item_ids = _split_values(ids) - - async def command(spotify: Spotifyify) -> Jsonable: - await _sequential_batches( - lambda chunk: action(spotify, chunk), item_ids, batch_size - ) - saved = await check(spotify, item_ids) - return [ - {"id": item_id, "saved": is_saved} - for item_id, is_saved in zip(item_ids, saved, strict=False) - ] - - result = _handle(command, scopes=_coalesce_scopes(scope) or WRITE_SCOPES) - _render( - result, - columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, - fields=fields, - ) - - -def _library_check( - ids: Sequence[str], - *, - action: Callable[[Spotifyify, list[str]], Awaitable[list[bool]]], - scope: Sequence[str] | None, - fmt: str | None, - json_output: bool, - raw: bool, - fields: Sequence[str] | None, -) -> None: - item_ids = _split_values(ids) - result = _handle( - lambda spotify: action(spotify, item_ids), scopes=_saved_scope(scope) - ) - payload = [ - {"id": item_id, "saved": saved} - for item_id, saved in zip(item_ids, result, strict=False) - ] - _render( - payload, - columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, - fields=fields, - ) - - @app.command("saved-tracks") -def saved_tracks( +@async_command +async def saved_tracks( limit: LimitOption = 20, offset: OffsetOption = 0, market: MarketOption = None, @@ -131,13 +63,11 @@ def saved_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_tracks( + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.library.saved_tracks( limit=limit, offset=offset, market=market - ), - scopes=_saved_scope(scope), - ) - _render( + ) + print_result( result, columns=("track.id", "track.name", "track.artists", "added_at"), fmt=fmt, @@ -150,7 +80,8 @@ def saved_tracks( @app.command("saved-albums") -def saved_albums( +@async_command +async def saved_albums( limit: LimitOption = 20, offset: OffsetOption = 0, market: MarketOption = None, @@ -162,13 +93,11 @@ def saved_albums( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_albums( + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.library.saved_albums( limit=limit, offset=offset, market=market - ), - scopes=_saved_scope(scope), - ) - _render( + ) + print_result( result, columns=("album.id", "album.name", "album.artists", "added_at"), fmt=fmt, @@ -181,7 +110,8 @@ def saved_albums( @app.command("saved-shows") -def saved_shows( +@async_command +async def saved_shows( limit: LimitOption = 20, offset: OffsetOption = 0, fmt: FormatOption = None, @@ -192,11 +122,9 @@ def saved_shows( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_shows(limit=limit, offset=offset), - scopes=_saved_scope(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.library.saved_shows(limit=limit, offset=offset) + print_result( result, columns=("show.id", "show.name", "show.publisher", "added_at"), fmt=fmt, @@ -209,7 +137,8 @@ def saved_shows( @app.command("saved-episodes") -def saved_episodes( +@async_command +async def saved_episodes( limit: LimitOption = 20, offset: OffsetOption = 0, fmt: FormatOption = None, @@ -220,11 +149,9 @@ def saved_episodes( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.saved_episodes(limit=limit, offset=offset), - scopes=_saved_scope(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.library.saved_episodes(limit=limit, offset=offset) + print_result( result, columns=("episode.id", "episode.name", "added_at"), fmt=fmt, @@ -237,7 +164,8 @@ def saved_episodes( @app.command("top-tracks") -def library_top_tracks( +@async_command +async def library_top_tracks( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, offset: OffsetOption = 0, @@ -249,13 +177,13 @@ def library_top_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.top_tracks( + async with spotify_client( + _coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value] + ) as spotify: + result = await spotify.library.top_tracks( time_range=time_range, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value], - ) - _render( + ) + print_result( result, columns=("id", "name", "artists", "uri"), fmt=fmt, @@ -268,7 +196,8 @@ def library_top_tracks( @app.command("top-artists") -def library_top_artists( +@async_command +async def library_top_artists( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, offset: OffsetOption = 0, @@ -280,13 +209,13 @@ def library_top_artists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.library.top_artists( + async with spotify_client( + _coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value] + ) as spotify: + result = await spotify.library.top_artists( time_range=time_range, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value], - ) - _render( + ) + print_result( result, columns=("id", "name", "uri"), fmt=fmt, @@ -299,7 +228,8 @@ def library_top_artists( @app.command("save-tracks") -def save_tracks( +@async_command +async def save_tracks( track_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -307,12 +237,17 @@ def save_tracks( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - track_ids, - action=lambda spotify, ids: spotify.library.save_tracks(ids), - check=lambda spotify, ids: spotify.library.check_tracks(ids), - batch_size=BATCH_TRACKS, - scope=scope, + ids = _split_values(track_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.save_tracks, ids, BATCH_TRACKS) + saved = await spotify.library.check_tracks(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -321,7 +256,8 @@ def save_tracks( @app.command("remove-tracks") -def remove_tracks( +@async_command +async def remove_tracks( track_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -329,12 +265,17 @@ def remove_tracks( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - track_ids, - action=lambda spotify, ids: spotify.library.remove_tracks(ids), - check=lambda spotify, ids: spotify.library.check_tracks(ids), - batch_size=BATCH_TRACKS, - scope=scope, + ids = _split_values(track_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.remove_tracks, ids, BATCH_TRACKS) + saved = await spotify.library.check_tracks(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -343,7 +284,8 @@ def remove_tracks( @app.command("check-tracks") -def check_tracks( +@async_command +async def check_tracks( track_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -351,10 +293,16 @@ def check_tracks( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_check( - track_ids, - action=lambda spotify, ids: spotify.library.check_tracks(ids), - scope=scope, + ids = _split_values(track_ids) + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + saved = await spotify.library.check_tracks(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -363,7 +311,8 @@ def check_tracks( @app.command("save-albums") -def save_albums( +@async_command +async def save_albums( album_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -371,12 +320,17 @@ def save_albums( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - album_ids, - action=lambda spotify, ids: spotify.library.save_albums(ids), - check=lambda spotify, ids: spotify.library.check_albums(ids), - batch_size=BATCH_ALBUMS, - scope=scope, + ids = _split_values(album_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.save_albums, ids, BATCH_ALBUMS) + saved = await spotify.library.check_albums(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -385,7 +339,8 @@ def save_albums( @app.command("remove-albums") -def remove_albums( +@async_command +async def remove_albums( album_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -393,12 +348,17 @@ def remove_albums( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - album_ids, - action=lambda spotify, ids: spotify.library.remove_albums(ids), - check=lambda spotify, ids: spotify.library.check_albums(ids), - batch_size=BATCH_ALBUMS, - scope=scope, + ids = _split_values(album_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.remove_albums, ids, BATCH_ALBUMS) + saved = await spotify.library.check_albums(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -407,7 +367,8 @@ def remove_albums( @app.command("check-albums") -def check_albums( +@async_command +async def check_albums( album_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -415,10 +376,16 @@ def check_albums( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_check( - album_ids, - action=lambda spotify, ids: spotify.library.check_albums(ids), - scope=scope, + ids = _split_values(album_ids) + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + saved = await spotify.library.check_albums(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -427,7 +394,8 @@ def check_albums( @app.command("save-shows") -def save_shows( +@async_command +async def save_shows( show_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -435,12 +403,17 @@ def save_shows( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - show_ids, - action=lambda spotify, ids: spotify.library.save_shows(ids), - check=lambda spotify, ids: spotify.library.check_shows(ids), - batch_size=BATCH_SHOWS, - scope=scope, + ids = _split_values(show_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.save_shows, ids, BATCH_SHOWS) + saved = await spotify.library.check_shows(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -449,7 +422,8 @@ def save_shows( @app.command("remove-shows") -def remove_shows( +@async_command +async def remove_shows( show_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -457,12 +431,17 @@ def remove_shows( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - show_ids, - action=lambda spotify, ids: spotify.library.remove_shows(ids), - check=lambda spotify, ids: spotify.library.check_shows(ids), - batch_size=BATCH_SHOWS, - scope=scope, + ids = _split_values(show_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.remove_shows, ids, BATCH_SHOWS) + saved = await spotify.library.check_shows(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -471,7 +450,8 @@ def remove_shows( @app.command("check-shows") -def check_shows( +@async_command +async def check_shows( show_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -479,10 +459,16 @@ def check_shows( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_check( - show_ids, - action=lambda spotify, ids: spotify.library.check_shows(ids), - scope=scope, + ids = _split_values(show_ids) + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + saved = await spotify.library.check_shows(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -491,7 +477,8 @@ def check_shows( @app.command("save-episodes") -def save_episodes( +@async_command +async def save_episodes( episode_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -499,12 +486,17 @@ def save_episodes( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - episode_ids, - action=lambda spotify, ids: spotify.library.save_episodes(ids), - check=lambda spotify, ids: spotify.library.check_episodes(ids), - batch_size=BATCH_EPISODES, - scope=scope, + ids = _split_values(episode_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.save_episodes, ids, BATCH_EPISODES) + saved = await spotify.library.check_episodes(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -513,7 +505,8 @@ def save_episodes( @app.command("remove-episodes") -def remove_episodes( +@async_command +async def remove_episodes( episode_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -521,12 +514,17 @@ def remove_episodes( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_action( - episode_ids, - action=lambda spotify, ids: spotify.library.remove_episodes(ids), - check=lambda spotify, ids: spotify.library.check_episodes(ids), - batch_size=BATCH_EPISODES, - scope=scope, + ids = _split_values(episode_ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches(spotify.library.remove_episodes, ids, BATCH_EPISODES) + saved = await spotify.library.check_episodes(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -535,7 +533,8 @@ def remove_episodes( @app.command("check-episodes") -def check_episodes( +@async_command +async def check_episodes( episode_ids: IdsArgument, fmt: FormatOption = None, json_output: JsonOption = False, @@ -543,10 +542,16 @@ def check_episodes( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _library_check( - episode_ids, - action=lambda spotify, ids: spotify.library.check_episodes(ids), - scope=scope, + ids = _split_values(episode_ids) + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + saved = await spotify.library.check_episodes(ids) + result = [ + {"id": item_id, "saved": is_saved} + for item_id, is_saved in zip(ids, saved, strict=False) + ] + print_result( + result, + columns=SAVED_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index 38fb6a7..a3f6fdd 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -11,7 +11,6 @@ from ._aliases import AliasGroup from ._core import ( PLAYBACK_COLUMNS, - Predicate, _coalesce_scopes, _merge_scopes, _parse_json_object, @@ -23,6 +22,7 @@ is_paused, is_playing, plays_uri, + spotify_client, ) from ._options import ( DeviceOption, @@ -36,8 +36,8 @@ UrisArgument, WaitOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -101,36 +101,9 @@ async def _play_with_device_fallback( await spotify.player.play(device_id=fallback.id, **playback) -def _control( - action, - *, - scope, - until: Predicate | None = None, - wait: bool = True, - fmt: str | None, - json_output: bool, - raw: bool, - fields, -) -> None: - """Run a playback mutation and print the state it produced.""" - - async def command(spotify: Spotifyify) -> Any: - await action(spotify) - return await _settled_playback(spotify, until=until, wait=wait) - - result = _handle(command, scopes=_coalesce_scopes(scope) or CONTROL_SCOPES) - _render( - result, - columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, - fields=fields, - ) - - @app.command("state") -def player_state( +@async_command +async def player_state( market: Annotated[ str | None, typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 market code."), @@ -141,22 +114,22 @@ def player_state( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.state(market=market), - scopes=_coalesce_scopes(scope) or READ_SCOPES, - ) - _render( - _playback_summary(result), + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + state = await spotify.player.state(market=market) + print_result( + state, columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("play") -def player_play( +@async_command +async def player_play( device_id: DeviceOption = None, context_uri: Annotated[str | None, typer.Option("--context-uri")] = None, uri: Annotated[list[str] | None, typer.Option("--uri")] = None, @@ -177,27 +150,30 @@ def player_play( until = is_fresh_track else: until = is_playing - _control( - lambda spotify: _play_with_device_fallback( + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await _play_with_device_fallback( spotify, device_id=device_id, context_uri=context_uri, uris=uris, offset=_parse_json_object(offset, "--offset-json"), position_ms=position_ms, - ), - scope=scope, - until=until, - wait=wait, + ) + state = await _settled_playback(spotify, until=until, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("pause") -def player_pause( +@async_command +async def player_pause( device_id: DeviceOption = None, wait: WaitOption = True, fmt: FormatOption = None, @@ -206,20 +182,23 @@ def player_pause( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.pause(device_id=device_id), - scope=scope, - until=is_paused, - wait=wait, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.pause(device_id=device_id) + state = await _settled_playback(spotify, until=is_paused, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("skip") -def player_skip( +@async_command +async def player_skip( device_id: DeviceOption = None, wait: WaitOption = True, fmt: FormatOption = None, @@ -228,20 +207,23 @@ def player_skip( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.skip(device_id=device_id), - scope=scope, - until=is_fresh_track, - wait=wait, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.skip(device_id=device_id) + state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("previous") -def player_previous( +@async_command +async def player_previous( device_id: DeviceOption = None, wait: WaitOption = True, fmt: FormatOption = None, @@ -250,20 +232,23 @@ def player_previous( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.previous(device_id=device_id), - scope=scope, - until=is_fresh_track, - wait=wait, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.previous(device_id=device_id) + state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("seek") -def player_seek( +@async_command +async def player_seek( position_ms: Annotated[int, typer.Argument(min=0)], device_id: DeviceOption = None, fmt: FormatOption = None, @@ -272,18 +257,23 @@ def player_seek( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.seek(position_ms, device_id=device_id), - scope=scope, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.seek(position_ms, device_id=device_id) + state = await _settled_playback(spotify) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("repeat") -def player_repeat( +@async_command +async def player_repeat( state: Annotated[str, typer.Argument(help="track, context, or off")], device_id: DeviceOption = None, fmt: FormatOption = None, @@ -292,18 +282,23 @@ def player_repeat( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.repeat(state, device_id=device_id), - scope=scope, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.repeat(state, device_id=device_id) + playback = await _settled_playback(spotify) + print_result( + playback, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("shuffle") -def player_shuffle( +@async_command +async def player_shuffle( state: Annotated[bool, typer.Argument()], device_id: DeviceOption = None, fmt: FormatOption = None, @@ -312,18 +307,23 @@ def player_shuffle( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.shuffle(state, device_id=device_id), - scope=scope, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.shuffle(state, device_id=device_id) + playback = await _settled_playback(spotify) + print_result( + playback, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("volume") -def player_volume( +@async_command +async def player_volume( volume_percent: Annotated[int, typer.Argument(min=0, max=100)], device_id: DeviceOption = None, fmt: FormatOption = None, @@ -332,18 +332,23 @@ def player_volume( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.volume(volume_percent, device_id=device_id), - scope=scope, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.volume(volume_percent, device_id=device_id) + state = await _settled_playback(spotify) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("queue") -def player_queue( +@async_command +async def player_queue( fmt: FormatOption = None, json_output: JsonOption = False, raw: RawOption = False, @@ -352,11 +357,9 @@ def player_queue( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.queue(), - scopes=_coalesce_scopes(scope) or READ_SCOPES, - ) - _render( + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.player.queue() + print_result( result, columns=("id", "name", "artists", "uri"), fmt=fmt, @@ -369,7 +372,8 @@ def player_queue( @app.command("add-to-queue") -def add_to_queue( +@async_command +async def add_to_queue( uris: UrisArgument, device_id: DeviceOption = None, fmt: FormatOption = None, @@ -381,24 +385,26 @@ def add_to_queue( """Queue one or many tracks or episodes in a single call.""" queued = _split_values(uris) - async def enqueue(spotify: Spotifyify) -> None: + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: # Spotify has no bulk queue endpoint and the queue is ordered, so these # must stay sequential. for uri in queued: await spotify.player.add_to_queue(uri, device_id=device_id) - - _control( - enqueue, - scope=scope, + state = await _settled_playback(spotify) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("transfer") -def transfer_playback( +@async_command +async def transfer_playback( device_id: Annotated[str, typer.Argument(help="Spotify device ID.")], play: Annotated[bool, typer.Option("--play/--no-play")] = False, wait: WaitOption = True, @@ -408,20 +414,27 @@ def transfer_playback( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _control( - lambda spotify: spotify.player.transfer(device_id, play=play), - scope=scope, - until=is_playing if play else None, - wait=wait, + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + await spotify.player.transfer(device_id, play=play) + state = await _settled_playback( + spotify, + until=is_playing if play else None, + wait=wait, + ) + print_result( + state, + columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) @app.command("devices") -def player_devices( +@async_command +async def player_devices( fmt: FormatOption = None, json_output: JsonOption = False, raw: RawOption = False, @@ -430,14 +443,13 @@ def player_devices( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.devices(), - scopes=_coalesce_scopes(scope) or READ_SCOPES, - ) + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.player.devices() # Spotify returns devices in no defined order; sort so repeated calls and # any caching built on top of them stay reproducible. - _render( - _sort_items(result or [], ("id",)), + display_result = result if raw else _sort_items(result or [], ("id",)) + print_result( + display_result, columns=("id", "name", "type", "is_active", "volume_percent"), fmt=fmt, json_output=json_output, @@ -449,7 +461,8 @@ def player_devices( @app.command("recently-played") -def recently_played( +@async_command +async def recently_played( limit: LimitOption = 20, after: Annotated[int | None, typer.Option("--after")] = None, before: Annotated[int | None, typer.Option("--before")] = None, @@ -461,14 +474,13 @@ def recently_played( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.player.recently_played( + async with spotify_client( + _coalesce_scopes(scope) or [SpotifyScope.USER_READ_RECENTLY_PLAYED.value] + ) as spotify: + result = await spotify.player.recently_played( limit=limit, after=after, before=before - ), - scopes=_coalesce_scopes(scope) - or [SpotifyScope.USER_READ_RECENTLY_PLAYED.value], - ) - _render( + ) + print_result( result, columns=("track.id", "track.name", "track.artists", "played_at"), fmt=fmt, diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index 1c87a95..7e0322e 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import Annotated, Any +from typing import Annotated import typer -from spotifyify import Spotifyify, SpotifyScope +from spotifyify import SpotifyScope from ._aliases import AliasGroup -from ._core import _coalesce_scopes, _split_values +from ._core import _coalesce_scopes, _split_values, spotify_client from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -21,8 +21,8 @@ SortOption, UrisArgument, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -41,41 +41,9 @@ ] -def _mutate_items( - action, - *, - playlist_id: str, - scope, - fmt: str | None, - json_output: bool, - raw: bool, - fields, -) -> None: - """Run a playlist mutation and report the resulting snapshot and length.""" - - async def command(spotify: Spotifyify) -> Any: - snapshot_id = await action(spotify) - playlist = await spotify.playlists.get(playlist_id) - return { - "playlist_id": playlist_id, - "snapshot_id": snapshot_id, - "total": getattr(getattr(playlist, "tracks", None), "total", None), - "name": getattr(playlist, "name", None), - } - - result = _handle(command, scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES) - _render( - result, - columns=SNAPSHOT_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, - fields=fields, - ) - - @app.command("search") -def search_playlists( +@async_command +async def search_playlists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, @@ -87,11 +55,9 @@ def search_playlists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.find(query, limit=limit, offset=offset), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.playlists.find(query, limit=limit, offset=offset) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -104,7 +70,8 @@ def search_playlists( @app.command("get") -def get_playlist( +@async_command +async def get_playlist( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], market: MarketOption = None, fmt: FormatOption = None, @@ -113,11 +80,9 @@ def get_playlist( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.get(playlist_id, market=market), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.playlists.get(playlist_id, market=market) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -128,7 +93,8 @@ def get_playlist( @app.command("list") -def list_playlists( +@async_command +async def list_playlists( user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, limit: LimitOption = 20, offset: OffsetOption = 0, @@ -140,13 +106,13 @@ def list_playlists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.list( + async with spotify_client( + _coalesce_scopes(scope) or [SpotifyScope.PLAYLIST_READ_PRIVATE.value] + ) as spotify: + result = await spotify.playlists.list( user_id=user_id, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope) or [SpotifyScope.PLAYLIST_READ_PRIVATE.value], - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -159,7 +125,8 @@ def list_playlists( @app.command("tracks") -def playlist_tracks( +@async_command +async def playlist_tracks( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], market: MarketOption = None, fields_query: Annotated[ @@ -182,18 +149,16 @@ def playlist_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.tracks( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.playlists.tracks( playlist_id, market=market, fields=fields_query, limit=limit, offset=offset, additional_types=_split_values(additional_types) or None, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=("track.id", "track.name", "track.artists", "added_at"), fmt=fmt, @@ -206,7 +171,8 @@ def playlist_tracks( @app.command("create") -def create_playlist( +@async_command +async def create_playlist( name: Annotated[str, typer.Argument(help="Playlist name.")], public: Annotated[bool, typer.Option("--public/--private")] = False, collaborative: Annotated[ @@ -220,17 +186,15 @@ def create_playlist( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.create( + async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + result = await spotify.playlists.create( name, public=public, collaborative=collaborative, description=description, user_id=user_id, - ), - scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES, - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -241,7 +205,8 @@ def create_playlist( @app.command("update") -def update_playlist( +@async_command +async def update_playlist( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], name: Annotated[str | None, typer.Option("--name")] = None, public: Annotated[bool | None, typer.Option("--public/--private")] = None, @@ -255,7 +220,7 @@ def update_playlist( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async def command(spotify: Spotifyify) -> Any: + async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: await spotify.playlists.update( playlist_id, name=name, @@ -263,10 +228,8 @@ async def command(spotify: Spotifyify) -> Any: collaborative=collaborative, description=description, ) - return await spotify.playlists.get(playlist_id) - - result = _handle(command, scopes=_coalesce_scopes(scope) or _MODIFY_SCOPES) - _render( + result = await spotify.playlists.get(playlist_id) + print_result( result, columns=("id", "name", "public", "collaborative", "description"), fmt=fmt, @@ -277,7 +240,8 @@ async def command(spotify: Spotifyify) -> Any: @app.command("add") -def add_playlist_items( +@async_command +async def add_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, position: Annotated[int | None, typer.Option("--position")] = None, @@ -288,12 +252,20 @@ def add_playlist_items( scope: ScopeOption = None, ) -> None: """Add one or many URIs in a single call.""" - _mutate_items( - lambda spotify: spotify.playlists.add( + async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + snapshot_id = await spotify.playlists.add( playlist_id, _split_values(uris), position=position - ), - playlist_id=playlist_id, - scope=scope, + ) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -302,7 +274,8 @@ def add_playlist_items( @app.command("replace") -def replace_playlist_items( +@async_command +async def replace_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, fmt: FormatOption = None, @@ -311,10 +284,18 @@ def replace_playlist_items( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _mutate_items( - lambda spotify: spotify.playlists.replace(playlist_id, _split_values(uris)), - playlist_id=playlist_id, - scope=scope, + async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + snapshot_id = await spotify.playlists.replace(playlist_id, _split_values(uris)) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -323,7 +304,8 @@ def replace_playlist_items( @app.command("remove") -def remove_playlist_items( +@async_command +async def remove_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, fmt: FormatOption = None, @@ -332,10 +314,18 @@ def remove_playlist_items( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _mutate_items( - lambda spotify: spotify.playlists.remove(playlist_id, _split_values(uris)), - playlist_id=playlist_id, - scope=scope, + async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + snapshot_id = await spotify.playlists.remove(playlist_id, _split_values(uris)) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -344,7 +334,8 @@ def remove_playlist_items( @app.command("reorder") -def reorder_playlist_items( +@async_command +async def reorder_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], range_start: Annotated[int, typer.Option("--range-start", min=0)], insert_before: Annotated[int, typer.Option("--insert-before", min=0)], @@ -356,16 +347,24 @@ def reorder_playlist_items( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _mutate_items( - lambda spotify: spotify.playlists.reorder( + async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + new_snapshot_id = await spotify.playlists.reorder( playlist_id, range_start=range_start, insert_before=insert_before, range_length=range_length, snapshot_id=snapshot_id, - ), - playlist_id=playlist_id, - scope=scope, + ) + playlist = await spotify.playlists.get(playlist_id) + result = { + "playlist_id": playlist_id, + "snapshot_id": new_snapshot_id, + "total": getattr(getattr(playlist, "tracks", None), "total", None), + "name": getattr(playlist, "name", None), + } + print_result( + result, + columns=SNAPSHOT_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -374,7 +373,8 @@ def reorder_playlist_items( @app.command("cover-image") -def playlist_cover_image( +@async_command +async def playlist_cover_image( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], fmt: FormatOption = None, json_output: JsonOption = False, @@ -383,11 +383,9 @@ def playlist_cover_image( sort: SortOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.playlists.cover_image(playlist_id), - scopes=_coalesce_scopes(scope), - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.playlists.cover_image(playlist_id) + print_result( result, columns=("url", "width", "height"), fmt=fmt, diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py index eaeb68f..a44807a 100644 --- a/spotifyify/cli/quick.py +++ b/spotifyify/cli/quick.py @@ -4,14 +4,14 @@ import typer -from spotifyify import Spotifyify - from ._core import ( PLAYBACK_COLUMNS, _coalesce_scopes, + _playback_summary, _settled_playback, is_fresh_track, plays_uri, + spotify_client, ) from ._options import ( DeviceOption, @@ -21,8 +21,8 @@ RawOption, ScopeOption, WaitOption, - _handle, - _render, + async_command, + print_result, ) from .player import CONTROL_SCOPES, _play_with_device_fallback @@ -62,7 +62,8 @@ def register(app: typer.Typer) -> None: """Attach the top-level resolve-and-play command.""" @app.command("play") - def play( + @async_command + async def play( words: Annotated[ list[str] | None, typer.Argument(help="Free-text search terms, added to the query as-is."), @@ -107,7 +108,7 @@ def play( wants_track = bool(track or words) wants_album = bool(album) and not wants_track - async def command(spotify: Spotifyify) -> Any: + async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: if wants_track: hit = _first(await spotify.tracks.find(query, limit=1, market=market)) uris, context_uri = ([hit.uri] if hit else None), None @@ -128,14 +129,14 @@ async def command(spotify: Spotifyify) -> Any: # Wait for the thing we resolved, so the reported state is not the # track that happened to be playing already. until = plays_uri(uris[0]) if uris else is_fresh_track - return await _settled_playback(spotify, until=until, wait=wait) + state = await _settled_playback(spotify, until=until, wait=wait) - result = _handle(command, scopes=_coalesce_scopes(scope) or CONTROL_SCOPES) - _render( - result, + print_result( + state, columns=PLAYBACK_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, fields=fields, + project=_playback_summary, ) diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index fb53f62..3831f06 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -5,7 +5,13 @@ import typer from ._aliases import AliasGroup -from ._core import BATCH_SHOWS, _coalesce_scopes, _gather_batches, _split_values +from ._core import ( + BATCH_SHOWS, + _coalesce_scopes, + _gather_batches, + _split_values, + spotify_client, +) from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -19,8 +25,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -35,7 +41,8 @@ @app.command("search") -def search_shows( +@async_command +async def search_shows( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, @@ -48,13 +55,11 @@ def search_shows( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.shows.find( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.shows.find( query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -67,7 +72,8 @@ def search_shows( @app.command("get") -def get_shows( +@async_command +async def get_shows( show_ids: IdsArgument, market: MarketOption = None, fmt: FormatOption = None, @@ -80,15 +86,13 @@ def get_shows( ) -> None: """Fetch one or many shows in a single call.""" ids = _split_values(show_ids) - result = _handle( - lambda spotify: _gather_batches( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await _gather_batches( lambda chunk: spotify.shows.get_many(chunk, market=market), ids, BATCH_SHOWS, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -101,7 +105,8 @@ def get_shows( @app.command("episodes") -def show_episodes( +@async_command +async def show_episodes( show_id: Annotated[str, typer.Argument(help="Spotify show ID.")], market: MarketOption = None, limit: LimitOption = 20, @@ -114,13 +119,11 @@ def show_episodes( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.shows.episodes( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.shows.episodes( show_id, market=market, limit=limit, offset=offset - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=EPISODE_COLUMNS, fmt=fmt, diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index 9d8a24a..ac3db8b 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -5,7 +5,13 @@ import typer from ._aliases import AliasGroup -from ._core import BATCH_TRACKS, _coalesce_scopes, _gather_batches, _split_values +from ._core import ( + BATCH_TRACKS, + _coalesce_scopes, + _gather_batches, + _split_values, + spotify_client, +) from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -19,8 +25,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -34,7 +40,8 @@ @app.command("search") -def search_tracks( +@async_command +async def search_tracks( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, offset: OffsetOption = 0, @@ -47,13 +54,11 @@ def search_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.tracks.find( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.tracks.find( query, limit=limit, offset=offset, market=market - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -66,7 +71,8 @@ def search_tracks( @app.command("get") -def get_tracks( +@async_command +async def get_tracks( track_ids: IdsArgument, market: MarketOption = None, fmt: FormatOption = None, @@ -79,15 +85,13 @@ def get_tracks( ) -> None: """Fetch one or many tracks in a single call.""" ids = _split_values(track_ids) - result = _handle( - lambda spotify: _gather_batches( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await _gather_batches( lambda chunk: spotify.tracks.get_many(chunk, market=market), ids, BATCH_TRACKS, - ), - scopes=_coalesce_scopes(scope), - ) - _render( + ) + print_result( result, columns=COLUMNS, fmt=fmt, diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index c2e48ad..61bb553 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -1,20 +1,19 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence from typing import Annotated import typer -from spotifyify import Spotifyify, SpotifyScope +from spotifyify import SpotifyScope from ._aliases import AliasGroup from ._core import ( BATCH_FOLLOW, - Jsonable, _coalesce_scopes, _merge_scopes, _sequential_batches, _split_values, + spotify_client, ) from ._options import ( FieldsOption, @@ -26,8 +25,8 @@ ScopeOption, SortOption, WhereOption, - _handle, - _render, + async_command, + print_result, ) app = typer.Typer( @@ -46,51 +45,18 @@ WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) -def _follow_action( - type: str, - ids: Sequence[str], - *, - action: Callable[[Spotifyify, list[str]], Awaitable[None]], - scope: Sequence[str] | None, - fmt: str | None, - json_output: bool, - raw: bool, - fields: Sequence[str] | None, -) -> None: - """Apply a follow mutation and report the resulting following state.""" - item_ids = _split_values(ids) - - async def command(spotify: Spotifyify) -> Jsonable: - await _sequential_batches( - lambda chunk: action(spotify, chunk), item_ids, BATCH_FOLLOW - ) - following = await spotify.users.check_following(type, item_ids) - return [ - {"id": item_id, "following": is_following} - for item_id, is_following in zip(item_ids, following, strict=False) - ] - - result = _handle(command, scopes=_coalesce_scopes(scope) or WRITE_SCOPES) - _render( - result, - columns=FOLLOWING_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, - fields=fields, - ) - - @app.command("me") -def users_me( +@async_command +async def users_me( fmt: FormatOption = None, json_output: JsonOption = False, raw: RawOption = False, fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle(lambda spotify: spotify.users.me(), scopes=_coalesce_scopes(scope)) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.users.me() + print_result( result, columns=COLUMNS, fmt=fmt, @@ -101,7 +67,8 @@ def users_me( @app.command("get") -def users_get( +@async_command +async def users_get( user_id: Annotated[str, typer.Argument(help="Spotify user ID.")], fmt: FormatOption = None, json_output: JsonOption = False, @@ -109,10 +76,9 @@ def users_get( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.users.get(user_id), scopes=_coalesce_scopes(scope) - ) - _render( + async with spotify_client(_coalesce_scopes(scope)) as spotify: + result = await spotify.users.get(user_id) + print_result( result, columns=COLUMNS, fmt=fmt, @@ -123,7 +89,8 @@ def users_get( @app.command("following") -def users_following( +@async_command +async def users_following( type: Annotated[str, typer.Option("--type")] = "artist", limit: LimitOption = 20, after: Annotated[str | None, typer.Option("--after")] = None, @@ -135,11 +102,9 @@ def users_following( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - result = _handle( - lambda spotify: spotify.users.following(type=type, limit=limit, after=after), - scopes=_coalesce_scopes(scope) or READ_SCOPES, - ) - _render( + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + result = await spotify.users.following(type=type, limit=limit, after=after) + print_result( result, columns=("id", "name", "uri"), fmt=fmt, @@ -152,7 +117,8 @@ def users_following( @app.command("follow") -def users_follow( +@async_command +async def users_follow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, fmt: FormatOption = None, @@ -161,11 +127,21 @@ def users_follow( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _follow_action( - type, - ids, - action=lambda spotify, chunk: spotify.users.follow(type, chunk), - scope=scope, + item_ids = _split_values(ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches( + lambda chunk: spotify.users.follow(type, chunk), + item_ids, + BATCH_FOLLOW, + ) + following = await spotify.users.check_following(type, item_ids) + result = [ + {"id": item_id, "following": is_following} + for item_id, is_following in zip(item_ids, following, strict=False) + ] + print_result( + result, + columns=FOLLOWING_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -174,7 +150,8 @@ def users_follow( @app.command("unfollow") -def users_unfollow( +@async_command +async def users_unfollow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, fmt: FormatOption = None, @@ -183,11 +160,21 @@ def users_unfollow( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - _follow_action( - type, - ids, - action=lambda spotify, chunk: spotify.users.unfollow(type, chunk), - scope=scope, + item_ids = _split_values(ids) + async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + await _sequential_batches( + lambda chunk: spotify.users.unfollow(type, chunk), + item_ids, + BATCH_FOLLOW, + ) + following = await spotify.users.check_following(type, item_ids) + result = [ + {"id": item_id, "following": is_following} + for item_id, is_following in zip(item_ids, following, strict=False) + ] + print_result( + result, + columns=FOLLOWING_COLUMNS, fmt=fmt, json_output=json_output, raw=raw, @@ -196,7 +183,8 @@ def users_unfollow( @app.command("check-following") -def users_check_following( +@async_command +async def users_check_following( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, fmt: FormatOption = None, @@ -206,15 +194,13 @@ def users_check_following( scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - result = _handle( - lambda spotify: spotify.users.check_following(type, item_ids), - scopes=_coalesce_scopes(scope) or READ_SCOPES, - ) + async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + following_values = await spotify.users.check_following(type, item_ids) payload = [ {"id": item_id, "following": following} - for item_id, following in zip(item_ids, result, strict=False) + for item_id, following in zip(item_ids, following_values, strict=False) ] - _render( + print_result( payload, columns=FOLLOWING_COLUMNS, fmt=fmt, diff --git a/tests/test_cli.py b/tests/test_cli.py index c07df07..8c16340 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -389,6 +389,31 @@ async def find(query, **kwargs): self.assertIn("available_markets", result.output) self.assertEqual(json.loads(result.output)["total"], 1) + def test_player_state_raw_returns_the_unprojected_playback_payload(self): + async def state(**kwargs): + return PlaybackState.model_validate( + { + "is_playing": True, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + result = self._run( + ["player", "state", "--raw"], + {"player": SimpleNamespace(state=state)}, + ) + + payload = json.loads(result.output) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(payload["is_playing"]) + self.assertEqual(payload["item"]["name"], "HAMPELMANN") + self.assertNotIn("state", payload) + def test_a_playback_mutation_prints_the_resulting_state(self): played = {} From 641843145d45e02583adfeb288ea0bf85e90109f Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 11:33:17 +0200 Subject: [PATCH 04/12] Use non deprecated async context manager typing --- spotifyify/cli/_core.py | 4 ++-- spotifyify/spotifyify.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spotifyify/cli/_core.py b/spotifyify/cli/_core.py index 431c8b9..738979e 100644 --- a/spotifyify/cli/_core.py +++ b/spotifyify/cli/_core.py @@ -4,7 +4,7 @@ import json import os import re -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from contextlib import asynccontextmanager from typing import Any @@ -316,7 +316,7 @@ def _print_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> N @asynccontextmanager -async def spotify_client(scopes: Sequence[str]) -> AsyncIterator[Spotifyify]: +async def spotify_client(scopes: Sequence[str]) -> AsyncGenerator[Spotifyify, None]: """Open the async Spotify client used by one explicit CLI command.""" async with Spotifyify(scopes=_parse_scopes(scopes)) as spotify: yield spotify diff --git a/spotifyify/spotifyify.py b/spotifyify/spotifyify.py index 4abbc55..febffc4 100644 --- a/spotifyify/spotifyify.py +++ b/spotifyify/spotifyify.py @@ -1,6 +1,6 @@ from typing import Any, Self -from collections.abc import AsyncIterator, Iterable, Iterator +from collections.abc import AsyncGenerator, Generator, Iterable from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar, Token @@ -77,7 +77,7 @@ async def close(self) -> None: await self._oauth.close() @contextmanager - def retry_hook(self, hook: OnRetryHook) -> Iterator[None]: + def retry_hook(self, hook: OnRetryHook) -> Generator[None, None, None]: token = current_retry_hook.set(hook) try: yield @@ -90,7 +90,7 @@ async def session( *, access_token: str | None = None, on_retry: OnRetryHook | None = None, - ) -> AsyncIterator[None]: + ) -> AsyncGenerator[None, None]: """Scope user-specific calls with a supplied token, otherwise use app auth.""" ctx_tokens: list[tuple[ContextVar[Any], Token]] = [] if access_token is not None: @@ -106,7 +106,7 @@ async def session( context_var.reset(token) @asynccontextmanager - async def user_token(self, access_token: str) -> AsyncIterator[None]: + async def user_token(self, access_token: str) -> AsyncGenerator[None, None]: async with self.session(access_token=access_token): yield From 191b89460feaa472ba61e8848aeaf84b75b760c5 Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 11:57:07 +0200 Subject: [PATCH 05/12] Remove agent help and alias table --- README.md | 18 +++--- spotifyify/cli/__init__.py | 29 +-------- spotifyify/cli/_agent_help.py | 109 ---------------------------------- spotifyify/cli/_aliases.py | 94 ----------------------------- spotifyify/cli/_core.py | 10 ++-- spotifyify/cli/albums.py | 11 ++-- spotifyify/cli/artists.py | 13 ++-- spotifyify/cli/episodes.py | 7 +-- spotifyify/cli/library.py | 43 ++++++-------- spotifyify/cli/player.py | 31 +++++----- spotifyify/cli/playlists.py | 26 ++++---- spotifyify/cli/quick.py | 3 +- spotifyify/cli/shows.py | 9 +-- spotifyify/cli/tracks.py | 7 +-- spotifyify/cli/users.py | 15 ++--- tests/test_cli.py | 58 +++++++----------- 16 files changed, 103 insertions(+), 380 deletions(-) delete mode 100644 spotifyify/cli/_agent_help.py delete mode 100644 spotifyify/cli/_aliases.py diff --git a/README.md b/README.md index bb9224b..5314fba 100644 --- a/README.md +++ b/README.md @@ -368,18 +368,18 @@ Both formats project the same fields, so `--format` changes the encoding and nothing else. `--raw` opts out into the untouched Spotify payload when you need paging metadata or a field that is not a declared column. -### One-shot discoverability +### Command discovery -`--agent-help` dumps the entire command tree and this output contract in a single -call, small enough to paste into a system prompt: +Use the standard `--help` option at the root, group, or command level: ```bash -spotifyify --agent-help +spotifyify --help +spotifyify artists --help +spotifyify artists get --help ``` -Group names also tolerate the singular form, so `spotifyify artist get ID` works -as well as `artists`. Unknown names list the alternatives in the error itself -rather than requiring a `--help` round trip. +Resource groups use plural names consistently (`artists`, `tracks`, `albums`), +and each command has one canonical spelling. ### Everyday usage @@ -490,10 +490,8 @@ client. | `player` | `state`, `play`, `pause`, `skip`, `previous`, `seek`, `repeat`, `shuffle`, `volume`, `queue`, `add-to-queue`, `transfer`, `devices`, `recently-played` | | `users` | `me`, `get`, `following`, `follow`, `unfollow`, `check-following` | -`get` replaces the former `get-many`, which is still accepted as an alias. - ```bash -spotifyify --agent-help +spotifyify --help spotifyify playlists create --help spotifyify player play --help ``` diff --git a/spotifyify/cli/__init__.py b/spotifyify/cli/__init__.py index 5783a0e..1480307 100644 --- a/spotifyify/cli/__init__.py +++ b/spotifyify/cli/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import sys -from typing import Annotated try: import typer @@ -77,14 +76,11 @@ def main() -> None: tracks, users, ) - from ._agent_help import agent_help - from ._aliases import AliasGroup - __all__ += ["agent_help", "app"] + __all__ += ["app"] app = typer.Typer( help="Command line tools for the spotifyify Spotify client.", - cls=AliasGroup, # Plain click help: no ANSI, no boxes, no pager. rich_markup_mode=None, # Completion installers are Typer's only interactive code path. @@ -105,28 +101,5 @@ def main() -> None: app.add_typer(users.app, name="users") quick.register(app) - def _render_agent_help() -> str: - return agent_help(typer.main.get_command(app)) - - @app.command("agent-help", hidden=True) - def agent_help_command() -> None: - """Print the whole command tree and output contract in one shot.""" - typer.echo(_render_agent_help()) - - @app.callback(invoke_without_command=True) - def _root( - agent: Annotated[ - bool, - typer.Option( - "--agent-help", - help="Print the whole command tree and output contract in one shot.", - ), - ] = False, - ) -> None: - if agent: - typer.echo(_render_agent_help()) - raise typer.Exit() - - if __name__ == "__main__": main() diff --git a/spotifyify/cli/_agent_help.py b/spotifyify/cli/_agent_help.py deleted file mode 100644 index 3c7f7ab..0000000 --- a/spotifyify/cli/_agent_help.py +++ /dev/null @@ -1,109 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import click - -# Options every read command shares. Listing them once keeps the per-command -# lines short enough that the whole tree fits in a system prompt. -GLOBAL_OPTIONS = frozenset( - { - "--format", - "--json", - "--field", - "--fields", - "-f", - "--sort", - "--where", - "--limit", - "-l", - "--offset", - "-o", - "--market", - "-m", - "--scope", - "-s", - "--help", - } -) - -PREAMBLE = """\ -spotifyify GROUP COMMAND [ARGS] [OPTIONS] - -Output contract: - JSON on stdout, always. --format table for aligned text. Never switches on isatty(). - No ANSI, no pager, no prompts. Errors go to stderr. - Exit 0 ok, 1 API error, 2 usage, 3 auth, 4 no match. - Row order is whatever Spotify returned; --sort FIELD is a stable re-sort ('-' reverses). - Mutations print the state they produced, not "ok". -Global options: --format json|table --json --field F,F --sort F,-F --where PATH=VALUE - --limit N --offset N --market XX --scope S -IDs, URIs, fields and scopes accept repeated or comma-separated values. -Group names tolerate the singular form (artist -> artists). - -Commands (ARG... means variadic):""" - - -def _is_group(command: Any) -> bool: - # Typer vendors its own click classes, so isinstance against click is unsafe. - return hasattr(command, "list_commands") - - -def _is_argument(param: Any) -> bool: - return getattr(param, "param_type_name", None) == "argument" - - -def _argument_names(command: Any) -> list[str]: - return [ - f"{param.name.upper()}..." if param.nargs == -1 else param.name.upper() - for param in command.params - if _is_argument(param) - ] - - -def _option_names(command: Any) -> list[str]: - names = [] - for param in command.params: - if _is_argument(param): - continue - long_opts = [opt for opt in param.opts if opt.startswith("--")] - if not long_opts: - continue - flag = long_opts[0] - if flag in GLOBAL_OPTIONS: - continue - names.append(flag) - return names - - -def _signature(name: str, command: Any) -> str: - parts = [name, *_argument_names(command)] - options = _option_names(command) - if options: - parts.append(f"[{' '.join(options)}]") - return " ".join(parts) - - -def _group_line(name: str, group: Any, ctx: click.Context) -> str: - signatures = [] - for command_name in group.list_commands(ctx): - command = group.get_command(ctx, command_name) - if command is None or command.hidden: - continue - signatures.append(_signature(command_name, command)) - return f"{name}: " + " | ".join(signatures) - - -def agent_help(root: Any, program_name: str = "spotifyify") -> str: - """Dump the whole command tree in one shot, straight from the real tree.""" - lines = [PREAMBLE] - with click.Context(root, info_name=program_name) as ctx: - for name in root.list_commands(ctx): - command = root.get_command(ctx, name) - if command is None or command.hidden: - continue - if _is_group(command): - lines.append(_group_line(name, command, ctx)) - else: - lines.append(_signature(name, command)) - return "\n".join(lines) diff --git a/spotifyify/cli/_aliases.py b/spotifyify/cli/_aliases.py deleted file mode 100644 index cc6f6e2..0000000 --- a/spotifyify/cli/_aliases.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from typer.core import TyperGroup - -try: - # Typer vendors its own copy of click; its UsageError is a different class - # from click.UsageError, so catching the latter would silently miss. - from typer._click.exceptions import UsageError -except ImportError: # pragma: no cover - falls back to plain click errors. - from click import UsageError - -# Names an agent is likely to reach for that are not the canonical spelling. -# Tolerating them costs nothing; failing on them costs a --help round trip. -SYNONYMS: dict[str, str] = { - "find": "search", - "query": "search", - "ls": "list", - "show": "get", - "get-many": "get", - "getmany": "get", - "song": "tracks", - "songs": "tracks", - "podcast": "shows", - "podcasts": "shows", - "playback": "player", - "status": "state", - "now-playing": "state", - "current": "state", - "next": "skip", - "forward": "skip", - "prev": "previous", - "back": "previous", - "enqueue": "add-to-queue", - "queue-add": "add-to-queue", - "resume": "play", - "start": "play", - "stop": "pause", - "profile": "me", - "whoami": "me", - "saved-track": "saved-tracks", - "saved-album": "saved-albums", - "recent": "recently-played", - "history": "recently-played", -} - - -def _candidates(name: str) -> list[str]: - """Spellings to try, in order, before giving up on a command name.""" - normalized = name.strip().lower().replace("_", "-") - tried = [normalized] - if normalized in SYNONYMS: - tried.append(SYNONYMS[normalized]) - # Plural/singular tolerance: `artist` and `artists` should both work. - tried.append(f"{normalized}s") - if normalized.endswith("s"): - tried.append(normalized[:-1]) - return tried - - -class AliasGroup(TyperGroup): - """A command group that accepts near-miss spellings instead of failing. - - Aliases resolve deterministically through an explicit table plus - plural/singular normalization. Prefix matching is deliberately not - supported: it would silently change meaning whenever a command is added. - """ - - def get_command(self, ctx: Any, name: str) -> Any: - command = super().get_command(ctx, name) - if command is not None: - return command - for candidate in _candidates(name): - command = super().get_command(ctx, candidate) - if command is not None: - return command - return None - - def resolve_command(self, ctx: Any, args: list[str]) -> Any: - try: - return super().resolve_command(ctx, args) - except UsageError as exc: - # Name the alternatives in the failure itself, so a wrong guess - # costs one call rather than a --help round trip. - message = getattr(exc, "message", None) - if args and isinstance(message, str) and "No such command" in message: - known = ", ".join( - name - for name in self.list_commands(ctx) - if not getattr(self.get_command(ctx, name), "hidden", False) - ) - exc.message = f"{message} Available: {known}" - raise diff --git a/spotifyify/cli/_core.py b/spotifyify/cli/_core.py index 738979e..590d1ac 100644 --- a/spotifyify/cli/_core.py +++ b/spotifyify/cli/_core.py @@ -51,7 +51,7 @@ def _split_values(values: Sequence[str] | None) -> list[str]: return items -def _parse_scopes(scope_values: Sequence[str]) -> list[SpotifyScope | str]: +def _parse_scopes(scope_values: Sequence[str] | None) -> list[SpotifyScope | str]: scopes: list[SpotifyScope | str] = [] for value in _split_values(scope_values): try: @@ -61,10 +61,6 @@ def _parse_scopes(scope_values: Sequence[str]) -> list[SpotifyScope | str]: return scopes -def _coalesce_scopes(scope_values: Sequence[str] | None) -> Sequence[str]: - return scope_values or () - - def _merge_scopes(*scope_groups: Sequence[str]) -> list[str]: merged: list[str] = [] for group in scope_groups: @@ -316,7 +312,9 @@ def _print_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> N @asynccontextmanager -async def spotify_client(scopes: Sequence[str]) -> AsyncGenerator[Spotifyify, None]: +async def spotify_client( + scopes: Sequence[str] | None, +) -> AsyncGenerator[Spotifyify, None]: """Open the async Spotify client used by one explicit CLI command.""" async with Spotifyify(scopes=_parse_scopes(scopes)) as spotify: yield spotify diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index 6b475de..c48c692 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -4,10 +4,8 @@ import typer -from ._aliases import AliasGroup from ._core import ( BATCH_ALBUMS, - _coalesce_scopes, _gather_batches, _split_values, spotify_client, @@ -31,7 +29,6 @@ app = typer.Typer( help="Work with Spotify albums.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -54,7 +51,7 @@ async def search_albums( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.albums.find( query, limit=limit, offset=offset, market=market ) @@ -85,7 +82,7 @@ async def get_albums( ) -> None: """Fetch one or many albums in a single call.""" ids = _split_values(album_ids) - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await _gather_batches( lambda chunk: spotify.albums.get_many(chunk, market=market), ids, @@ -118,7 +115,7 @@ async def album_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.albums.tracks( album_id, limit=limit, offset=offset, market=market ) @@ -148,7 +145,7 @@ async def new_releases( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.albums.new_releases( country=country, limit=limit, offset=offset ) diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index c44e67d..3953cf1 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -4,10 +4,8 @@ import typer -from ._aliases import AliasGroup from ._core import ( BATCH_ARTISTS, - _coalesce_scopes, _gather_batches, _split_values, spotify_client, @@ -31,7 +29,6 @@ app = typer.Typer( help="Work with Spotify artists.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -55,7 +52,7 @@ async def search_artists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.artists.find(query, limit=limit, offset=offset) print_result( result, @@ -83,7 +80,7 @@ async def get_artists( ) -> None: """Fetch one or many artists in a single call.""" ids = _split_values(artist_ids) - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await _gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS) print_result( result, @@ -110,7 +107,7 @@ async def artist_top_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.artists.top_tracks(artist_id, market=market) print_result( result, @@ -143,7 +140,7 @@ async def artist_albums( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.artists.albums( artist_id, include_groups=include_groups, @@ -175,7 +172,7 @@ async def related_artists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.artists.related(artist_id) print_result( result, diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index ed84017..c27baf5 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -4,10 +4,8 @@ import typer -from ._aliases import AliasGroup from ._core import ( BATCH_EPISODES, - _coalesce_scopes, _gather_batches, _split_values, spotify_client, @@ -31,7 +29,6 @@ app = typer.Typer( help="Work with Spotify episodes.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -55,7 +52,7 @@ async def search_episodes( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.episodes.find( query, limit=limit, offset=offset, market=market ) @@ -86,7 +83,7 @@ async def get_episodes( ) -> None: """Fetch one or many episodes in a single call.""" ids = _split_values(episode_ids) - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await _gather_batches( lambda chunk: spotify.episodes.get_many(chunk, market=market), ids, diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 751960b..5fd11e1 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -6,13 +6,11 @@ from spotifyify import SpotifyScope -from ._aliases import AliasGroup from ._core import ( BATCH_ALBUMS, BATCH_EPISODES, BATCH_SHOWS, BATCH_TRACKS, - _coalesce_scopes, _merge_scopes, _sequential_batches, _split_values, @@ -36,7 +34,6 @@ app = typer.Typer( help="Work with the current user's library.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -63,7 +60,7 @@ async def saved_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.library.saved_tracks( limit=limit, offset=offset, market=market ) @@ -93,7 +90,7 @@ async def saved_albums( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.library.saved_albums( limit=limit, offset=offset, market=market ) @@ -122,7 +119,7 @@ async def saved_shows( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.library.saved_shows(limit=limit, offset=offset) print_result( result, @@ -149,7 +146,7 @@ async def saved_episodes( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.library.saved_episodes(limit=limit, offset=offset) print_result( result, @@ -177,9 +174,7 @@ async def library_top_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client( - _coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value] - ) as spotify: + async with spotify_client(scope or [SpotifyScope.USER_TOP_READ.value]) as spotify: result = await spotify.library.top_tracks( time_range=time_range, limit=limit, offset=offset ) @@ -209,9 +204,7 @@ async def library_top_artists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client( - _coalesce_scopes(scope) or [SpotifyScope.USER_TOP_READ.value] - ) as spotify: + async with spotify_client(scope or [SpotifyScope.USER_TOP_READ.value]) as spotify: result = await spotify.library.top_artists( time_range=time_range, limit=limit, offset=offset ) @@ -238,7 +231,7 @@ async def save_tracks( scope: ScopeOption = None, ) -> None: ids = _split_values(track_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_tracks, ids, BATCH_TRACKS) saved = await spotify.library.check_tracks(ids) result = [ @@ -266,7 +259,7 @@ async def remove_tracks( scope: ScopeOption = None, ) -> None: ids = _split_values(track_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_tracks, ids, BATCH_TRACKS) saved = await spotify.library.check_tracks(ids) result = [ @@ -294,7 +287,7 @@ async def check_tracks( scope: ScopeOption = None, ) -> None: ids = _split_values(track_ids) - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: saved = await spotify.library.check_tracks(ids) result = [ {"id": item_id, "saved": is_saved} @@ -321,7 +314,7 @@ async def save_albums( scope: ScopeOption = None, ) -> None: ids = _split_values(album_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_albums, ids, BATCH_ALBUMS) saved = await spotify.library.check_albums(ids) result = [ @@ -349,7 +342,7 @@ async def remove_albums( scope: ScopeOption = None, ) -> None: ids = _split_values(album_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_albums, ids, BATCH_ALBUMS) saved = await spotify.library.check_albums(ids) result = [ @@ -377,7 +370,7 @@ async def check_albums( scope: ScopeOption = None, ) -> None: ids = _split_values(album_ids) - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: saved = await spotify.library.check_albums(ids) result = [ {"id": item_id, "saved": is_saved} @@ -404,7 +397,7 @@ async def save_shows( scope: ScopeOption = None, ) -> None: ids = _split_values(show_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_shows, ids, BATCH_SHOWS) saved = await spotify.library.check_shows(ids) result = [ @@ -432,7 +425,7 @@ async def remove_shows( scope: ScopeOption = None, ) -> None: ids = _split_values(show_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_shows, ids, BATCH_SHOWS) saved = await spotify.library.check_shows(ids) result = [ @@ -460,7 +453,7 @@ async def check_shows( scope: ScopeOption = None, ) -> None: ids = _split_values(show_ids) - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: saved = await spotify.library.check_shows(ids) result = [ {"id": item_id, "saved": is_saved} @@ -487,7 +480,7 @@ async def save_episodes( scope: ScopeOption = None, ) -> None: ids = _split_values(episode_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_episodes, ids, BATCH_EPISODES) saved = await spotify.library.check_episodes(ids) result = [ @@ -515,7 +508,7 @@ async def remove_episodes( scope: ScopeOption = None, ) -> None: ids = _split_values(episode_ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_episodes, ids, BATCH_EPISODES) saved = await spotify.library.check_episodes(ids) result = [ @@ -543,7 +536,7 @@ async def check_episodes( scope: ScopeOption = None, ) -> None: ids = _split_values(episode_ids) - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: saved = await spotify.library.check_episodes(ids) result = [ {"id": item_id, "saved": is_saved} diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index a3f6fdd..4f89d73 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -8,10 +8,8 @@ from spotifyify.exceptions import SpotifyAPIError from spotifyify.schemas import Device -from ._aliases import AliasGroup from ._core import ( PLAYBACK_COLUMNS, - _coalesce_scopes, _merge_scopes, _parse_json_object, _playback_summary, @@ -42,7 +40,6 @@ app = typer.Typer( help="Control and inspect Spotify playback.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -114,7 +111,7 @@ async def player_state( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: state = await spotify.player.state(market=market) print_result( state, @@ -150,7 +147,7 @@ async def player_play( until = is_fresh_track else: until = is_playing - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await _play_with_device_fallback( spotify, device_id=device_id, @@ -182,7 +179,7 @@ async def player_pause( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.pause(device_id=device_id) state = await _settled_playback(spotify, until=is_paused, wait=wait) print_result( @@ -207,7 +204,7 @@ async def player_skip( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.skip(device_id=device_id) state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) print_result( @@ -232,7 +229,7 @@ async def player_previous( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.previous(device_id=device_id) state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) print_result( @@ -257,7 +254,7 @@ async def player_seek( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.seek(position_ms, device_id=device_id) state = await _settled_playback(spotify) print_result( @@ -282,7 +279,7 @@ async def player_repeat( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.repeat(state, device_id=device_id) playback = await _settled_playback(spotify) print_result( @@ -307,7 +304,7 @@ async def player_shuffle( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.shuffle(state, device_id=device_id) playback = await _settled_playback(spotify) print_result( @@ -332,7 +329,7 @@ async def player_volume( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.volume(volume_percent, device_id=device_id) state = await _settled_playback(spotify) print_result( @@ -357,7 +354,7 @@ async def player_queue( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.player.queue() print_result( result, @@ -385,7 +382,7 @@ async def add_to_queue( """Queue one or many tracks or episodes in a single call.""" queued = _split_values(uris) - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: # Spotify has no bulk queue endpoint and the queue is ordered, so these # must stay sequential. for uri in queued: @@ -414,7 +411,7 @@ async def transfer_playback( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: await spotify.player.transfer(device_id, play=play) state = await _settled_playback( spotify, @@ -443,7 +440,7 @@ async def player_devices( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.player.devices() # Spotify returns devices in no defined order; sort so repeated calls and # any caching built on top of them stay reproducible. @@ -475,7 +472,7 @@ async def recently_played( scope: ScopeOption = None, ) -> None: async with spotify_client( - _coalesce_scopes(scope) or [SpotifyScope.USER_READ_RECENTLY_PLAYED.value] + scope or [SpotifyScope.USER_READ_RECENTLY_PLAYED.value] ) as spotify: result = await spotify.player.recently_played( limit=limit, after=after, before=before diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index 7e0322e..3f37c23 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -6,8 +6,7 @@ from spotifyify import SpotifyScope -from ._aliases import AliasGroup -from ._core import _coalesce_scopes, _split_values, spotify_client +from ._core import _split_values, spotify_client from ._options import ( DEFAULT_LIMIT, FieldsOption, @@ -27,7 +26,6 @@ app = typer.Typer( help="Work with Spotify playlists.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -55,7 +53,7 @@ async def search_playlists( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.playlists.find(query, limit=limit, offset=offset) print_result( result, @@ -80,7 +78,7 @@ async def get_playlist( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.playlists.get(playlist_id, market=market) print_result( result, @@ -107,7 +105,7 @@ async def list_playlists( scope: ScopeOption = None, ) -> None: async with spotify_client( - _coalesce_scopes(scope) or [SpotifyScope.PLAYLIST_READ_PRIVATE.value] + scope or [SpotifyScope.PLAYLIST_READ_PRIVATE.value] ) as spotify: result = await spotify.playlists.list( user_id=user_id, limit=limit, offset=offset @@ -149,7 +147,7 @@ async def playlist_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.playlists.tracks( playlist_id, market=market, @@ -186,7 +184,7 @@ async def create_playlist( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + async with spotify_client(scope or _MODIFY_SCOPES) as spotify: result = await spotify.playlists.create( name, public=public, @@ -220,7 +218,7 @@ async def update_playlist( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + async with spotify_client(scope or _MODIFY_SCOPES) as spotify: await spotify.playlists.update( playlist_id, name=name, @@ -252,7 +250,7 @@ async def add_playlist_items( scope: ScopeOption = None, ) -> None: """Add one or many URIs in a single call.""" - async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + async with spotify_client(scope or _MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.add( playlist_id, _split_values(uris), position=position ) @@ -284,7 +282,7 @@ async def replace_playlist_items( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + async with spotify_client(scope or _MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.replace(playlist_id, _split_values(uris)) playlist = await spotify.playlists.get(playlist_id) result = { @@ -314,7 +312,7 @@ async def remove_playlist_items( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + async with spotify_client(scope or _MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.remove(playlist_id, _split_values(uris)) playlist = await spotify.playlists.get(playlist_id) result = { @@ -347,7 +345,7 @@ async def reorder_playlist_items( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or _MODIFY_SCOPES) as spotify: + async with spotify_client(scope or _MODIFY_SCOPES) as spotify: new_snapshot_id = await spotify.playlists.reorder( playlist_id, range_start=range_start, @@ -383,7 +381,7 @@ async def playlist_cover_image( sort: SortOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.playlists.cover_image(playlist_id) print_result( result, diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py index a44807a..6611899 100644 --- a/spotifyify/cli/quick.py +++ b/spotifyify/cli/quick.py @@ -6,7 +6,6 @@ from ._core import ( PLAYBACK_COLUMNS, - _coalesce_scopes, _playback_summary, _settled_playback, is_fresh_track, @@ -108,7 +107,7 @@ async def play( wants_track = bool(track or words) wants_album = bool(album) and not wants_track - async with spotify_client(_coalesce_scopes(scope) or CONTROL_SCOPES) as spotify: + async with spotify_client(scope or CONTROL_SCOPES) as spotify: if wants_track: hit = _first(await spotify.tracks.find(query, limit=1, market=market)) uris, context_uri = ([hit.uri] if hit else None), None diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index 3831f06..0fb90d3 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -4,10 +4,8 @@ import typer -from ._aliases import AliasGroup from ._core import ( BATCH_SHOWS, - _coalesce_scopes, _gather_batches, _split_values, spotify_client, @@ -31,7 +29,6 @@ app = typer.Typer( help="Work with Spotify shows.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -55,7 +52,7 @@ async def search_shows( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.shows.find( query, limit=limit, offset=offset, market=market ) @@ -86,7 +83,7 @@ async def get_shows( ) -> None: """Fetch one or many shows in a single call.""" ids = _split_values(show_ids) - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await _gather_batches( lambda chunk: spotify.shows.get_many(chunk, market=market), ids, @@ -119,7 +116,7 @@ async def show_episodes( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.shows.episodes( show_id, market=market, limit=limit, offset=offset ) diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index ac3db8b..8b8055f 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -4,10 +4,8 @@ import typer -from ._aliases import AliasGroup from ._core import ( BATCH_TRACKS, - _coalesce_scopes, _gather_batches, _split_values, spotify_client, @@ -31,7 +29,6 @@ app = typer.Typer( help="Work with Spotify tracks.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -54,7 +51,7 @@ async def search_tracks( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.tracks.find( query, limit=limit, offset=offset, market=market ) @@ -85,7 +82,7 @@ async def get_tracks( ) -> None: """Fetch one or many tracks in a single call.""" ids = _split_values(track_ids) - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await _gather_batches( lambda chunk: spotify.tracks.get_many(chunk, market=market), ids, diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index 61bb553..57b3750 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -6,10 +6,8 @@ from spotifyify import SpotifyScope -from ._aliases import AliasGroup from ._core import ( BATCH_FOLLOW, - _coalesce_scopes, _merge_scopes, _sequential_batches, _split_values, @@ -31,7 +29,6 @@ app = typer.Typer( help="Work with Spotify users and following.", - cls=AliasGroup, rich_markup_mode=None, no_args_is_help=True, ) @@ -54,7 +51,7 @@ async def users_me( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.users.me() print_result( result, @@ -76,7 +73,7 @@ async def users_get( fields: FieldsOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope)) as spotify: + async with spotify_client(scope) as spotify: result = await spotify.users.get(user_id) print_result( result, @@ -102,7 +99,7 @@ async def users_following( where: WhereOption = None, scope: ScopeOption = None, ) -> None: - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: result = await spotify.users.following(type=type, limit=limit, after=after) print_result( result, @@ -128,7 +125,7 @@ async def users_follow( scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches( lambda chunk: spotify.users.follow(type, chunk), item_ids, @@ -161,7 +158,7 @@ async def users_unfollow( scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - async with spotify_client(_coalesce_scopes(scope) or WRITE_SCOPES) as spotify: + async with spotify_client(scope or WRITE_SCOPES) as spotify: await _sequential_batches( lambda chunk: spotify.users.unfollow(type, chunk), item_ids, @@ -194,7 +191,7 @@ async def users_check_following( scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - async with spotify_client(_coalesce_scopes(scope) or READ_SCOPES) as spotify: + async with spotify_client(scope or READ_SCOPES) as spotify: following_values = await spotify.users.check_following(type, item_ids) payload = [ {"id": item_id, "following": following} diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c16340..28f6ea7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -266,7 +266,7 @@ def test_ids_are_chunked_to_the_endpoint_limit(self): self.assertEqual(_chunked([], 2), []) -class TestAliasTolerance(unittest.TestCase): +class TestCommandNaming(unittest.TestCase): def setUp(self): if cli.typer is None: self.skipTest("typer is optional") @@ -278,46 +278,34 @@ def setUp(self): def _resolves(self, name): return self.root.get_command(self.ctx, name) - def test_singular_group_names_resolve_to_the_plural_group(self): - self.assertIsNotNone(self._resolves("artist")) - self.assertIsNotNone(self._resolves("track")) + def test_resource_groups_use_plural_names(self): + resource_groups = ( + "albums", + "artists", + "episodes", + "playlists", + "shows", + "tracks", + "users", + ) + for name in resource_groups: + self.assertIsNotNone(self._resolves(name)) - def test_canonical_names_still_resolve(self): - self.assertIsNotNone(self._resolves("artists")) + def test_singular_group_names_do_not_resolve(self): + self.assertIsNone(self._resolves("artist")) + self.assertIsNone(self._resolves("track")) def test_unknown_names_still_fail(self): self.assertIsNone(self._resolves("definitely-not-a-command")) - def test_get_many_is_accepted_as_get(self): + def test_get_is_the_only_bulk_fetch_command_name(self): import click artists = self._resolves("artists") - command = artists.get_command(click.Context(artists), "get-many") - - self.assertIsNotNone(command) - self.assertEqual(command.name, "get") - - -class TestAgentHelp(unittest.TestCase): - def setUp(self): - if cli.typer is None: - self.skipTest("typer is optional") - self.text = cli.agent_help(cli.typer.main.get_command(cli.app)) - - def test_every_group_appears(self): - for group in ("tracks", "artists", "albums", "playlists", "player", "library"): - self.assertIn(f"{group}: ", self.text) - - def test_the_output_contract_is_stated(self): - self.assertIn("JSON on stdout", self.text) - self.assertIn("--sort", self.text) - self.assertIn("Mutations print the state they produced", self.text) - - def test_variadic_arguments_are_marked(self): - self.assertIn("get TRACK_IDS...", self.text) + context = click.Context(artists) - def test_it_stays_small_enough_for_a_system_prompt(self): - self.assertLess(len(self.text), 4000) + self.assertIsNotNone(artists.get_command(context, "get")) + self.assertIsNone(artists.get_command(context, "get-many")) class FakeSpotifyify: @@ -571,12 +559,12 @@ def test_top_level_play_needs_something_to_search_for(self): self.assertNotEqual(result.exit_code, 0) - def test_unknown_command_names_the_alternatives(self): + def test_unknown_command_fails_normally(self): result = self.runner.invoke(cli.app, ["artists", "lookup"]) self.assertNotEqual(result.exit_code, 0) - self.assertIn("Available:", result.output) - self.assertIn("top-tracks", result.output) + self.assertIn("No such command", result.output) + self.assertNotIn("Available:", result.output) def test_output_carries_no_ansi_escapes(self): async def find(query, **kwargs): From 17e94744746621991dc517c7ab14bfe005e7cdb3 Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 12:19:35 +0200 Subject: [PATCH 06/12] Remove unused flags --- README.md | 62 +++--- spotifyify/cli/__init__.py | 39 +++- spotifyify/cli/albums.py | 82 +------- spotifyify/cli/artists.py | 91 ++------- spotifyify/cli/{_core.py => core.py} | 113 +++------- spotifyify/cli/episodes.py | 45 +--- spotifyify/cli/library.py | 227 +++------------------ spotifyify/cli/{_options.py => options.py} | 82 +------- spotifyify/cli/player.py | 192 +++-------------- spotifyify/cli/playlists.py | 147 ++----------- spotifyify/cli/quick.py | 30 +-- spotifyify/cli/shows.py | 64 +----- spotifyify/cli/tracks.py | 47 +---- spotifyify/cli/users.py | 72 +------ tests/test_cli.py | 97 ++------- 15 files changed, 251 insertions(+), 1139 deletions(-) rename spotifyify/cli/{_core.py => core.py} (77%) rename spotifyify/cli/{_options.py => options.py} (50%) diff --git a/README.md b/README.md index 5314fba..578f394 100644 --- a/README.md +++ b/README.md @@ -334,20 +334,18 @@ spotifyify --help ### Output contract -Every command writes JSON to stdout and nothing else. The format depends only on -arguments and environment — never on whether stdout is a terminal — so piping -through `tee` or capturing the output cannot change its shape. +Every command writes JSON to stdout and nothing else, regardless of whether +stdout is a terminal — so piping through `tee` or capturing the output cannot +change its shape. | | | | --- | --- | -| Format | JSON by default; `--format table` for aligned text, `--format json` to be explicit | +| Format | Always JSON | | Shape | A JSON array of row objects whose keys are the command's declared columns, in a fixed order | | Encoding | UTF-8, no ANSI escapes, no pager, no prompts | | Errors | Plain text on stderr | | Exit codes | `0` ok, `1` API error, `2` usage error, `3` auth error, `4` no match | -Set `SPOTIFYIFY_FORMAT=table` to change the default for an interactive shell. - ```bash spotifyify tracks search "Ikkimel" --limit 2 ``` @@ -364,9 +362,8 @@ spotifyify tracks search "Ikkimel" --limit 2 ] ``` -Both formats project the same fields, so `--format` changes the encoding and -nothing else. `--raw` opts out into the untouched Spotify payload when you need -paging metadata or a field that is not a declared column. +Set `SPOTIFYIFY_RAW=1` to get the untouched Spotify payload instead, for +debugging paging metadata or a field that is not a declared column. ### Command discovery @@ -388,7 +385,7 @@ The CLI mirrors the public namespace API from `spotifyify.namespaces`: ```bash spotifyify tracks search "Daft Punk" --limit 5 spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy -spotifyify playlists list --scope playlist-read-private +spotifyify playlists list ``` To find something and play it without a separate lookup: @@ -419,24 +416,19 @@ pass `--no-wait` to skip that and read immediately. Library and follow mutations report the resulting saved/following state, and playlist mutations report the new snapshot and length. -### Filtering and ordering +### Filtering ```bash spotifyify tracks search "Daft Punk" --limit 3 --field id,name,uri -spotifyify library saved-tracks --sort track.name -spotifyify library top-tracks --limit 50 --where "artists=Ikkimel" spotifyify playlists tracks PLAYLIST_ID --spotify-fields "items(track(id,name))" ``` | Option | Effect | | --- | --- | | `--field`, `-f` | Replace the declared columns with the given field paths | -| `--sort` | Stable re-sort by field path; prefix `-` for descending, repeat for tiebreakers | -| `--where` | Keep rows whose field path contains a value, case-insensitively | | `--spotify-fields` | Server-side filter applied by Spotify before it sends the response | -Rows otherwise keep the order Spotify returned them in. `--sort` is stable, so -ties keep that order and repeated calls are reproducible. +Rows otherwise keep the order Spotify returned them in. ### Batching @@ -456,24 +448,32 @@ spotifyify library save-tracks ID_1,ID_2,ID_3 | Option | Description | | ------ | ----------- | -| `--format` | `json` (default) or `table` | -| `--json` | Shorthand for `--format json` | -| `--raw` | Emit the untouched Spotify payload | | `--field`, `--fields`, `-f` | Include only selected field paths | -| `--sort` | Stable sort by field path | -| `--where` | Keep only matching rows | -| `--scope`, `-s` | Request OAuth scopes | | `--limit`, `-l` | Number of items to fetch, capped at Spotify's per-endpoint limits | -| `--offset`, `-o` | Result offset for paginated endpoints | -| `--market`, `-m` | ISO 3166-1 alpha-2 market code | -| `--device-id` | Target Spotify Connect device for playback commands | | `--wait` / `--no-wait` | Whether playback mutations wait for the change to take effect | -Most user-scoped commands set the matching default scope automatically. Override -or extend scopes with `--scope` when you need a different authorization grant. -When a command needs user authorization and no token is configured yet, the CLI -uses the same interactive Authorization Code login and token cache as the Python -client. +Each command already requests the OAuth scopes it needs — there is no way to +override that per call. When a command needs user authorization and no token +is configured yet, the CLI uses the same interactive Authorization Code login +and token cache as the Python client. + +### Global options + +`--market` and `--device-id` apply to the whole invocation, so they go before +the group name rather than on the individual command: + +```bash +spotifyify --market DE tracks search "Daft Punk" +spotifyify --device-id kitchen player play --uri spotify:track:TRACK_ID +``` + +| Option | Description | Env var fallback | +| ------ | ----------- | ----------------- | +| `--market`, `-m` | ISO 3166-1 alpha-2 market code | `SPOTIFYIFY_MARKET` | +| `--device-id` | Target Spotify Connect device for playback commands | `SPOTIFYIFY_DEVICE_ID` | + +A flag always wins over its env var. Neither is required — omit both and +Spotify falls back to its own default market and active device. ### Command overview diff --git a/spotifyify/cli/__init__.py b/spotifyify/cli/__init__.py index 1480307..2436e4c 100644 --- a/spotifyify/cli/__init__.py +++ b/spotifyify/cli/__init__.py @@ -1,46 +1,39 @@ from __future__ import annotations import sys +from typing import Annotated try: import typer except ImportError: # pragma: no cover - exercised by installed package users. typer = None -from ._core import ( - FORMATS, +from .core import ( INSTALL_MESSAGE, _apply_sort, - _apply_where, _cell, _filter_fields, _get_path, - _parse_scopes, _playback_summary, - _resolve_format, _rows, + _set_default_device_id, + _set_default_market, _sort_items, _split_values, - _table, ) __all__ = [ - "FORMATS", "INSTALL_MESSAGE", "main", "typer", "_apply_sort", - "_apply_where", "_cell", "_filter_fields", "_get_path", - "_parse_scopes", "_playback_summary", - "_resolve_format", "_rows", "_sort_items", "_split_values", - "_table", ] @@ -101,5 +94,29 @@ def main() -> None: app.add_typer(users.app, name="users") quick.register(app) + @app.callback(invoke_without_command=True) + def _root( + market: Annotated[ + str | None, + typer.Option( + "--market", + "-m", + help="Default ISO 3166-1 alpha-2 market code for every command " + "in this invocation. Falls back to SPOTIFYIFY_MARKET.", + ), + ] = None, + device_id: Annotated[ + str | None, + typer.Option( + "--device-id", + help="Default Spotify Connect device for every playback command " + "in this invocation. Falls back to SPOTIFYIFY_DEVICE_ID.", + ), + ] = None, + ) -> None: + _set_default_market(market) + _set_default_device_id(device_id) + + if __name__ == "__main__": main() diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index c48c692..e581a53 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -4,25 +4,18 @@ import typer -from ._core import ( +from .core import ( BATCH_ALBUMS, + _default_market, _gather_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( DEFAULT_LIMIT, FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -41,29 +34,14 @@ async def search_albums( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.albums.find( - query, limit=limit, offset=offset, market=market - ) + async with spotify_client() as spotify: + result = await spotify.albums.find(query, limit=limit, market=_default_market()) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -71,18 +49,12 @@ async def search_albums( @async_command async def get_albums( album_ids: IdsArgument, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: """Fetch one or many albums in a single call.""" ids = _split_values(album_ids) - async with spotify_client(scope) as spotify: + market = _default_market() + async with spotify_client() as spotify: result = await _gather_batches( lambda chunk: spotify.albums.get_many(chunk, market=market), ids, @@ -91,12 +63,7 @@ async def get_albums( print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -105,29 +72,16 @@ async def get_albums( async def album_tracks( album_id: Annotated[str, typer.Argument(help="Spotify album ID.")], limit: LimitOption = 50, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.albums.tracks( - album_id, limit=limit, offset=offset, market=market + album_id, limit=limit, market=_default_market() ) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -136,26 +90,12 @@ async def album_tracks( async def new_releases( country: Annotated[str | None, typer.Option("--country", "-c")] = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.albums.new_releases( - country=country, limit=limit, offset=offset - ) + async with spotify_client() as spotify: + result = await spotify.albums.new_releases(country=country, limit=limit) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index 3953cf1..2893473 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -4,25 +4,18 @@ import typer -from ._core import ( +from .core import ( BATCH_ARTISTS, + _default_market, _gather_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( DEFAULT_LIMIT, FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -43,26 +36,14 @@ async def search_artists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.artists.find(query, limit=limit, offset=offset) + async with spotify_client() as spotify: + result = await spotify.artists.find(query, limit=limit) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -70,27 +51,16 @@ async def search_artists( @async_command async def get_artists( artist_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: """Fetch one or many artists in a single call.""" ids = _split_values(artist_ids) - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await _gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -98,26 +68,16 @@ async def get_artists( @async_command async def artist_top_tracks( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], - market: Annotated[str, typer.Option("--market", "-m")] = "US", - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.artists.top_tracks(artist_id, market=market) + async with spotify_client() as spotify: + result = await spotify.artists.top_tracks( + artist_id, market=_default_market() or "US" + ) print_result( result, columns=TRACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -129,34 +89,20 @@ async def artist_albums( str | None, typer.Option("--include-groups", help="album,single,appears_on,compilation"), ] = None, - market: MarketOption = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.artists.albums( artist_id, include_groups=include_groups, - market=market, + market=_default_market(), limit=limit, - offset=offset, ) print_result( result, columns=ALBUM_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -164,23 +110,12 @@ async def artist_albums( @async_command async def related_artists( artist_id: Annotated[str, typer.Argument(help="Spotify artist ID.")], - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.artists.related(artist_id) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) diff --git a/spotifyify/cli/_core.py b/spotifyify/cli/core.py similarity index 77% rename from spotifyify/cli/_core.py rename to spotifyify/cli/core.py index 590d1ac..269164e 100644 --- a/spotifyify/cli/_core.py +++ b/spotifyify/cli/core.py @@ -22,10 +22,12 @@ DEFAULT_LIMIT = 10 INSTALL_MESSAGE = "Install the CLI dependencies with: uv add spotifyify[cli]" -FORMAT_JSON = "json" -FORMAT_TABLE = "table" -FORMATS = (FORMAT_JSON, FORMAT_TABLE) -FORMAT_ENV_VAR = "SPOTIFYIFY_FORMAT" +RAW_ENV_VAR = "SPOTIFYIFY_RAW" +MARKET_ENV_VAR = "SPOTIFYIFY_MARKET" +DEVICE_ENV_VAR = "SPOTIFYIFY_DEVICE_ID" + +_market_override: str | None = None +_device_override: str | None = None # Table cells are single-line by construction, so anything that could forge a # row boundary or smuggle escape sequences is folded into a single space. @@ -51,18 +53,8 @@ def _split_values(values: Sequence[str] | None) -> list[str]: return items -def _parse_scopes(scope_values: Sequence[str] | None) -> list[SpotifyScope | str]: - scopes: list[SpotifyScope | str] = [] - for value in _split_values(scope_values): - try: - scopes.append(SpotifyScope(value)) - except ValueError: - scopes.append(value) - return scopes - - -def _merge_scopes(*scope_groups: Sequence[str]) -> list[str]: - merged: list[str] = [] +def _merge_scopes(*scope_groups: Sequence[SpotifyScope]) -> list[SpotifyScope]: + merged: list[SpotifyScope] = [] for group in scope_groups: merged.extend(scope for scope in group if scope not in merged) return merged @@ -221,77 +213,30 @@ def _apply_sort(value: Any, specs: Sequence[str]) -> Any: ) -def _parse_where(specs: Sequence[str] | None) -> list[tuple[str, str]]: - predicates: list[tuple[str, str]] = [] - for spec in specs or (): - path, separator, expected = spec.partition("=") - if not separator or not path: - message = f"--where must look like PATH=VALUE, got {spec!r}" - if typer is None: - raise ValueError(message) - raise typer.BadParameter(message) - predicates.append((path, expected.casefold())) - return predicates - - -def _matches(item: Any, predicates: Sequence[tuple[str, str]]) -> bool: - return all( - expected in _cell(_get_path(item, path)).casefold() - for path, expected in predicates - ) +def _set_default_market(value: str | None) -> None: + """Record the --market value from the root command, for this process only.""" + global _market_override + _market_override = value -def _apply_where(value: Any, specs: Sequence[str] | None) -> Any: - """Drop rows the caller does not want before they reach the output.""" - predicates = _parse_where(specs) - if not predicates: - return value - return _replace_collection( - _as_jsonable(value), - lambda items: [item for item in items if _matches(item, predicates)], - ) +def _set_default_device_id(value: str | None) -> None: + """Record the --device-id value from the root command, for this process only.""" + global _device_override + _device_override = value -def _resolve_format(fmt: str | None, *, json_output: bool = False) -> str: - """Explicit --format wins, then --json, then the environment, then JSON. +def _default_market() -> str | None: + """Root --market flag, then the environment, then no market at all.""" + return _market_override or os.environ.get(MARKET_ENV_VAR) or None - Deliberately not switched on isatty(): the shape of a command's output - depends only on its arguments and environment, so piping through `tee` or - capturing stdout cannot silently change it. - """ - if fmt: - value = fmt.strip().lower() - if value not in FORMATS: - message = f"--format must be one of: {', '.join(FORMATS)}" - if typer is None: - raise ValueError(message) - raise typer.BadParameter(message) - return value - if json_output: - return FORMAT_JSON - from_env = (os.environ.get(FORMAT_ENV_VAR) or "").strip().lower() - if from_env in FORMATS: - return from_env - return FORMAT_JSON +def _default_device_id() -> str | None: + """Root --device-id flag, then the environment, then no device at all.""" + return _device_override or os.environ.get(DEVICE_ENV_VAR) or None -def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: - if not rows: - return "No results." - widths = [ - max(len(header), *(len(row[index]) for row in rows)) - for index, header in enumerate(headers) - ] - rendered = [ - " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) - ] - rendered.append(" ".join("-" * width for width in widths)) - rendered.extend( - " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) - for row in rows - ) - return "\n".join(rendered) +def _is_raw_output() -> bool: + return os.environ.get(RAW_ENV_VAR) == "1" def _echo(text: str) -> None: @@ -305,18 +250,12 @@ def _print_json(value: Any) -> None: _echo(json.dumps(_as_jsonable(value), indent=2, ensure_ascii=False)) -def _print_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> None: - headers = [column.replace(".", " ").title() for column in columns] - cells = [[_cell(row.get(column)) for column in columns] for row in rows] - _echo(_table(headers, cells)) - - @asynccontextmanager async def spotify_client( - scopes: Sequence[str] | None, + scopes: Sequence[SpotifyScope] = (), ) -> AsyncGenerator[Spotifyify, None]: """Open the async Spotify client used by one explicit CLI command.""" - async with Spotifyify(scopes=_parse_scopes(scopes)) as spotify: + async with Spotifyify(scopes=scopes) as spotify: yield spotify diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index c27baf5..9b99175 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -4,25 +4,18 @@ import typer -from ._core import ( +from .core import ( BATCH_EPISODES, + _default_market, _gather_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( DEFAULT_LIMIT, FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -42,29 +35,16 @@ async def search_episodes( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.episodes.find( - query, limit=limit, offset=offset, market=market + query, limit=limit, market=_default_market() ) print_result( result, columns=SEARCH_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -72,18 +52,12 @@ async def search_episodes( @async_command async def get_episodes( episode_ids: IdsArgument, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: """Fetch one or many episodes in a single call.""" ids = _split_values(episode_ids) - async with spotify_client(scope) as spotify: + market = _default_market() + async with spotify_client() as spotify: result = await _gather_batches( lambda chunk: spotify.episodes.get_many(chunk, market=market), ids, @@ -92,10 +66,5 @@ async def get_episodes( print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 5fd11e1..8f5f4a6 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -6,28 +6,21 @@ from spotifyify import SpotifyScope -from ._core import ( +from .core import ( BATCH_ALBUMS, BATCH_EPISODES, BATCH_SHOWS, BATCH_TRACKS, + _default_market, _merge_scopes, _sequential_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -40,8 +33,8 @@ SAVED_COLUMNS = ("id", "saved") -READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ.value] -MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY.value] +READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ] +MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY] # Save/remove report the resulting saved state, so they read it back too. WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) @@ -50,29 +43,16 @@ @async_command async def saved_tracks( limit: LimitOption = 20, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: result = await spotify.library.saved_tracks( - limit=limit, offset=offset, market=market + limit=limit, market=_default_market() ) print_result( result, columns=("track.id", "track.name", "track.artists", "added_at"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -80,29 +60,16 @@ async def saved_tracks( @async_command async def saved_albums( limit: LimitOption = 20, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: result = await spotify.library.saved_albums( - limit=limit, offset=offset, market=market + limit=limit, market=_default_market() ) print_result( result, columns=("album.id", "album.name", "album.artists", "added_at"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -110,26 +77,14 @@ async def saved_albums( @async_command async def saved_shows( limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: - result = await spotify.library.saved_shows(limit=limit, offset=offset) + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.library.saved_shows(limit=limit) print_result( result, columns=("show.id", "show.name", "show.publisher", "added_at"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -137,26 +92,14 @@ async def saved_shows( @async_command async def saved_episodes( limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: - result = await spotify.library.saved_episodes(limit=limit, offset=offset) + async with spotify_client(READ_SCOPES) as spotify: + result = await spotify.library.saved_episodes(limit=limit) print_result( result, columns=("episode.id", "episode.name", "added_at"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -165,28 +108,14 @@ async def saved_episodes( async def library_top_tracks( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or [SpotifyScope.USER_TOP_READ.value]) as spotify: - result = await spotify.library.top_tracks( - time_range=time_range, limit=limit, offset=offset - ) + async with spotify_client([SpotifyScope.USER_TOP_READ]) as spotify: + result = await spotify.library.top_tracks(time_range=time_range, limit=limit) print_result( result, columns=("id", "name", "artists", "uri"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -195,28 +124,14 @@ async def library_top_tracks( async def library_top_artists( time_range: Annotated[str, typer.Option("--time-range")] = "medium_term", limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or [SpotifyScope.USER_TOP_READ.value]) as spotify: - result = await spotify.library.top_artists( - time_range=time_range, limit=limit, offset=offset - ) + async with spotify_client([SpotifyScope.USER_TOP_READ]) as spotify: + result = await spotify.library.top_artists(time_range=time_range, limit=limit) print_result( result, columns=("id", "name", "uri"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -224,14 +139,10 @@ async def library_top_artists( @async_command async def save_tracks( track_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(track_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_tracks, ids, BATCH_TRACKS) saved = await spotify.library.check_tracks(ids) result = [ @@ -241,9 +152,6 @@ async def save_tracks( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -252,14 +160,10 @@ async def save_tracks( @async_command async def remove_tracks( track_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(track_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_tracks, ids, BATCH_TRACKS) saved = await spotify.library.check_tracks(ids) result = [ @@ -269,9 +173,6 @@ async def remove_tracks( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -280,14 +181,10 @@ async def remove_tracks( @async_command async def check_tracks( track_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(track_ids) - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_tracks(ids) result = [ {"id": item_id, "saved": is_saved} @@ -296,9 +193,6 @@ async def check_tracks( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -307,14 +201,10 @@ async def check_tracks( @async_command async def save_albums( album_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(album_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_albums, ids, BATCH_ALBUMS) saved = await spotify.library.check_albums(ids) result = [ @@ -324,9 +214,6 @@ async def save_albums( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -335,14 +222,10 @@ async def save_albums( @async_command async def remove_albums( album_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(album_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_albums, ids, BATCH_ALBUMS) saved = await spotify.library.check_albums(ids) result = [ @@ -352,9 +235,6 @@ async def remove_albums( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -363,14 +243,10 @@ async def remove_albums( @async_command async def check_albums( album_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(album_ids) - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_albums(ids) result = [ {"id": item_id, "saved": is_saved} @@ -379,9 +255,6 @@ async def check_albums( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -390,14 +263,10 @@ async def check_albums( @async_command async def save_shows( show_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(show_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_shows, ids, BATCH_SHOWS) saved = await spotify.library.check_shows(ids) result = [ @@ -407,9 +276,6 @@ async def save_shows( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -418,14 +284,10 @@ async def save_shows( @async_command async def remove_shows( show_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(show_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_shows, ids, BATCH_SHOWS) saved = await spotify.library.check_shows(ids) result = [ @@ -435,9 +297,6 @@ async def remove_shows( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -446,14 +305,10 @@ async def remove_shows( @async_command async def check_shows( show_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(show_ids) - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_shows(ids) result = [ {"id": item_id, "saved": is_saved} @@ -462,9 +317,6 @@ async def check_shows( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -473,14 +325,10 @@ async def check_shows( @async_command async def save_episodes( episode_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(episode_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.save_episodes, ids, BATCH_EPISODES) saved = await spotify.library.check_episodes(ids) result = [ @@ -490,9 +338,6 @@ async def save_episodes( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -501,14 +346,10 @@ async def save_episodes( @async_command async def remove_episodes( episode_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(episode_ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches(spotify.library.remove_episodes, ids, BATCH_EPISODES) saved = await spotify.library.check_episodes(ids) result = [ @@ -518,9 +359,6 @@ async def remove_episodes( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -529,14 +367,10 @@ async def remove_episodes( @async_command async def check_episodes( episode_ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: ids = _split_values(episode_ids) - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_episodes(ids) result = [ {"id": item_id, "saved": is_saved} @@ -545,8 +379,5 @@ async def check_episodes( print_result( result, columns=SAVED_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) diff --git a/spotifyify/cli/_options.py b/spotifyify/cli/options.py similarity index 50% rename from spotifyify/cli/_options.py rename to spotifyify/cli/options.py index 6c4044f..271125e 100644 --- a/spotifyify/cli/_options.py +++ b/spotifyify/cli/options.py @@ -10,16 +10,11 @@ from spotifyify import SpotifyAPIError, SpotifyAuthError -from ._core import ( - FORMAT_JSON, - FORMATS, +from .core import ( Jsonable, _as_jsonable, - _apply_sort, - _apply_where, + _is_raw_output, _print_json, - _print_table, - _resolve_format, _rows, _split_values, ) @@ -29,32 +24,6 @@ EXIT_API_ERROR = 1 EXIT_AUTH_ERROR = 3 -ScopeOption = Annotated[ - list[str] | None, - typer.Option( - "--scope", - "-s", - help="OAuth scope. Can be repeated or comma-separated.", - ), -] -FormatOption = Annotated[ - str | None, - typer.Option( - "--format", - help=f"Output format: {'|'.join(FORMATS)}. Defaults to json.", - ), -] -JsonOption = Annotated[ - bool, - typer.Option("--json", help="Shorthand for --format json."), -] -RawOption = Annotated[ - bool, - typer.Option( - "--raw", - help="Emit the untouched Spotify payload instead of the declared columns.", - ), -] FieldsOption = Annotated[ list[str] | None, typer.Option( @@ -64,36 +33,10 @@ help="Field path to include. Can be repeated or comma-separated.", ), ] -SortOption = Annotated[ - list[str] | None, - typer.Option( - "--sort", - help="Field path to sort by; prefix with '-' for descending. Stable, repeatable.", - ), -] -WhereOption = Annotated[ - list[str] | None, - typer.Option( - "--where", - help="Keep rows where PATH contains VALUE (case-insensitive). Repeatable, AND-ed.", - ), -] LimitOption = Annotated[ int, typer.Option("--limit", "-l", min=1, max=50, help="Number of items to fetch."), ] -OffsetOption = Annotated[ - int, - typer.Option("--offset", "-o", min=0, help="Result offset."), -] -MarketOption = Annotated[ - str | None, - typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 market code."), -] -DeviceOption = Annotated[ - str | None, - typer.Option("--device-id", help="Spotify device ID."), -] WaitOption = Annotated[ bool, typer.Option( @@ -135,28 +78,17 @@ def print_result( result: Jsonable, *, columns: Sequence[str], - fmt: str | None = None, - json_output: bool = False, - raw: bool = False, fields: Sequence[str] | None = None, - sort: Sequence[str] | None = None, - where: Sequence[str] | None = None, project: Callable[[Any], Any] | None = None, ) -> None: - """Emit the command's declared columns, in order, in the chosen format. + """Emit the command's declared columns, in order, as JSON. - Both formats project the same fixed set of fields, so switching format - changes the encoding and nothing else. --raw opts out into the untouched - Spotify payload. + SPOTIFYIFY_RAW=1 opts out into the untouched Spotify payload, for + debugging fields that are not part of any command's declared columns. """ - if raw: + if _is_raw_output(): _print_json(_as_jsonable(result)) return payload = project(result) if project is not None else result - payload = _apply_sort(_apply_where(payload, where), _split_values(sort)) selected_columns = _split_values(fields) or list(columns) - rows = _rows(payload, selected_columns) - if _resolve_format(fmt, json_output=json_output) == FORMAT_JSON: - _print_json(rows) - return - _print_table(rows, selected_columns) + _print_json(_rows(payload, selected_columns)) diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index 4f89d73..a853ca8 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -8,8 +8,11 @@ from spotifyify.exceptions import SpotifyAPIError from spotifyify.schemas import Device -from ._core import ( +from .core import ( PLAYBACK_COLUMNS, + _default_device_id, + _default_market, + _is_raw_output, _merge_scopes, _parse_json_object, _playback_summary, @@ -22,18 +25,11 @@ plays_uri, spotify_client, ) -from ._options import ( - DeviceOption, +from .options import ( FieldsOption, - FormatOption, - JsonOption, LimitOption, - RawOption, - ScopeOption, - SortOption, UrisArgument, WaitOption, - WhereOption, async_command, print_result, ) @@ -44,8 +40,8 @@ no_args_is_help=True, ) -READ_SCOPES = [SpotifyScope.USER_READ_PLAYBACK_STATE.value] -MODIFY_SCOPES = [SpotifyScope.USER_MODIFY_PLAYBACK_STATE.value] +READ_SCOPES = [SpotifyScope.USER_READ_PLAYBACK_STATE] +MODIFY_SCOPES = [SpotifyScope.USER_MODIFY_PLAYBACK_STATE] # Playback mutations report the state they produced, so they need the read # scope too — otherwise the caller pays a second round trip to learn what # actually happened. @@ -101,24 +97,13 @@ async def _play_with_device_fallback( @app.command("state") @async_command async def player_state( - market: Annotated[ - str | None, - typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 market code."), - ] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: - state = await spotify.player.state(market=market) + async with spotify_client(READ_SCOPES) as spotify: + state = await spotify.player.state(market=_default_market()) print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -127,17 +112,12 @@ async def player_state( @app.command("play") @async_command async def player_play( - device_id: DeviceOption = None, context_uri: Annotated[str | None, typer.Option("--context-uri")] = None, uri: Annotated[list[str] | None, typer.Option("--uri")] = None, offset: Annotated[str | None, typer.Option("--offset-json")] = None, position_ms: Annotated[int | None, typer.Option("--position-ms", min=0)] = None, wait: WaitOption = True, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: uris = _split_values(uri) or None if uris: @@ -147,10 +127,10 @@ async def player_play( until = is_fresh_track else: until = is_playing - async with spotify_client(scope or CONTROL_SCOPES) as spotify: + async with spotify_client(CONTROL_SCOPES) as spotify: await _play_with_device_fallback( spotify, - device_id=device_id, + device_id=_default_device_id(), context_uri=context_uri, uris=uris, offset=_parse_json_object(offset, "--offset-json"), @@ -160,9 +140,6 @@ async def player_play( print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -171,23 +148,15 @@ async def player_play( @app.command("pause") @async_command async def player_pause( - device_id: DeviceOption = None, wait: WaitOption = True, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.pause(device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.pause(device_id=_default_device_id()) state = await _settled_playback(spotify, until=is_paused, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -196,23 +165,15 @@ async def player_pause( @app.command("skip") @async_command async def player_skip( - device_id: DeviceOption = None, wait: WaitOption = True, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.skip(device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.skip(device_id=_default_device_id()) state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -221,23 +182,15 @@ async def player_skip( @app.command("previous") @async_command async def player_previous( - device_id: DeviceOption = None, wait: WaitOption = True, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.previous(device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.previous(device_id=_default_device_id()) state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -247,22 +200,14 @@ async def player_previous( @async_command async def player_seek( position_ms: Annotated[int, typer.Argument(min=0)], - device_id: DeviceOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.seek(position_ms, device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.seek(position_ms, device_id=_default_device_id()) state = await _settled_playback(spotify) print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -272,22 +217,14 @@ async def player_seek( @async_command async def player_repeat( state: Annotated[str, typer.Argument(help="track, context, or off")], - device_id: DeviceOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.repeat(state, device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.repeat(state, device_id=_default_device_id()) playback = await _settled_playback(spotify) print_result( playback, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -297,22 +234,14 @@ async def player_repeat( @async_command async def player_shuffle( state: Annotated[bool, typer.Argument()], - device_id: DeviceOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.shuffle(state, device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.shuffle(state, device_id=_default_device_id()) playback = await _settled_playback(spotify) print_result( playback, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -322,22 +251,14 @@ async def player_shuffle( @async_command async def player_volume( volume_percent: Annotated[int, typer.Argument(min=0, max=100)], - device_id: DeviceOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: - await spotify.player.volume(volume_percent, device_id=device_id) + async with spotify_client(CONTROL_SCOPES) as spotify: + await spotify.player.volume(volume_percent, device_id=_default_device_id()) state = await _settled_playback(spotify) print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -346,25 +267,14 @@ async def player_volume( @app.command("queue") @async_command async def player_queue( - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: result = await spotify.player.queue() print_result( result, columns=("id", "name", "artists", "uri"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -372,17 +282,13 @@ async def player_queue( @async_command async def add_to_queue( uris: UrisArgument, - device_id: DeviceOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: """Queue one or many tracks or episodes in a single call.""" queued = _split_values(uris) + device_id = _default_device_id() - async with spotify_client(scope or CONTROL_SCOPES) as spotify: + async with spotify_client(CONTROL_SCOPES) as spotify: # Spotify has no bulk queue endpoint and the queue is ordered, so these # must stay sequential. for uri in queued: @@ -391,9 +297,6 @@ async def add_to_queue( print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -405,13 +308,9 @@ async def transfer_playback( device_id: Annotated[str, typer.Argument(help="Spotify device ID.")], play: Annotated[bool, typer.Option("--play/--no-play")] = False, wait: WaitOption = True, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or CONTROL_SCOPES) as spotify: + async with spotify_client(CONTROL_SCOPES) as spotify: await spotify.player.transfer(device_id, play=play) state = await _settled_playback( spotify, @@ -421,9 +320,6 @@ async def transfer_playback( print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) @@ -432,28 +328,17 @@ async def transfer_playback( @app.command("devices") @async_command async def player_devices( - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: result = await spotify.player.devices() # Spotify returns devices in no defined order; sort so repeated calls and # any caching built on top of them stay reproducible. - display_result = result if raw else _sort_items(result or [], ("id",)) + display_result = result if _is_raw_output() else _sort_items(result or [], ("id",)) print_result( display_result, columns=("id", "name", "type", "is_active", "volume_percent"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -463,27 +348,14 @@ async def recently_played( limit: LimitOption = 20, after: Annotated[int | None, typer.Option("--after")] = None, before: Annotated[int | None, typer.Option("--before")] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client( - scope or [SpotifyScope.USER_READ_RECENTLY_PLAYED.value] - ) as spotify: + async with spotify_client([SpotifyScope.USER_READ_RECENTLY_PLAYED]) as spotify: result = await spotify.player.recently_played( limit=limit, after=after, before=before ) print_result( result, columns=("track.id", "track.name", "track.artists", "played_at"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index 3f37c23..8d7516e 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -6,20 +6,12 @@ from spotifyify import SpotifyScope -from ._core import _split_values, spotify_client -from ._options import ( +from .core import _default_market, _split_values, spotify_client +from .options import ( DEFAULT_LIMIT, FieldsOption, - FormatOption, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, UrisArgument, - WhereOption, async_command, print_result, ) @@ -34,8 +26,8 @@ SNAPSHOT_COLUMNS = ("playlist_id", "snapshot_id", "total") _MODIFY_SCOPES = [ - SpotifyScope.PLAYLIST_MODIFY_PUBLIC.value, - SpotifyScope.PLAYLIST_MODIFY_PRIVATE.value, + SpotifyScope.PLAYLIST_MODIFY_PUBLIC, + SpotifyScope.PLAYLIST_MODIFY_PRIVATE, ] @@ -44,26 +36,14 @@ async def search_playlists( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.playlists.find(query, limit=limit, offset=offset) + async with spotify_client() as spotify: + result = await spotify.playlists.find(query, limit=limit) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -71,21 +51,13 @@ async def search_playlists( @async_command async def get_playlist( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.playlists.get(playlist_id, market=market) + async with spotify_client() as spotify: + result = await spotify.playlists.get(playlist_id, market=_default_market()) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -95,30 +67,14 @@ async def get_playlist( async def list_playlists( user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client( - scope or [SpotifyScope.PLAYLIST_READ_PRIVATE.value] - ) as spotify: - result = await spotify.playlists.list( - user_id=user_id, limit=limit, offset=offset - ) + async with spotify_client([SpotifyScope.PLAYLIST_READ_PRIVATE]) as spotify: + result = await spotify.playlists.list(user_id=user_id, limit=limit) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -126,7 +82,6 @@ async def list_playlists( @async_command async def playlist_tracks( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], - market: MarketOption = None, fields_query: Annotated[ str | None, typer.Option( @@ -135,36 +90,23 @@ async def playlist_tracks( ), ] = None, limit: LimitOption = 20, - offset: OffsetOption = 0, additional_types: Annotated[ list[str] | None, typer.Option("--additional-type") ] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.playlists.tracks( playlist_id, - market=market, + market=_default_market(), fields=fields_query, limit=limit, - offset=offset, additional_types=_split_values(additional_types) or None, ) print_result( result, columns=("track.id", "track.name", "track.artists", "added_at"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -178,13 +120,9 @@ async def create_playlist( ] = False, description: Annotated[str, typer.Option("--description", "-d")] = "", user_id: Annotated[str | None, typer.Option("--user-id", "-u")] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or _MODIFY_SCOPES) as spotify: + async with spotify_client(_MODIFY_SCOPES) as spotify: result = await spotify.playlists.create( name, public=public, @@ -195,9 +133,6 @@ async def create_playlist( print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -212,13 +147,9 @@ async def update_playlist( bool | None, typer.Option("--collaborative/--not-collaborative") ] = None, description: Annotated[str | None, typer.Option("--description", "-d")] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or _MODIFY_SCOPES) as spotify: + async with spotify_client(_MODIFY_SCOPES) as spotify: await spotify.playlists.update( playlist_id, name=name, @@ -230,9 +161,6 @@ async def update_playlist( print_result( result, columns=("id", "name", "public", "collaborative", "description"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -243,14 +171,10 @@ async def add_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, position: Annotated[int | None, typer.Option("--position")] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: """Add one or many URIs in a single call.""" - async with spotify_client(scope or _MODIFY_SCOPES) as spotify: + async with spotify_client(_MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.add( playlist_id, _split_values(uris), position=position ) @@ -264,9 +188,6 @@ async def add_playlist_items( print_result( result, columns=SNAPSHOT_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -276,13 +197,9 @@ async def add_playlist_items( async def replace_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or _MODIFY_SCOPES) as spotify: + async with spotify_client(_MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.replace(playlist_id, _split_values(uris)) playlist = await spotify.playlists.get(playlist_id) result = { @@ -294,9 +211,6 @@ async def replace_playlist_items( print_result( result, columns=SNAPSHOT_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -306,13 +220,9 @@ async def replace_playlist_items( async def remove_playlist_items( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], uris: UrisArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or _MODIFY_SCOPES) as spotify: + async with spotify_client(_MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.remove(playlist_id, _split_values(uris)) playlist = await spotify.playlists.get(playlist_id) result = { @@ -324,9 +234,6 @@ async def remove_playlist_items( print_result( result, columns=SNAPSHOT_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -339,13 +246,9 @@ async def reorder_playlist_items( insert_before: Annotated[int, typer.Option("--insert-before", min=0)], range_length: Annotated[int, typer.Option("--range-length", min=1)] = 1, snapshot_id: Annotated[str | None, typer.Option("--snapshot-id")] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or _MODIFY_SCOPES) as spotify: + async with spotify_client(_MODIFY_SCOPES) as spotify: new_snapshot_id = await spotify.playlists.reorder( playlist_id, range_start=range_start, @@ -363,9 +266,6 @@ async def reorder_playlist_items( print_result( result, columns=SNAPSHOT_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -374,21 +274,12 @@ async def reorder_playlist_items( @async_command async def playlist_cover_image( playlist_id: Annotated[str, typer.Argument(help="Spotify playlist ID.")], - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.playlists.cover_image(playlist_id) print_result( result, columns=("url", "width", "height"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, ) diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py index 6611899..c3afc59 100644 --- a/spotifyify/cli/quick.py +++ b/spotifyify/cli/quick.py @@ -4,21 +4,18 @@ import typer -from ._core import ( +from .core import ( PLAYBACK_COLUMNS, + _default_device_id, + _default_market, _playback_summary, _settled_playback, is_fresh_track, plays_uri, spotify_client, ) -from ._options import ( - DeviceOption, +from .options import ( FieldsOption, - FormatOption, - JsonOption, - RawOption, - ScopeOption, WaitOption, async_command, print_result, @@ -76,16 +73,8 @@ async def play( album: Annotated[ str | None, typer.Option("--album", help="Album name to match.") ] = None, - market: Annotated[ - str | None, typer.Option("--market", "-m", help="ISO 3166-1 alpha-2 code.") - ] = None, - device_id: DeviceOption = None, wait: WaitOption = True, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: """Find something and play it in one call. @@ -106,8 +95,9 @@ async def play( # Otherwise the broadest given filter becomes the playback context. wants_track = bool(track or words) wants_album = bool(album) and not wants_track + market = _default_market() - async with spotify_client(scope or CONTROL_SCOPES) as spotify: + async with spotify_client(CONTROL_SCOPES) as spotify: if wants_track: hit = _first(await spotify.tracks.find(query, limit=1, market=market)) uris, context_uri = ([hit.uri] if hit else None), None @@ -123,7 +113,10 @@ async def play( raise typer.Exit(EXIT_NO_MATCH) await _play_with_device_fallback( - spotify, device_id=device_id, uris=uris, context_uri=context_uri + spotify, + device_id=_default_device_id(), + uris=uris, + context_uri=context_uri, ) # Wait for the thing we resolved, so the reported state is not the # track that happened to be playing already. @@ -133,9 +126,6 @@ async def play( print_result( state, columns=PLAYBACK_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, project=_playback_summary, ) diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index 0fb90d3..ab6dc31 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -4,25 +4,18 @@ import typer -from ._core import ( +from .core import ( BATCH_SHOWS, + _default_market, _gather_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( DEFAULT_LIMIT, FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -42,29 +35,14 @@ async def search_shows( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.shows.find( - query, limit=limit, offset=offset, market=market - ) + async with spotify_client() as spotify: + result = await spotify.shows.find(query, limit=limit, market=_default_market()) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -72,18 +50,12 @@ async def search_shows( @async_command async def get_shows( show_ids: IdsArgument, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: """Fetch one or many shows in a single call.""" ids = _split_values(show_ids) - async with spotify_client(scope) as spotify: + market = _default_market() + async with spotify_client() as spotify: result = await _gather_batches( lambda chunk: spotify.shows.get_many(chunk, market=market), ids, @@ -92,12 +64,7 @@ async def get_shows( print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -105,28 +72,15 @@ async def get_shows( @async_command async def show_episodes( show_id: Annotated[str, typer.Argument(help="Spotify show ID.")], - market: MarketOption = None, limit: LimitOption = 20, - offset: OffsetOption = 0, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.shows.episodes( - show_id, market=market, limit=limit, offset=offset + show_id, market=_default_market(), limit=limit ) print_result( result, columns=EPISODE_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index 8b8055f..3e077e6 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -4,25 +4,18 @@ import typer -from ._core import ( +from .core import ( BATCH_TRACKS, + _default_market, _gather_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( DEFAULT_LIMIT, FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - MarketOption, - OffsetOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -41,29 +34,14 @@ async def search_tracks( query: Annotated[str, typer.Argument(help="Spotify search query.")], limit: LimitOption = DEFAULT_LIMIT, - offset: OffsetOption = 0, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: - result = await spotify.tracks.find( - query, limit=limit, offset=offset, market=market - ) + async with spotify_client() as spotify: + result = await spotify.tracks.find(query, limit=limit, market=_default_market()) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -71,18 +49,12 @@ async def search_tracks( @async_command async def get_tracks( track_ids: IdsArgument, - market: MarketOption = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: """Fetch one or many tracks in a single call.""" ids = _split_values(track_ids) - async with spotify_client(scope) as spotify: + market = _default_market() + async with spotify_client() as spotify: result = await _gather_batches( lambda chunk: spotify.tracks.get_many(chunk, market=market), ids, @@ -91,10 +63,5 @@ async def get_tracks( print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index 57b3750..92435b3 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -6,23 +6,17 @@ from spotifyify import SpotifyScope -from ._core import ( +from .core import ( BATCH_FOLLOW, _merge_scopes, _sequential_batches, _split_values, spotify_client, ) -from ._options import ( +from .options import ( FieldsOption, - FormatOption, IdsArgument, - JsonOption, LimitOption, - RawOption, - ScopeOption, - SortOption, - WhereOption, async_command, print_result, ) @@ -36,8 +30,8 @@ COLUMNS = ("id", "display_name", "uri") FOLLOWING_COLUMNS = ("id", "following") -READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ.value] -MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY.value] +READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ] +MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY] # follow/unfollow report the resulting state, so they read it back too. WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) @@ -45,20 +39,13 @@ @app.command("me") @async_command async def users_me( - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.users.me() print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -67,20 +54,13 @@ async def users_me( @async_command async def users_get( user_id: Annotated[str, typer.Argument(help="Spotify user ID.")], - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope) as spotify: + async with spotify_client() as spotify: result = await spotify.users.get(user_id) print_result( result, columns=COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -91,25 +71,14 @@ async def users_following( type: Annotated[str, typer.Option("--type")] = "artist", limit: LimitOption = 20, after: Annotated[str | None, typer.Option("--after")] = None, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - sort: SortOption = None, - where: WhereOption = None, - scope: ScopeOption = None, ) -> None: - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: result = await spotify.users.following(type=type, limit=limit, after=after) print_result( result, columns=("id", "name", "uri"), - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, - sort=sort, - where=where, ) @@ -118,14 +87,10 @@ async def users_following( async def users_follow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches( lambda chunk: spotify.users.follow(type, chunk), item_ids, @@ -139,9 +104,6 @@ async def users_follow( print_result( result, columns=FOLLOWING_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -151,14 +113,10 @@ async def users_follow( async def users_unfollow( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - async with spotify_client(scope or WRITE_SCOPES) as spotify: + async with spotify_client(WRITE_SCOPES) as spotify: await _sequential_batches( lambda chunk: spotify.users.unfollow(type, chunk), item_ids, @@ -172,9 +130,6 @@ async def users_unfollow( print_result( result, columns=FOLLOWING_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) @@ -184,14 +139,10 @@ async def users_unfollow( async def users_check_following( type: Annotated[str, typer.Argument(help="artist or user")], ids: IdsArgument, - fmt: FormatOption = None, - json_output: JsonOption = False, - raw: RawOption = False, fields: FieldsOption = None, - scope: ScopeOption = None, ) -> None: item_ids = _split_values(ids) - async with spotify_client(scope or READ_SCOPES) as spotify: + async with spotify_client(READ_SCOPES) as spotify: following_values = await spotify.users.check_following(type, item_ids) payload = [ {"id": item_id, "following": following} @@ -200,8 +151,5 @@ async def users_check_following( print_result( payload, columns=FOLLOWING_COLUMNS, - fmt=fmt, - json_output=json_output, - raw=raw, fields=fields, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 28f6ea7..e318cee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from unittest.mock import patch -from spotifyify import SpotifyScope, cli +from spotifyify import cli from spotifyify.schemas import PlaybackState @@ -29,23 +29,6 @@ def test_main_explains_missing_optional_dependency(self): self.assertEqual(str(raised.exception), cli.INSTALL_MESSAGE) - def test_parse_scopes_accepts_repeated_and_csv_values(self): - result = cli._parse_scopes( - [ - "user-read-playback-state,playlist-read-private", - "custom-scope", - ] - ) - - self.assertEqual( - result, - [ - SpotifyScope.USER_READ_PLAYBACK_STATE, - SpotifyScope.PLAYLIST_READ_PRIVATE, - "custom-scope", - ], - ) - def test_split_values_accepts_repeated_and_csv_values(self): self.assertEqual( cli._split_values(["a,b", "c d"]), @@ -70,17 +53,6 @@ def test_filter_fields_supports_nested_paths_and_lists(self): ) self.assertEqual(cli._get_path(payload, "items.0.name"), "Track") - def test_table_formats_headers_and_rows(self): - table = cli._table(("ID", "Name"), [["1", "Track"], ["22", "Other"]]) - - self.assertEqual( - table, - "ID Name \n-- -----\n1 Track\n22 Other", - ) - - def test_table_handles_empty_results(self): - self.assertEqual(cli._table(("ID",), []), "No results.") - def test_typer_app_registers_all_namespace_groups_when_available(self): if cli.typer is None: self.skipTest("typer is optional") @@ -106,32 +78,7 @@ def test_typer_app_registers_all_namespace_groups_when_available(self): class TestOutputContract(unittest.TestCase): - """The format a command emits depends only on arguments and environment.""" - - def test_json_is_the_default_format(self): - with patch.dict("os.environ", {}, clear=True): - self.assertEqual(cli._resolve_format(None), "json") - - def test_explicit_format_wins_over_everything(self): - with patch.dict("os.environ", {"SPOTIFYIFY_FORMAT": "json"}, clear=True): - self.assertEqual( - cli._resolve_format("table", json_output=True), - "table", - ) - - def test_environment_overrides_the_default(self): - with patch.dict("os.environ", {"SPOTIFYIFY_FORMAT": "table"}, clear=True): - self.assertEqual(cli._resolve_format(None), "table") - - def test_json_flag_is_shorthand_for_json_format(self): - with patch.dict("os.environ", {"SPOTIFYIFY_FORMAT": "table"}, clear=True): - self.assertEqual(cli._resolve_format(None, json_output=True), "json") - - def test_unknown_format_is_rejected(self): - with self.assertRaises(Exception) as raised: - cli._resolve_format("yaml") - - self.assertIn("--format", str(raised.exception)) + """The CLI always emits JSON, driven only by the declared columns.""" def test_rows_keep_declared_column_order_and_flatten_nested_objects(self): payload = { @@ -203,28 +150,6 @@ def test_sort_applies_inside_a_paging_envelope(self): self.assertEqual(result["total"], 2) -class TestWhereFilter(unittest.TestCase): - def test_where_keeps_matching_rows_case_insensitively(self): - payload = {"items": [{"name": "Alpha"}, {"name": "Beta"}]} - - result = cli._apply_where(payload, ["name=alp"]) - - self.assertEqual(result["items"], [{"name": "Alpha"}]) - - def test_where_predicates_are_combined_with_and(self): - payload = {"items": [{"a": "x", "b": "y"}, {"a": "x", "b": "z"}]} - - result = cli._apply_where(payload, ["a=x", "b=z"]) - - self.assertEqual(result["items"], [{"a": "x", "b": "z"}]) - - def test_where_rejects_a_missing_separator(self): - with self.assertRaises(Exception) as raised: - cli._apply_where([{"a": 1}], ["nope"]) - - self.assertIn("PATH=VALUE", str(raised.exception)) - - class TestPlaybackSummary(unittest.TestCase): def test_summary_of_no_playback_is_stopped(self): summary = cli._playback_summary(None) @@ -260,7 +185,7 @@ def test_paused_playback_is_reported_as_paused(self): class TestBatching(unittest.TestCase): def test_ids_are_chunked_to_the_endpoint_limit(self): - from spotifyify.cli._core import _chunked + from spotifyify.cli.core import _chunked self.assertEqual(_chunked(["a", "b", "c"], 2), [["a", "b"], ["c"]]) self.assertEqual(_chunked([], 2), []) @@ -333,10 +258,10 @@ def setUp(self): self.runner = CliRunner() - def _run(self, args, namespaces): + def _run(self, args, namespaces, env=None): FakeSpotifyify.namespaces = namespaces - with patch("spotifyify.cli._core.Spotifyify", FakeSpotifyify): - return self.runner.invoke(cli.app, args) + with patch("spotifyify.cli.core.Spotifyify", FakeSpotifyify): + return self.runner.invoke(cli.app, args, env=env) def test_search_prints_declared_columns_as_json_by_default(self): async def find(query, **kwargs): @@ -369,8 +294,9 @@ async def find(query, **kwargs): return {"items": [{"id": "t1", "available_markets": ["DE"]}], "total": 1} result = self._run( - ["tracks", "search", "x", "--raw"], + ["tracks", "search", "x"], {"tracks": SimpleNamespace(find=find)}, + env={"SPOTIFYIFY_RAW": "1"}, ) self.assertEqual(result.exit_code, 0, result.output) @@ -392,8 +318,9 @@ async def state(**kwargs): ) result = self._run( - ["player", "state", "--raw"], + ["player", "state"], {"player": SimpleNamespace(state=state)}, + env={"SPOTIFYIFY_RAW": "1"}, ) payload = json.loads(result.output) @@ -525,7 +452,7 @@ async def play(**kwargs): async def state(**kwargs): return PlaybackState.model_validate(states.pop(0) if states else states) - with patch("spotifyify.cli._core.SETTLE_DELAY_SECONDS", 0): + with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): result = self._run( ["player", "play", "--uri", "spotify:track:new"], {"player": SimpleNamespace(play=play, state=state)}, @@ -571,7 +498,7 @@ async def find(query, **kwargs): return {"items": [{"id": "t1", "name": "Track"}]} result = self._run( - ["tracks", "search", "x", "--format", "table"], + ["tracks", "search", "x"], {"tracks": SimpleNamespace(find=find)}, ) From c0b17840c503d2d09c5f47f01dfa3d5f34c71b90 Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 20:36:25 +0200 Subject: [PATCH 07/12] Refine cli --- spotifyify/cli/__init__.py | 42 ++++++++-------- spotifyify/cli/albums.py | 18 ++++--- spotifyify/cli/artists.py | 16 +++---- spotifyify/cli/core.py | 96 +++++++++++++++++-------------------- spotifyify/cli/episodes.py | 16 +++---- spotifyify/cli/library.py | 56 +++++++++++----------- spotifyify/cli/options.py | 20 ++++---- spotifyify/cli/player.py | 92 +++++++++++++++++------------------ spotifyify/cli/playlists.py | 16 +++---- spotifyify/cli/quick.py | 18 ++++--- spotifyify/cli/shows.py | 18 ++++--- spotifyify/cli/tracks.py | 16 +++---- spotifyify/cli/users.py | 20 ++++---- tests/test_cli.py | 32 ++++++------- 14 files changed, 222 insertions(+), 254 deletions(-) diff --git a/spotifyify/cli/__init__.py b/spotifyify/cli/__init__.py index 2436e4c..6dc7309 100644 --- a/spotifyify/cli/__init__.py +++ b/spotifyify/cli/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import sys from typing import Annotated @@ -10,30 +8,30 @@ from .core import ( INSTALL_MESSAGE, - _apply_sort, - _cell, - _filter_fields, - _get_path, - _playback_summary, - _rows, - _set_default_device_id, - _set_default_market, - _sort_items, - _split_values, + apply_sort, + cell, + filter_fields, + get_path, + playback_summary, + rows, + set_default_device_id, + set_default_market, + sort_items, + split_values, ) __all__ = [ "INSTALL_MESSAGE", "main", "typer", - "_apply_sort", - "_cell", - "_filter_fields", - "_get_path", - "_playback_summary", - "_rows", - "_sort_items", - "_split_values", + "apply_sort", + "cell", + "filter_fields", + "get_path", + "playback_summary", + "rows", + "sort_items", + "split_values", ] @@ -114,8 +112,8 @@ def _root( ), ] = None, ) -> None: - _set_default_market(market) - _set_default_device_id(device_id) + set_default_market(market) + set_default_device_id(device_id) if __name__ == "__main__": diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index e581a53..5f7fae8 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -1,14 +1,12 @@ -from __future__ import annotations - from typing import Annotated import typer from .core import ( BATCH_ALBUMS, - _default_market, - _gather_batches, - _split_values, + default_market, + gather_batches, + split_values, spotify_client, ) from .options import ( @@ -37,7 +35,7 @@ async def search_albums( fields: FieldsOption = None, ) -> None: async with spotify_client() as spotify: - result = await spotify.albums.find(query, limit=limit, market=_default_market()) + result = await spotify.albums.find(query, limit=limit, market=default_market()) print_result( result, columns=COLUMNS, @@ -52,10 +50,10 @@ async def get_albums( fields: FieldsOption = None, ) -> None: """Fetch one or many albums in a single call.""" - ids = _split_values(album_ids) - market = _default_market() + ids = split_values(album_ids) + market = default_market() async with spotify_client() as spotify: - result = await _gather_batches( + result = await gather_batches( lambda chunk: spotify.albums.get_many(chunk, market=market), ids, BATCH_ALBUMS, @@ -76,7 +74,7 @@ async def album_tracks( ) -> None: async with spotify_client() as spotify: result = await spotify.albums.tracks( - album_id, limit=limit, market=_default_market() + album_id, limit=limit, market=default_market() ) print_result( result, diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index 2893473..463f2bf 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -1,14 +1,12 @@ -from __future__ import annotations - from typing import Annotated import typer from .core import ( BATCH_ARTISTS, - _default_market, - _gather_batches, - _split_values, + default_market, + gather_batches, + split_values, spotify_client, ) from .options import ( @@ -54,9 +52,9 @@ async def get_artists( fields: FieldsOption = None, ) -> None: """Fetch one or many artists in a single call.""" - ids = _split_values(artist_ids) + ids = split_values(artist_ids) async with spotify_client() as spotify: - result = await _gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS) + result = await gather_batches(spotify.artists.get_many, ids, BATCH_ARTISTS) print_result( result, columns=COLUMNS, @@ -72,7 +70,7 @@ async def artist_top_tracks( ) -> None: async with spotify_client() as spotify: result = await spotify.artists.top_tracks( - artist_id, market=_default_market() or "US" + artist_id, market=default_market() or "US" ) print_result( result, @@ -96,7 +94,7 @@ async def artist_albums( result = await spotify.artists.albums( artist_id, include_groups=include_groups, - market=_default_market(), + market=default_market(), limit=limit, ) print_result( diff --git a/spotifyify/cli/core.py b/spotifyify/cli/core.py index 269164e..599f231 100644 --- a/spotifyify/cli/core.py +++ b/spotifyify/cli/core.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio import json import os @@ -11,6 +9,7 @@ from pydantic import BaseModel from spotifyify import Spotifyify, SpotifyScope +from spotifyify.schemas import PlaybackState try: import typer @@ -34,17 +33,17 @@ _CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") -def _as_jsonable(value: Jsonable) -> Any: +def as_jsonable(value: Jsonable) -> Any: if isinstance(value, BaseModel): return value.model_dump(mode="json", exclude_none=True) if isinstance(value, list): - return [_as_jsonable(item) for item in value] + return [as_jsonable(item) for item in value] if isinstance(value, dict): - return {key: _as_jsonable(item) for key, item in value.items()} + return {key: as_jsonable(item) for key, item in value.items()} return value -def _split_values(values: Sequence[str] | None) -> list[str]: +def split_values(values: Sequence[str] | None) -> list[str]: if not values: return [] items: list[str] = [] @@ -53,16 +52,14 @@ def _split_values(values: Sequence[str] | None) -> list[str]: return items -def _merge_scopes(*scope_groups: Sequence[SpotifyScope]) -> list[SpotifyScope]: +def merge_scopes(*scope_groups: Sequence[SpotifyScope]) -> list[SpotifyScope]: merged: list[SpotifyScope] = [] for group in scope_groups: merged.extend(scope for scope in group if scope not in merged) return merged -def _parse_json_object( - raw_value: str | None, option_name: str -) -> dict[str, Any] | None: +def parse_json_object(raw_value: str | None, option_name: str) -> dict[str, Any] | None: if not raw_value: return None try: @@ -80,7 +77,7 @@ def _parse_json_object( return value -def _get_path(value: Any, path: str) -> Any: +def get_path(value: Any, path: str) -> Any: current = value for part in path.split("."): if isinstance(current, Mapping): @@ -97,16 +94,16 @@ def _get_path(value: Any, path: str) -> Any: return current -def _filter_fields(value: Any, fields: Sequence[str]) -> Any: +def filter_fields(value: Any, fields: Sequence[str]) -> Any: if not fields: return value if isinstance(value, list): - return [_filter_fields(item, fields) for item in value] - return {field: _get_path(value, field) for field in fields} + return [filter_fields(item, fields) for item in value] + return {field: get_path(value, field) for field in fields} def _format_value(value: Any) -> str: - value = _as_jsonable(value) + value = as_jsonable(value) if value is None: return "" if isinstance(value, bool): @@ -122,7 +119,7 @@ def _format_value(value: Any) -> str: return str(value) -def _cell(value: Any) -> str: +def cell(value: Any) -> str: """Render one field as a single-line, escape-free table cell.""" return _CONTROL_CHARACTERS.sub(" ", _format_value(value)) @@ -134,7 +131,7 @@ def _project(value: Any) -> Any: `"artists": ["Ikkimel"]` rather than two screens of artist objects, while numbers and booleans stay numbers and booleans. """ - value = _as_jsonable(value) + value = as_jsonable(value) if isinstance(value, list): return [_project(item) for item in value] if isinstance(value, dict): @@ -144,16 +141,16 @@ def _project(value: Any) -> Any: return value -def _rows(value: Any, columns: Sequence[str]) -> list[dict[str, Any]]: +def rows(value: Any, columns: Sequence[str]) -> list[dict[str, Any]]: """The command's declared columns, in order, for every item in the result.""" return [ - {column: _project(_get_path(item, column)) for column in columns} + {column: _project(get_path(item, column)) for column in columns} for item in _items(value) ] def _items(value: Any) -> list[Any]: - value = _as_jsonable(value) + value = as_jsonable(value) if isinstance(value, dict) and isinstance(value.get("items"), list): return value["items"] if isinstance(value, dict) and isinstance(value.get("queue"), list): @@ -167,17 +164,17 @@ def _items(value: Any) -> list[Any]: def _sort_key(value: Any) -> tuple[int, float, str]: """Total order across mixed types so sorting never raises and never varies.""" - value = _as_jsonable(value) + value = as_jsonable(value) if value is None: return (2, 0.0, "") if isinstance(value, bool): return (0, float(value), "") if isinstance(value, (int, float)): return (0, float(value), "") - return (1, 0.0, _cell(value).casefold()) + return (1, 0.0, cell(value).casefold()) -def _sort_items(items: Sequence[Any], specs: Sequence[str]) -> list[Any]: +def sort_items(items: Sequence[Any], specs: Sequence[str]) -> list[Any]: """Stable multi-key sort; ties keep the order Spotify returned them in.""" ordered = list(items) for spec in reversed(list(specs)): @@ -186,7 +183,7 @@ def _sort_items(items: Sequence[Any], specs: Sequence[str]) -> list[Any]: if not path: continue ordered.sort( - key=lambda item: _sort_key(_get_path(item, path)), reverse=descending + key=lambda item: _sort_key(get_path(item, path)), reverse=descending ) return ordered @@ -205,37 +202,37 @@ def _replace_collection( return payload -def _apply_sort(value: Any, specs: Sequence[str]) -> Any: +def apply_sort(value: Any, specs: Sequence[str]) -> Any: if not specs: return value return _replace_collection( - _as_jsonable(value), lambda items: _sort_items(items, specs) + as_jsonable(value), lambda items: sort_items(items, specs) ) -def _set_default_market(value: str | None) -> None: +def set_default_market(value: str | None) -> None: """Record the --market value from the root command, for this process only.""" global _market_override _market_override = value -def _set_default_device_id(value: str | None) -> None: +def set_default_device_id(value: str | None) -> None: """Record the --device-id value from the root command, for this process only.""" global _device_override _device_override = value -def _default_market() -> str | None: +def default_market() -> str | None: """Root --market flag, then the environment, then no market at all.""" return _market_override or os.environ.get(MARKET_ENV_VAR) or None -def _default_device_id() -> str | None: +def default_device_id() -> str | None: """Root --device-id flag, then the environment, then no device at all.""" return _device_override or os.environ.get(DEVICE_ENV_VAR) or None -def _is_raw_output() -> bool: +def is_raw_output() -> bool: return os.environ.get(RAW_ENV_VAR) == "1" @@ -246,8 +243,8 @@ def _echo(text: str) -> None: typer.echo(text) -def _print_json(value: Any) -> None: - _echo(json.dumps(_as_jsonable(value), indent=2, ensure_ascii=False)) +def print_json(value: Any) -> None: + _echo(json.dumps(as_jsonable(value), indent=2, ensure_ascii=False)) @asynccontextmanager @@ -279,7 +276,7 @@ def _chunked(values: Sequence[str], size: int) -> list[list[str]]: return [list(values[index : index + size]) for index in range(0, len(values), size)] -async def _gather_batches( +async def gather_batches( action: Callable[[list[str]], Awaitable[Sequence[Any]]], ids: Sequence[str], size: int, @@ -292,7 +289,7 @@ async def _gather_batches( return [item for result in results for item in result] -async def _sequential_batches( +async def sequential_batches( action: Callable[[list[str]], Awaitable[Any]], ids: Sequence[str], size: int, @@ -310,10 +307,10 @@ async def _sequential_batches( SETTLE_ATTEMPTS = 4 SETTLE_DELAY_SECONDS = 0.25 -Predicate = Callable[[Any], bool] +Predicate = Callable[[PlaybackState | None], bool] -def _playback_summary(state: Any) -> dict[str, Any]: +def playback_summary(state: Any) -> dict[str, Any]: """A flat, fixed-key view of playback that mutations and reads both emit.""" if state is None: return { @@ -328,7 +325,7 @@ def _playback_summary(state: Any) -> dict[str, Any]: "repeat": "", "uri": "", } - payload = _as_jsonable(state) + payload = as_jsonable(state) item = payload.get("item") or {} artists = item.get("artists") or [] show = item.get("show") or {} @@ -349,17 +346,17 @@ def _playback_summary(state: Any) -> dict[str, Any]: } -def is_playing(state: Any) -> bool: - return bool(getattr(state, "is_playing", False)) +def is_playing(state: PlaybackState | None) -> bool: + return bool(state and state.is_playing) -def is_paused(state: Any) -> bool: - return state is not None and not getattr(state, "is_playing", False) +def is_paused(state: PlaybackState | None) -> bool: + return state is not None and not state.is_playing -def is_fresh_track(state: Any) -> bool: +def is_fresh_track(state: PlaybackState | None) -> bool: """A just-started track: playing and barely into its runtime.""" - return is_playing(state) and (getattr(state, "progress_ms", None) or 0) < 5000 + return is_playing(state) and (state.progress_ms or 0) < 5000 def plays_uri(uri: str | None) -> Predicate: @@ -371,21 +368,18 @@ def plays_uri(uri: str | None) -> Predicate: if uri is None: return is_fresh_track - def matches(state: Any) -> bool: - return ( - is_playing(state) - and getattr(getattr(state, "item", None), "uri", None) == uri - ) + def matches(state: PlaybackState | None) -> bool: + return is_playing(state) and state.item is not None and state.item.uri == uri return matches -async def _settled_playback( +async def settled_playback( spotify: Spotifyify, *, until: Predicate | None = None, wait: bool = True, -) -> Any: +) -> PlaybackState | None: """Read playback back after a mutation, briefly waiting for it to take effect. Spotify applies playback commands asynchronously, so an immediate read can diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index 9b99175..f81e6b5 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -1,14 +1,12 @@ -from __future__ import annotations - from typing import Annotated import typer from .core import ( BATCH_EPISODES, - _default_market, - _gather_batches, - _split_values, + default_market, + gather_batches, + split_values, spotify_client, ) from .options import ( @@ -39,7 +37,7 @@ async def search_episodes( ) -> None: async with spotify_client() as spotify: result = await spotify.episodes.find( - query, limit=limit, market=_default_market() + query, limit=limit, market=default_market() ) print_result( result, @@ -55,10 +53,10 @@ async def get_episodes( fields: FieldsOption = None, ) -> None: """Fetch one or many episodes in a single call.""" - ids = _split_values(episode_ids) - market = _default_market() + ids = split_values(episode_ids) + market = default_market() async with spotify_client() as spotify: - result = await _gather_batches( + result = await gather_batches( lambda chunk: spotify.episodes.get_many(chunk, market=market), ids, BATCH_EPISODES, diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 8f5f4a6..9df9528 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated import typer @@ -11,10 +9,10 @@ BATCH_EPISODES, BATCH_SHOWS, BATCH_TRACKS, - _default_market, - _merge_scopes, - _sequential_batches, - _split_values, + default_market, + merge_scopes, + sequential_batches, + split_values, spotify_client, ) from .options import ( @@ -36,7 +34,7 @@ READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ] MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY] # Save/remove report the resulting saved state, so they read it back too. -WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) +WRITE_SCOPES = merge_scopes(MODIFY_SCOPES, READ_SCOPES) @app.command("saved-tracks") @@ -47,7 +45,7 @@ async def saved_tracks( ) -> None: async with spotify_client(READ_SCOPES) as spotify: result = await spotify.library.saved_tracks( - limit=limit, market=_default_market() + limit=limit, market=default_market() ) print_result( result, @@ -64,7 +62,7 @@ async def saved_albums( ) -> None: async with spotify_client(READ_SCOPES) as spotify: result = await spotify.library.saved_albums( - limit=limit, market=_default_market() + limit=limit, market=default_market() ) print_result( result, @@ -141,9 +139,9 @@ async def save_tracks( track_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(track_ids) + ids = split_values(track_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.save_tracks, ids, BATCH_TRACKS) + await sequential_batches(spotify.library.save_tracks, ids, BATCH_TRACKS) saved = await spotify.library.check_tracks(ids) result = [ {"id": item_id, "saved": is_saved} @@ -162,9 +160,9 @@ async def remove_tracks( track_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(track_ids) + ids = split_values(track_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.remove_tracks, ids, BATCH_TRACKS) + await sequential_batches(spotify.library.remove_tracks, ids, BATCH_TRACKS) saved = await spotify.library.check_tracks(ids) result = [ {"id": item_id, "saved": is_saved} @@ -183,7 +181,7 @@ async def check_tracks( track_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(track_ids) + ids = split_values(track_ids) async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_tracks(ids) result = [ @@ -203,9 +201,9 @@ async def save_albums( album_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(album_ids) + ids = split_values(album_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.save_albums, ids, BATCH_ALBUMS) + await sequential_batches(spotify.library.save_albums, ids, BATCH_ALBUMS) saved = await spotify.library.check_albums(ids) result = [ {"id": item_id, "saved": is_saved} @@ -224,9 +222,9 @@ async def remove_albums( album_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(album_ids) + ids = split_values(album_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.remove_albums, ids, BATCH_ALBUMS) + await sequential_batches(spotify.library.remove_albums, ids, BATCH_ALBUMS) saved = await spotify.library.check_albums(ids) result = [ {"id": item_id, "saved": is_saved} @@ -245,7 +243,7 @@ async def check_albums( album_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(album_ids) + ids = split_values(album_ids) async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_albums(ids) result = [ @@ -265,9 +263,9 @@ async def save_shows( show_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(show_ids) + ids = split_values(show_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.save_shows, ids, BATCH_SHOWS) + await sequential_batches(spotify.library.save_shows, ids, BATCH_SHOWS) saved = await spotify.library.check_shows(ids) result = [ {"id": item_id, "saved": is_saved} @@ -286,9 +284,9 @@ async def remove_shows( show_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(show_ids) + ids = split_values(show_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.remove_shows, ids, BATCH_SHOWS) + await sequential_batches(spotify.library.remove_shows, ids, BATCH_SHOWS) saved = await spotify.library.check_shows(ids) result = [ {"id": item_id, "saved": is_saved} @@ -307,7 +305,7 @@ async def check_shows( show_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(show_ids) + ids = split_values(show_ids) async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_shows(ids) result = [ @@ -327,9 +325,9 @@ async def save_episodes( episode_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(episode_ids) + ids = split_values(episode_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.save_episodes, ids, BATCH_EPISODES) + await sequential_batches(spotify.library.save_episodes, ids, BATCH_EPISODES) saved = await spotify.library.check_episodes(ids) result = [ {"id": item_id, "saved": is_saved} @@ -348,9 +346,9 @@ async def remove_episodes( episode_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(episode_ids) + ids = split_values(episode_ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches(spotify.library.remove_episodes, ids, BATCH_EPISODES) + await sequential_batches(spotify.library.remove_episodes, ids, BATCH_EPISODES) saved = await spotify.library.check_episodes(ids) result = [ {"id": item_id, "saved": is_saved} @@ -369,7 +367,7 @@ async def check_episodes( episode_ids: IdsArgument, fields: FieldsOption = None, ) -> None: - ids = _split_values(episode_ids) + ids = split_values(episode_ids) async with spotify_client(READ_SCOPES) as spotify: saved = await spotify.library.check_episodes(ids) result = [ diff --git a/spotifyify/cli/options.py b/spotifyify/cli/options.py index 271125e..3ee535e 100644 --- a/spotifyify/cli/options.py +++ b/spotifyify/cli/options.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio from collections.abc import Sequence from functools import wraps @@ -12,11 +10,11 @@ from .core import ( Jsonable, - _as_jsonable, - _is_raw_output, - _print_json, - _rows, - _split_values, + as_jsonable, + is_raw_output, + print_json, + rows, + split_values, ) DEFAULT_LIMIT = 10 @@ -86,9 +84,9 @@ def print_result( SPOTIFYIFY_RAW=1 opts out into the untouched Spotify payload, for debugging fields that are not part of any command's declared columns. """ - if _is_raw_output(): - _print_json(_as_jsonable(result)) + if is_raw_output(): + print_json(as_jsonable(result)) return payload = project(result) if project is not None else result - selected_columns = _split_values(fields) or list(columns) - _print_json(_rows(payload, selected_columns)) + selected_columns = split_values(fields) or list(columns) + print_json(rows(payload, selected_columns)) diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index a853ca8..fc5ec78 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated, Any import typer @@ -10,15 +8,15 @@ from .core import ( PLAYBACK_COLUMNS, - _default_device_id, - _default_market, - _is_raw_output, - _merge_scopes, - _parse_json_object, - _playback_summary, - _settled_playback, - _sort_items, - _split_values, + default_device_id, + default_market, + is_raw_output, + merge_scopes, + parse_json_object, + playback_summary, + settled_playback, + sort_items, + split_values, is_fresh_track, is_paused, is_playing, @@ -45,7 +43,7 @@ # Playback mutations report the state they produced, so they need the read # scope too — otherwise the caller pays a second round trip to learn what # actually happened. -CONTROL_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) +CONTROL_SCOPES = merge_scopes(MODIFY_SCOPES, READ_SCOPES) def _select_fallback_device(devices: list[Device]) -> Device | None: @@ -100,12 +98,12 @@ async def player_state( fields: FieldsOption = None, ) -> None: async with spotify_client(READ_SCOPES) as spotify: - state = await spotify.player.state(market=_default_market()) + state = await spotify.player.state(market=default_market()) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -119,7 +117,7 @@ async def player_play( wait: WaitOption = True, fields: FieldsOption = None, ) -> None: - uris = _split_values(uri) or None + uris = split_values(uri) or None if uris: # Wait for the requested track, not merely for "something is playing". until = plays_uri(uris[0]) @@ -130,18 +128,18 @@ async def player_play( async with spotify_client(CONTROL_SCOPES) as spotify: await _play_with_device_fallback( spotify, - device_id=_default_device_id(), + device_id=default_device_id(), context_uri=context_uri, uris=uris, - offset=_parse_json_object(offset, "--offset-json"), + offset=parse_json_object(offset, "--offset-json"), position_ms=position_ms, ) - state = await _settled_playback(spotify, until=until, wait=wait) + state = await settled_playback(spotify, until=until, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -152,13 +150,13 @@ async def player_pause( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.pause(device_id=_default_device_id()) - state = await _settled_playback(spotify, until=is_paused, wait=wait) + await spotify.player.pause(device_id=default_device_id()) + state = await settled_playback(spotify, until=is_paused, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -169,13 +167,13 @@ async def player_skip( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.skip(device_id=_default_device_id()) - state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) + await spotify.player.skip(device_id=default_device_id()) + state = await settled_playback(spotify, until=is_fresh_track, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -186,13 +184,13 @@ async def player_previous( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.previous(device_id=_default_device_id()) - state = await _settled_playback(spotify, until=is_fresh_track, wait=wait) + await spotify.player.previous(device_id=default_device_id()) + state = await settled_playback(spotify, until=is_fresh_track, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -203,13 +201,13 @@ async def player_seek( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.seek(position_ms, device_id=_default_device_id()) - state = await _settled_playback(spotify) + await spotify.player.seek(position_ms, device_id=default_device_id()) + state = await settled_playback(spotify) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -220,13 +218,13 @@ async def player_repeat( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.repeat(state, device_id=_default_device_id()) - playback = await _settled_playback(spotify) + await spotify.player.repeat(state, device_id=default_device_id()) + playback = await settled_playback(spotify) print_result( playback, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -237,13 +235,13 @@ async def player_shuffle( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.shuffle(state, device_id=_default_device_id()) - playback = await _settled_playback(spotify) + await spotify.player.shuffle(state, device_id=default_device_id()) + playback = await settled_playback(spotify) print_result( playback, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -254,13 +252,13 @@ async def player_volume( fields: FieldsOption = None, ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: - await spotify.player.volume(volume_percent, device_id=_default_device_id()) - state = await _settled_playback(spotify) + await spotify.player.volume(volume_percent, device_id=default_device_id()) + state = await settled_playback(spotify) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -285,20 +283,20 @@ async def add_to_queue( fields: FieldsOption = None, ) -> None: """Queue one or many tracks or episodes in a single call.""" - queued = _split_values(uris) - device_id = _default_device_id() + queued = split_values(uris) + device_id = default_device_id() async with spotify_client(CONTROL_SCOPES) as spotify: # Spotify has no bulk queue endpoint and the queue is ordered, so these # must stay sequential. for uri in queued: await spotify.player.add_to_queue(uri, device_id=device_id) - state = await _settled_playback(spotify) + state = await settled_playback(spotify) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -312,7 +310,7 @@ async def transfer_playback( ) -> None: async with spotify_client(CONTROL_SCOPES) as spotify: await spotify.player.transfer(device_id, play=play) - state = await _settled_playback( + state = await settled_playback( spotify, until=is_playing if play else None, wait=wait, @@ -321,7 +319,7 @@ async def transfer_playback( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) @@ -334,7 +332,7 @@ async def player_devices( result = await spotify.player.devices() # Spotify returns devices in no defined order; sort so repeated calls and # any caching built on top of them stay reproducible. - display_result = result if _is_raw_output() else _sort_items(result or [], ("id",)) + display_result = result if is_raw_output() else sort_items(result or [], ("id",)) print_result( display_result, columns=("id", "name", "type", "is_active", "volume_percent"), diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index 8d7516e..dab2208 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -1,12 +1,10 @@ -from __future__ import annotations - from typing import Annotated import typer from spotifyify import SpotifyScope -from .core import _default_market, _split_values, spotify_client +from .core import default_market, split_values, spotify_client from .options import ( DEFAULT_LIMIT, FieldsOption, @@ -54,7 +52,7 @@ async def get_playlist( fields: FieldsOption = None, ) -> None: async with spotify_client() as spotify: - result = await spotify.playlists.get(playlist_id, market=_default_market()) + result = await spotify.playlists.get(playlist_id, market=default_market()) print_result( result, columns=COLUMNS, @@ -98,10 +96,10 @@ async def playlist_tracks( async with spotify_client() as spotify: result = await spotify.playlists.tracks( playlist_id, - market=_default_market(), + market=default_market(), fields=fields_query, limit=limit, - additional_types=_split_values(additional_types) or None, + additional_types=split_values(additional_types) or None, ) print_result( result, @@ -176,7 +174,7 @@ async def add_playlist_items( """Add one or many URIs in a single call.""" async with spotify_client(_MODIFY_SCOPES) as spotify: snapshot_id = await spotify.playlists.add( - playlist_id, _split_values(uris), position=position + playlist_id, split_values(uris), position=position ) playlist = await spotify.playlists.get(playlist_id) result = { @@ -200,7 +198,7 @@ async def replace_playlist_items( fields: FieldsOption = None, ) -> None: async with spotify_client(_MODIFY_SCOPES) as spotify: - snapshot_id = await spotify.playlists.replace(playlist_id, _split_values(uris)) + snapshot_id = await spotify.playlists.replace(playlist_id, split_values(uris)) playlist = await spotify.playlists.get(playlist_id) result = { "playlist_id": playlist_id, @@ -223,7 +221,7 @@ async def remove_playlist_items( fields: FieldsOption = None, ) -> None: async with spotify_client(_MODIFY_SCOPES) as spotify: - snapshot_id = await spotify.playlists.remove(playlist_id, _split_values(uris)) + snapshot_id = await spotify.playlists.remove(playlist_id, split_values(uris)) playlist = await spotify.playlists.get(playlist_id) result = { "playlist_id": playlist_id, diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py index c3afc59..70485a8 100644 --- a/spotifyify/cli/quick.py +++ b/spotifyify/cli/quick.py @@ -1,15 +1,13 @@ -from __future__ import annotations - from typing import Annotated, Any import typer from .core import ( PLAYBACK_COLUMNS, - _default_device_id, - _default_market, - _playback_summary, - _settled_playback, + default_device_id, + default_market, + playback_summary, + settled_playback, is_fresh_track, plays_uri, spotify_client, @@ -95,7 +93,7 @@ async def play( # Otherwise the broadest given filter becomes the playback context. wants_track = bool(track or words) wants_album = bool(album) and not wants_track - market = _default_market() + market = default_market() async with spotify_client(CONTROL_SCOPES) as spotify: if wants_track: @@ -114,18 +112,18 @@ async def play( await _play_with_device_fallback( spotify, - device_id=_default_device_id(), + device_id=default_device_id(), uris=uris, context_uri=context_uri, ) # Wait for the thing we resolved, so the reported state is not the # track that happened to be playing already. until = plays_uri(uris[0]) if uris else is_fresh_track - state = await _settled_playback(spotify, until=until, wait=wait) + state = await settled_playback(spotify, until=until, wait=wait) print_result( state, columns=PLAYBACK_COLUMNS, fields=fields, - project=_playback_summary, + project=playback_summary, ) diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index ab6dc31..0ffdbc2 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -1,14 +1,12 @@ -from __future__ import annotations - from typing import Annotated import typer from .core import ( BATCH_SHOWS, - _default_market, - _gather_batches, - _split_values, + default_market, + gather_batches, + split_values, spotify_client, ) from .options import ( @@ -38,7 +36,7 @@ async def search_shows( fields: FieldsOption = None, ) -> None: async with spotify_client() as spotify: - result = await spotify.shows.find(query, limit=limit, market=_default_market()) + result = await spotify.shows.find(query, limit=limit, market=default_market()) print_result( result, columns=COLUMNS, @@ -53,10 +51,10 @@ async def get_shows( fields: FieldsOption = None, ) -> None: """Fetch one or many shows in a single call.""" - ids = _split_values(show_ids) - market = _default_market() + ids = split_values(show_ids) + market = default_market() async with spotify_client() as spotify: - result = await _gather_batches( + result = await gather_batches( lambda chunk: spotify.shows.get_many(chunk, market=market), ids, BATCH_SHOWS, @@ -77,7 +75,7 @@ async def show_episodes( ) -> None: async with spotify_client() as spotify: result = await spotify.shows.episodes( - show_id, market=_default_market(), limit=limit + show_id, market=default_market(), limit=limit ) print_result( result, diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index 3e077e6..344b980 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -1,14 +1,12 @@ -from __future__ import annotations - from typing import Annotated import typer from .core import ( BATCH_TRACKS, - _default_market, - _gather_batches, - _split_values, + default_market, + gather_batches, + split_values, spotify_client, ) from .options import ( @@ -37,7 +35,7 @@ async def search_tracks( fields: FieldsOption = None, ) -> None: async with spotify_client() as spotify: - result = await spotify.tracks.find(query, limit=limit, market=_default_market()) + result = await spotify.tracks.find(query, limit=limit, market=default_market()) print_result( result, columns=COLUMNS, @@ -52,10 +50,10 @@ async def get_tracks( fields: FieldsOption = None, ) -> None: """Fetch one or many tracks in a single call.""" - ids = _split_values(track_ids) - market = _default_market() + ids = split_values(track_ids) + market = default_market() async with spotify_client() as spotify: - result = await _gather_batches( + result = await gather_batches( lambda chunk: spotify.tracks.get_many(chunk, market=market), ids, BATCH_TRACKS, diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index 92435b3..f20c48c 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated import typer @@ -8,9 +6,9 @@ from .core import ( BATCH_FOLLOW, - _merge_scopes, - _sequential_batches, - _split_values, + merge_scopes, + sequential_batches, + split_values, spotify_client, ) from .options import ( @@ -33,7 +31,7 @@ READ_SCOPES = [SpotifyScope.USER_LIBRARY_READ] MODIFY_SCOPES = [SpotifyScope.USER_LIBRARY_MODIFY] # follow/unfollow report the resulting state, so they read it back too. -WRITE_SCOPES = _merge_scopes(MODIFY_SCOPES, READ_SCOPES) +WRITE_SCOPES = merge_scopes(MODIFY_SCOPES, READ_SCOPES) @app.command("me") @@ -89,9 +87,9 @@ async def users_follow( ids: IdsArgument, fields: FieldsOption = None, ) -> None: - item_ids = _split_values(ids) + item_ids = split_values(ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches( + await sequential_batches( lambda chunk: spotify.users.follow(type, chunk), item_ids, BATCH_FOLLOW, @@ -115,9 +113,9 @@ async def users_unfollow( ids: IdsArgument, fields: FieldsOption = None, ) -> None: - item_ids = _split_values(ids) + item_ids = split_values(ids) async with spotify_client(WRITE_SCOPES) as spotify: - await _sequential_batches( + await sequential_batches( lambda chunk: spotify.users.unfollow(type, chunk), item_ids, BATCH_FOLLOW, @@ -141,7 +139,7 @@ async def users_check_following( ids: IdsArgument, fields: FieldsOption = None, ) -> None: - item_ids = _split_values(ids) + item_ids = split_values(ids) async with spotify_client(READ_SCOPES) as spotify: following_values = await spotify.users.check_following(type, item_ids) payload = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index e318cee..b79692d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,7 +31,7 @@ def test_main_explains_missing_optional_dependency(self): def test_split_values_accepts_repeated_and_csv_values(self): self.assertEqual( - cli._split_values(["a,b", "c d"]), + cli.split_values(["a,b", "c d"]), ["a", "b", "c", "d"], ) @@ -48,10 +48,10 @@ def test_filter_fields_supports_nested_paths_and_lists(self): } self.assertEqual( - cli._filter_fields(payload["items"], ["id", "album.name"]), + cli.filter_fields(payload["items"], ["id", "album.name"]), [{"id": "track_id", "album.name": "Album"}], ) - self.assertEqual(cli._get_path(payload, "items.0.name"), "Track") + self.assertEqual(cli.get_path(payload, "items.0.name"), "Track") def test_typer_app_registers_all_namespace_groups_when_available(self): if cli.typer is None: @@ -93,9 +93,7 @@ def test_rows_keep_declared_column_order_and_flatten_nested_objects(self): ] } - rows = cli._rows( - payload, ("id", "name", "artists", "album.name", "duration_ms") - ) + rows = cli.rows(payload, ("id", "name", "artists", "album.name", "duration_ms")) self.assertEqual( list(rows[0]), @@ -107,11 +105,11 @@ def test_rows_keep_declared_column_order_and_flatten_nested_objects(self): self.assertEqual(rows[0]["duration_ms"], 1000) def test_cells_never_contain_control_characters(self): - rows = cli._rows([{"name": "a\tb\nc\x1b[31m"}], ("name",)) + rows = cli.rows([{"name": "a\tb\nc\x1b[31m"}], ("name",)) - self.assertNotIn("\t", cli._cell(rows[0]["name"])) - self.assertNotIn("\n", cli._cell(rows[0]["name"])) - self.assertNotIn("\x1b", cli._cell(rows[0]["name"])) + self.assertNotIn("\t", cli.cell(rows[0]["name"])) + self.assertNotIn("\n", cli.cell(rows[0]["name"])) + self.assertNotIn("\x1b", cli.cell(rows[0]["name"])) class TestStableOrdering(unittest.TestCase): @@ -122,14 +120,14 @@ def test_sort_is_stable_so_ties_keep_api_order(self): {"name": "same", "id": "third"}, ] - ordered = cli._sort_items(items, ["name"]) + ordered = cli.sort_items(items, ["name"]) self.assertEqual([item["id"] for item in ordered], ["first", "second", "third"]) def test_sort_handles_missing_values_without_raising(self): items = [{"n": 2}, {"n": None}, {"n": 1}, {}] - ordered = cli._sort_items(items, ["n"]) + ordered = cli.sort_items(items, ["n"]) # Numbers first in order, absent values last. self.assertEqual([item.get("n") for item in ordered], [1, 2, None, None]) @@ -137,14 +135,14 @@ def test_sort_handles_missing_values_without_raising(self): def test_descending_sort_uses_a_leading_dash(self): items = [{"n": 1}, {"n": 3}, {"n": 2}] - ordered = cli._sort_items(items, ["-n"]) + ordered = cli.sort_items(items, ["-n"]) self.assertEqual([item["n"] for item in ordered], [3, 2, 1]) def test_sort_applies_inside_a_paging_envelope(self): payload = {"total": 2, "items": [{"n": 2}, {"n": 1}]} - result = cli._apply_sort(payload, ["n"]) + result = cli.apply_sort(payload, ["n"]) self.assertEqual(result["items"], [{"n": 1}, {"n": 2}]) self.assertEqual(result["total"], 2) @@ -152,7 +150,7 @@ def test_sort_applies_inside_a_paging_envelope(self): class TestPlaybackSummary(unittest.TestCase): def test_summary_of_no_playback_is_stopped(self): - summary = cli._playback_summary(None) + summary = cli.playback_summary(None) self.assertEqual(summary["state"], "stopped") self.assertEqual(summary["artists"], []) @@ -170,7 +168,7 @@ def test_summary_reports_state_track_artists_and_device(self): } ) - summary = cli._playback_summary(state) + summary = cli.playback_summary(state) self.assertEqual(summary["state"], "playing") self.assertEqual(summary["track"], "HAMPELMANN") @@ -180,7 +178,7 @@ def test_summary_reports_state_track_artists_and_device(self): def test_paused_playback_is_reported_as_paused(self): state = PlaybackState.model_validate({"is_playing": False}) - self.assertEqual(cli._playback_summary(state)["state"], "paused") + self.assertEqual(cli.playback_summary(state)["state"], "paused") class TestBatching(unittest.TestCase): From cb2ad5585eeedfeb4f6cd082493b1b5559a94fce Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 20:40:51 +0200 Subject: [PATCH 08/12] Add documentation for cli --- README.md | 39 +++++++- examples/README.md | 44 +++++++++ examples/cli/README.md | 196 +++++++++++++++++++++++++++++++++++++++++ tests/cli_harness.py | 55 ++++++++++++ 4 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/cli/README.md create mode 100644 tests/cli_harness.py diff --git a/README.md b/README.md index 578f394..b54f259 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,13 @@ uv tool install --from . "spotifyify[cli]" spotifyify --help ``` +Or run the checkout directly without installing a global command: + +```bash +uv sync --extra cli +uv run spotifyify --help +``` + ### Output contract Every command writes JSON to stdout and nothing else, regardless of whether @@ -365,6 +372,18 @@ spotifyify tracks search "Ikkimel" --limit 2 Set `SPOTIFYIFY_RAW=1` to get the untouched Spotify payload instead, for debugging paging metadata or a field that is not a declared column. +```bash +SPOTIFYIFY_RAW=1 spotifyify tracks search "Daft Punk" --limit 1 +``` + +PowerShell: + +```powershell +$env:SPOTIFYIFY_RAW = "1" +spotifyify tracks search "Daft Punk" --limit 1 +Remove-Item Env:SPOTIFYIFY_RAW +``` + ### Command discovery Use the standard `--help` option at the root, group, or command level: @@ -375,6 +394,8 @@ spotifyify artists --help spotifyify artists get --help ``` +The short form `-h` works at every level as well. + Resource groups use plural names consistently (`artists`, `tracks`, `albums`), and each command has one canonical spelling. @@ -386,6 +407,7 @@ The CLI mirrors the public namespace API from `spotifyify.namespaces`: spotifyify tracks search "Daft Punk" --limit 5 spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy spotifyify playlists list +spotifyify player state ``` To find something and play it without a separate lookup: @@ -430,6 +452,13 @@ spotifyify playlists tracks PLAYLIST_ID --spotify-fields "items(track(id,name))" Rows otherwise keep the order Spotify returned them in. +`--field` is a client-side output projection and can be repeated or receive a +comma-separated list. Nested values use dotted paths: + +```bash +spotifyify tracks get TRACK_ID --field id --field name --field album.name +``` + ### Batching Commands that take IDs or URIs are variadic and accept repeated or @@ -498,9 +527,11 @@ spotifyify player play --help ## Examples -See the [`examples/`](./examples) directory for runnable scripts: +See the [`examples/`](./examples) directory for CLI recipes and runnable Python +scripts: -- [`examples/search_and_play.py`](./examples/search_and_play.py) — search for tracks and control playback -- [`examples/manage_playlist.py`](./examples/manage_playlist.py) — create and manage a playlist +- [`examples/cli/README.md`](./examples/cli/README.md) — copy-paste CLI workflows for search, playback, playlists, library, batching, and JSON output +- [`examples/player/search_and_play.py`](./examples/player/search_and_play.py) — search for tracks and control playback with the Python API +- [`examples/playlists/manage_playlist.py`](./examples/playlists/manage_playlist.py) — create and manage a playlist with the Python API - [`examples/playlists/user_token_playlist.py`](./examples/playlists/user_token_playlist.py) — create playlists with caller-supplied user tokens -- [`examples/library_stats.py`](./examples/library_stats.py) — explore your top tracks and saved library +- [`examples/library/library_stats.py`](./examples/library/library_stats.py) — explore your top tracks and saved library diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b5ef8ba --- /dev/null +++ b/examples/README.md @@ -0,0 +1,44 @@ +# Examples + +The examples are split into two styles: + +- [`cli/`](./cli) contains copy-paste command-line workflows for the optional + `spotifyify` CLI. +- The namespace directories contain runnable Python examples for the async API. + +## CLI + +Start with the [CLI recipes](./cli/README.md). They cover command discovery, +search, playback, playlists, library operations, batching, field selection, and +raw Spotify responses. + +From a development checkout, run each command as `uv run spotifyify ...`. After +a global `uv tool install --from . "spotifyify[cli]"`, use `spotifyify ...` +directly. + +## Python API + +Run a Python example from the repository root: + +```bash +uv run python examples/tracks/search_tracks.py +uv run python examples/player/search_and_play.py +uv run python examples/playlists/manage_playlist.py +``` + +Examples that access playback, private playlists, or the user's library start +the interactive Authorization Code flow when no suitable user token is already +configured. + +| Area | Examples | +| --- | --- | +| Tracks | [`tracks/search_tracks.py`](./tracks/search_tracks.py) | +| Artists | [`artists/explore_artist.py`](./artists/explore_artist.py) | +| Albums | [`albums/browse_album.py`](./albums/browse_album.py) | +| Playlists | [`playlists/list_playlists.py`](./playlists/list_playlists.py), [`playlists/manage_playlist.py`](./playlists/manage_playlist.py), [`playlists/user_token_playlist.py`](./playlists/user_token_playlist.py) | +| Playback | [`player/playback_status.py`](./player/playback_status.py), [`player/search_and_play.py`](./player/search_and_play.py) | +| Library | [`library/library_overview.py`](./library/library_overview.py), [`library/library_stats.py`](./library/library_stats.py) | +| Shows and episodes | [`shows/browse_show.py`](./shows/browse_show.py), [`episodes/search_episodes.py`](./episodes/search_episodes.py) | +| Users | [`users/profile.py`](./users/profile.py) | +| Retries | [`retries.py`](./retries.py) | +| MCP | [`mcp/mcp_server.py`](./mcp/mcp_server.py) | diff --git a/examples/cli/README.md b/examples/cli/README.md new file mode 100644 index 0000000..c3b7e77 --- /dev/null +++ b/examples/cli/README.md @@ -0,0 +1,196 @@ +# CLI examples + +These recipes use the current plural resource groups and canonical command +names. Run `spotifyify ...` after installing the CLI globally, or prefix every +command with `uv run` when working from this checkout: + +```bash +uv sync --extra cli +uv run spotifyify --help +``` + +The same Spotify credentials described in the project +[README](../../README.md#configuration) apply here. Commands that require a +user scope open the interactive login flow when necessary. + +## Discover commands + +Help is available at the root, namespace, and individual command levels: + +```bash +spotifyify -h +spotifyify playlists -h +spotifyify playlists create -h +``` + +## Search and fetch + +Search results and fetched resources are JSON arrays with stable, declared +columns: + +```bash +spotifyify tracks search "Daft Punk" --limit 3 +spotifyify artists search "Radiohead" --limit 3 +spotifyify albums get 4aawyAB9vmqN3uQ7FjRGTy +spotifyify shows search "Lex Fridman" --limit 3 +``` + +`get` accepts multiple IDs, either as separate arguments or comma-separated: + +```bash +spotifyify tracks get TRACK_ID_1 TRACK_ID_2 TRACK_ID_3 +spotifyify albums get ALBUM_ID_1,ALBUM_ID_2 +``` + +The CLI splits large ID lists into the request sizes supported by Spotify and +combines the results into one JSON array. + +## Select output fields + +Use `--field`/`-f` to replace a command's default columns. It can be repeated, +and comma-separated field paths are accepted. Nested fields use dot notation: + +```bash +spotifyify tracks search "Daft Punk" -f id,name,uri +spotifyify tracks get TRACK_ID -f id -f name -f album.name +spotifyify playlists tracks PLAYLIST_ID -f track.id,track.name,added_at +``` + +`playlists tracks` additionally supports Spotify's server-side field filter: + +```bash +spotifyify playlists tracks PLAYLIST_ID \ + --spotify-fields "items(track(id,name)),next,total" +``` + +`--spotify-fields` controls what Spotify sends. `--field` controls the final +columns printed by spotifyify. + +## Markets and devices + +`--market` and `--device-id` are root options, so place them before the resource +group: + +```bash +spotifyify --market DE tracks search "Daft Punk" +spotifyify player devices +spotifyify --device-id DEVICE_ID player state +``` + +They can also be configured with `SPOTIFYIFY_MARKET` and +`SPOTIFYIFY_DEVICE_ID`. An explicit option takes precedence over the +environment. + +## Find and play in one command + +The top-level `play` command resolves the first matching result and starts it: + +```bash +spotifyify play --artist Ikkimel --track "WHO'S THAT" +spotifyify play --artist "Daft Punk" --album "Random Access Memories" +spotifyify play --artist "Daft Punk" +spotifyify play Get Lucky +``` + +A track name or free text plays one track. With no track, an album filter plays +the album context; with only an artist, it plays the artist context. If no +active Spotify device exists, the command selects a controllable device and +retries. + +Playback mutations return the resulting state: + +```bash +spotifyify player play --uri spotify:track:TRACK_ID +spotifyify player pause +spotifyify player skip +spotifyify player seek 60000 +spotifyify player volume 35 +spotifyify player add-to-queue spotify:track:TRACK_ID_1 spotify:track:TRACK_ID_2 +``` + +They briefly wait for Spotify to reflect the change. Add `--no-wait` to a +playback command when an immediate read is preferable: + +```bash +spotifyify player skip --no-wait +``` + +## Manage a playlist + +Create a private playlist, then use the returned `id` in subsequent commands: + +```bash +spotifyify playlists create "My Boards of Canada Mix" \ + --private \ + --description "Created with spotifyify" + +spotifyify playlists add PLAYLIST_ID \ + spotify:track:TRACK_ID_1 \ + spotify:track:TRACK_ID_2 + +spotifyify playlists tracks PLAYLIST_ID +spotifyify playlists remove PLAYLIST_ID spotify:track:TRACK_ID_1 +``` + +Playlist mutations report the new snapshot and total item count: + +```json +[ + { + "playlist_id": "PLAYLIST_ID", + "snapshot_id": "NEW_SNAPSHOT_ID", + "total": 2 + } +] +``` + +## Library and following + +Commands that take IDs support the same repeated and comma-separated forms: + +```bash +spotifyify library save-tracks TRACK_ID_1,TRACK_ID_2 +spotifyify library check-tracks TRACK_ID_1 TRACK_ID_2 +spotifyify library remove-tracks TRACK_ID_1 TRACK_ID_2 + +spotifyify users follow artist ARTIST_ID_1 ARTIST_ID_2 +spotifyify users check-following artist ARTIST_ID_1,ARTIST_ID_2 +spotifyify users unfollow artist ARTIST_ID_1 ARTIST_ID_2 +``` + +Write commands read the state back and return one row per requested ID, for +example: + +```json +[ + {"id": "TRACK_ID_1", "saved": true}, + {"id": "TRACK_ID_2", "saved": true} +] +``` + +## Raw Spotify responses + +The default output deliberately contains only useful, stable columns. Set +`SPOTIFYIFY_RAW=1` to inspect the untouched Spotify payload, including paging +metadata and undeclared fields. + +Bash: + +```bash +SPOTIFYIFY_RAW=1 spotifyify tracks search "Daft Punk" --limit 1 +``` + +PowerShell: + +```powershell +$env:SPOTIFYIFY_RAW = "1" +spotifyify tracks search "Daft Punk" --limit 1 +Remove-Item Env:SPOTIFYIFY_RAW +``` + +Every successful normal command writes JSON to stdout, so its output can be +redirected or consumed by another program without changing shape: + +```bash +spotifyify tracks search "Daft Punk" --limit 5 > tracks.json +``` diff --git a/tests/cli_harness.py b/tests/cli_harness.py new file mode 100644 index 0000000..5de25f6 --- /dev/null +++ b/tests/cli_harness.py @@ -0,0 +1,55 @@ +"""Shared harness for CLI tests: drive the real Typer app over a fake client.""" + +from __future__ import annotations + +import json +import unittest +from unittest.mock import patch + +from spotifyify import cli + + +class FakeSpotifyify: + """Stands in for the real client inside the CLI's async runner.""" + + namespaces: dict = {} + instances: list[FakeSpotifyify] = [] + + def __init__(self, **kwargs): + self.scopes = list(kwargs.get("scopes") or []) + FakeSpotifyify.instances.append(self) + for name, value in self.namespaces.items(): + setattr(self, name, value) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + +class CliTestCase(unittest.TestCase): + """Invokes the installed commands with only the Spotify client replaced.""" + + def setUp(self): + if cli.typer is None: + self.skipTest("typer is optional") + from typer.testing import CliRunner + + self.runner = CliRunner() + + def run_cli(self, args, namespaces=None, env=None): + FakeSpotifyify.namespaces = namespaces or {} + FakeSpotifyify.instances = [] + with patch("spotifyify.cli.core.Spotifyify", FakeSpotifyify): + return self.runner.invoke(cli.app, args, env=env) + + def run_json(self, args, namespaces=None, env=None): + """Run a command that is expected to succeed and parse its JSON.""" + result = self.run_cli(args, namespaces, env=env) + self.assertEqual(result.exit_code, 0, result.output) + return json.loads(result.output) + + def requested_scopes(self): + """Scopes the command asked the client for, in request order.""" + return [scope for client in FakeSpotifyify.instances for scope in client.scopes] From b627253cd4b58e91e695da68b16e486261e4a40a Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 20:45:46 +0200 Subject: [PATCH 09/12] Use absolute imports within the cli package Relative imports (.core, .options) made module identity dependent on where a file sits in the package; absolute imports keep that explicit everywhere except __init__.py, which stays relative by convention. --- spotifyify/cli/albums.py | 4 ++-- spotifyify/cli/artists.py | 4 ++-- spotifyify/cli/episodes.py | 4 ++-- spotifyify/cli/library.py | 4 ++-- spotifyify/cli/options.py | 2 +- spotifyify/cli/player.py | 4 ++-- spotifyify/cli/playlists.py | 4 ++-- spotifyify/cli/quick.py | 6 +++--- spotifyify/cli/shows.py | 4 ++-- spotifyify/cli/tracks.py | 4 ++-- spotifyify/cli/users.py | 4 ++-- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/spotifyify/cli/albums.py b/spotifyify/cli/albums.py index 5f7fae8..0befc9d 100644 --- a/spotifyify/cli/albums.py +++ b/spotifyify/cli/albums.py @@ -2,14 +2,14 @@ import typer -from .core import ( +from spotifyify.cli.core import ( BATCH_ALBUMS, default_market, gather_batches, split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, diff --git a/spotifyify/cli/artists.py b/spotifyify/cli/artists.py index 463f2bf..ecbd943 100644 --- a/spotifyify/cli/artists.py +++ b/spotifyify/cli/artists.py @@ -2,14 +2,14 @@ import typer -from .core import ( +from spotifyify.cli.core import ( BATCH_ARTISTS, default_market, gather_batches, split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, diff --git a/spotifyify/cli/episodes.py b/spotifyify/cli/episodes.py index f81e6b5..9172536 100644 --- a/spotifyify/cli/episodes.py +++ b/spotifyify/cli/episodes.py @@ -2,14 +2,14 @@ import typer -from .core import ( +from spotifyify.cli.core import ( BATCH_EPISODES, default_market, gather_batches, split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, diff --git a/spotifyify/cli/library.py b/spotifyify/cli/library.py index 9df9528..06ba02b 100644 --- a/spotifyify/cli/library.py +++ b/spotifyify/cli/library.py @@ -4,7 +4,7 @@ from spotifyify import SpotifyScope -from .core import ( +from spotifyify.cli.core import ( BATCH_ALBUMS, BATCH_EPISODES, BATCH_SHOWS, @@ -15,7 +15,7 @@ split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( FieldsOption, IdsArgument, LimitOption, diff --git a/spotifyify/cli/options.py b/spotifyify/cli/options.py index 3ee535e..73aa274 100644 --- a/spotifyify/cli/options.py +++ b/spotifyify/cli/options.py @@ -8,7 +8,7 @@ from spotifyify import SpotifyAPIError, SpotifyAuthError -from .core import ( +from spotifyify.cli.core import ( Jsonable, as_jsonable, is_raw_output, diff --git a/spotifyify/cli/player.py b/spotifyify/cli/player.py index fc5ec78..03ae525 100644 --- a/spotifyify/cli/player.py +++ b/spotifyify/cli/player.py @@ -6,7 +6,7 @@ from spotifyify.exceptions import SpotifyAPIError from spotifyify.schemas import Device -from .core import ( +from spotifyify.cli.core import ( PLAYBACK_COLUMNS, default_device_id, default_market, @@ -23,7 +23,7 @@ plays_uri, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( FieldsOption, LimitOption, UrisArgument, diff --git a/spotifyify/cli/playlists.py b/spotifyify/cli/playlists.py index dab2208..3785d26 100644 --- a/spotifyify/cli/playlists.py +++ b/spotifyify/cli/playlists.py @@ -4,8 +4,8 @@ from spotifyify import SpotifyScope -from .core import default_market, split_values, spotify_client -from .options import ( +from spotifyify.cli.core import default_market, split_values, spotify_client +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, LimitOption, diff --git a/spotifyify/cli/quick.py b/spotifyify/cli/quick.py index 70485a8..9f5f31e 100644 --- a/spotifyify/cli/quick.py +++ b/spotifyify/cli/quick.py @@ -2,7 +2,7 @@ import typer -from .core import ( +from spotifyify.cli.core import ( PLAYBACK_COLUMNS, default_device_id, default_market, @@ -12,13 +12,13 @@ plays_uri, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( FieldsOption, WaitOption, async_command, print_result, ) -from .player import CONTROL_SCOPES, _play_with_device_fallback +from spotifyify.cli.player import CONTROL_SCOPES, _play_with_device_fallback EXIT_NO_MATCH = 4 diff --git a/spotifyify/cli/shows.py b/spotifyify/cli/shows.py index 0ffdbc2..e059948 100644 --- a/spotifyify/cli/shows.py +++ b/spotifyify/cli/shows.py @@ -2,14 +2,14 @@ import typer -from .core import ( +from spotifyify.cli.core import ( BATCH_SHOWS, default_market, gather_batches, split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, diff --git a/spotifyify/cli/tracks.py b/spotifyify/cli/tracks.py index 344b980..ced8c00 100644 --- a/spotifyify/cli/tracks.py +++ b/spotifyify/cli/tracks.py @@ -2,14 +2,14 @@ import typer -from .core import ( +from spotifyify.cli.core import ( BATCH_TRACKS, default_market, gather_batches, split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( DEFAULT_LIMIT, FieldsOption, IdsArgument, diff --git a/spotifyify/cli/users.py b/spotifyify/cli/users.py index f20c48c..3b945d4 100644 --- a/spotifyify/cli/users.py +++ b/spotifyify/cli/users.py @@ -4,14 +4,14 @@ from spotifyify import SpotifyScope -from .core import ( +from spotifyify.cli.core import ( BATCH_FOLLOW, merge_scopes, sequential_batches, split_values, spotify_client, ) -from .options import ( +from spotifyify.cli.options import ( FieldsOption, IdsArgument, LimitOption, From 6f462ad823792d4e6e8b25fe83f530021b364f81 Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 20:45:55 +0200 Subject: [PATCH 10/12] Warn when calling artists.related, which Spotify has deprecated Spotify retired the Related Artists endpoint for most apps in November 2024, so this call now reliably 404s. Add a reusable deprecated decorator (sync and async) in utils and apply it to Artists.related so both the SDK and the CLI surface a clear warning instead of a bare 404. --- spotifyify/namespaces/artists.py | 6 +++++- spotifyify/utils.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/spotifyify/namespaces/artists.py b/spotifyify/namespaces/artists.py index bdc016f..5133727 100644 --- a/spotifyify/namespaces/artists.py +++ b/spotifyify/namespaces/artists.py @@ -9,7 +9,7 @@ PagingArtist, Track, ) -from spotifyify.utils import coalesce_csv +from spotifyify.utils import coalesce_csv, deprecated class Artists: @@ -79,6 +79,10 @@ async def albums( ) return PagingArtistDiscographyAlbum.model_validate(data) + @deprecated( + "Spotify retired the Related Artists endpoint for most apps in " + "November 2024; this call will likely fail with a 404." + ) async def related(self, artist_id: str) -> list[Artist]: data = ( await self._http.get( diff --git a/spotifyify/utils.py b/spotifyify/utils.py index 773f83f..53ddeb5 100644 --- a/spotifyify/utils.py +++ b/spotifyify/utils.py @@ -1,4 +1,10 @@ -from collections.abc import Iterable +import functools +import inspect +import sys +from collections.abc import Callable, Iterable +from typing import Any, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) def coalesce_items(ids_or_uris: Iterable[str]) -> list[str]: @@ -7,3 +13,24 @@ def coalesce_items(ids_or_uris: Iterable[str]) -> list[str]: def coalesce_csv(ids_or_uris: Iterable[str]) -> str: return ",".join(coalesce_items(ids_or_uris)) + + +def deprecated(reason: str) -> Callable[[F], F]: + def decorator(func: F) -> F: + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + print(f"warning: {reason}", file=sys.stderr) + return await func(*args, **kwargs) + + return async_wrapper # type: ignore[return-value] + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + print(f"warning: {reason}", file=sys.stderr) + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator From e61f173f64c239a251f4f721d798255dbc8f318b Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 20:49:42 +0200 Subject: [PATCH 11/12] Add cli unit tests --- tests/test_cli_core.py | 389 ++++++++++++++++++++++++++++++++++++++ tests/test_cli_library.py | 219 +++++++++++++++++++++ tests/test_cli_options.py | 221 ++++++++++++++++++++++ tests/test_cli_player.py | 249 +++++++++++++++++++++++- tests/test_cli_quick.py | 275 +++++++++++++++++++++++++++ 5 files changed, 1351 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_core.py create mode 100644 tests/test_cli_library.py create mode 100644 tests/test_cli_options.py create mode 100644 tests/test_cli_quick.py diff --git a/tests/test_cli_core.py b/tests/test_cli_core.py new file mode 100644 index 0000000..e93bbfc --- /dev/null +++ b/tests/test_cli_core.py @@ -0,0 +1,389 @@ +import asyncio +import os +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from spotifyify import SpotifyScope +from spotifyify.cli import core +from spotifyify.schemas import PlaybackState +from tests.conftest import episode, simplified_show + + +class TestParseJsonObject(unittest.TestCase): + def setUp(self): + if core.typer is None: + self.skipTest("typer is optional") + + def test_no_value_means_no_object(self): + self.assertIsNone(core.parse_json_object(None, "--offset-json")) + self.assertIsNone(core.parse_json_object("", "--offset-json")) + + def test_an_object_is_returned_as_a_dict(self): + self.assertEqual( + core.parse_json_object('{"position": 3}', "--offset-json"), + {"position": 3}, + ) + + def test_malformed_json_names_the_option_it_came_from(self): + with self.assertRaises(core.typer.BadParameter) as raised: + core.parse_json_object("{position: 3}", "--offset-json") + + self.assertIn("--offset-json", str(raised.exception)) + + def test_json_that_is_not_an_object_is_rejected(self): + # A bare array parses fine but would build a request body Spotify rejects. + with self.assertRaises(core.typer.BadParameter): + core.parse_json_object("[1, 2]", "--offset-json") + + def test_without_typer_the_same_input_raises_a_plain_value_error(self): + with patch.object(core, "typer", None): + with self.assertRaises(ValueError): + core.parse_json_object("[1, 2]", "--offset-json") + with self.assertRaises(ValueError): + core.parse_json_object("{position: 3}", "--offset-json") + + +class TestMergeScopes(unittest.TestCase): + def test_duplicates_collapse_and_first_mention_fixes_the_order(self): + merged = core.merge_scopes( + [SpotifyScope.USER_MODIFY_PLAYBACK_STATE], + [ + SpotifyScope.USER_READ_PLAYBACK_STATE, + SpotifyScope.USER_MODIFY_PLAYBACK_STATE, + ], + ) + + self.assertEqual( + merged, + [ + SpotifyScope.USER_MODIFY_PLAYBACK_STATE, + SpotifyScope.USER_READ_PLAYBACK_STATE, + ], + ) + + def test_no_groups_means_no_scopes(self): + self.assertEqual(core.merge_scopes(), []) + + +class TestDefaults(unittest.TestCase): + """--market/--device-id from the root command, then the environment.""" + + def setUp(self): + core.set_default_market(None) + core.set_default_device_id(None) + self.addCleanup(core.set_default_market, None) + self.addCleanup(core.set_default_device_id, None) + + def test_market_falls_back_to_the_environment(self): + with patch.dict(os.environ, {core.MARKET_ENV_VAR: "DE"}): + self.assertEqual(core.default_market(), "DE") + + def test_the_root_flag_wins_over_the_environment(self): + core.set_default_market("US") + + with patch.dict(os.environ, {core.MARKET_ENV_VAR: "DE"}): + self.assertEqual(core.default_market(), "US") + + def test_an_empty_environment_value_counts_as_unset(self): + with patch.dict(os.environ, {core.MARKET_ENV_VAR: ""}): + self.assertIsNone(core.default_market()) + + def test_device_id_follows_the_same_precedence(self): + with patch.dict(os.environ, {core.DEVICE_ENV_VAR: "from-env"}): + self.assertEqual(core.default_device_id(), "from-env") + core.set_default_device_id("from-flag") + self.assertEqual(core.default_device_id(), "from-flag") + + def test_nothing_configured_means_no_default(self): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(core.default_market()) + self.assertIsNone(core.default_device_id()) + + def test_raw_output_is_opt_in(self): + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(core.is_raw_output()) + with patch.dict(os.environ, {core.RAW_ENV_VAR: "0"}): + self.assertFalse(core.is_raw_output()) + with patch.dict(os.environ, {core.RAW_ENV_VAR: "1"}): + self.assertTrue(core.is_raw_output()) + + +class TestProjection(unittest.TestCase): + def test_a_queue_envelope_is_unwrapped_like_a_paging_one(self): + payload = {"queue": [{"id": "t1", "name": "Track"}]} + + self.assertEqual( + core.rows(payload, ("id", "name")), [{"id": "t1", "name": "Track"}] + ) + + def test_a_single_object_becomes_one_row(self): + self.assertEqual(core.rows({"id": "t1"}, ("id",)), [{"id": "t1"}]) + + def test_no_payload_means_no_rows(self): + self.assertEqual(core.rows(None, ("id",)), []) + + def test_missing_paths_are_reported_as_null_rather_than_dropped(self): + # Every row keeps the same keys, so callers can index the output blindly. + rows = core.rows([{"id": "t1"}], ("id", "album.name")) + + self.assertEqual(rows, [{"id": "t1", "album.name": None}]) + + def test_an_object_without_a_name_or_id_keeps_its_fields(self): + rows = core.rows( + [{"external_urls": {"spotify": "https://x"}}], ("external_urls",) + ) + + self.assertEqual(rows[0]["external_urls"], {"spotify": "https://x"}) + + def test_filter_fields_without_fields_passes_the_value_through(self): + payload = [{"id": "t1", "name": "Track"}] + + self.assertIs(core.filter_fields(payload, []), payload) + + def test_get_path_walks_into_pydantic_models(self): + state = PlaybackState.model_validate( + {"is_playing": True, "device": {"name": "Wohnzimmer"}} + ) + + self.assertEqual(core.get_path(state, "device.name"), "Wohnzimmer") + + def test_get_path_stops_at_an_out_of_range_index(self): + self.assertIsNone(core.get_path({"items": []}, "items.0.name")) + + def test_cells_render_booleans_and_lists_readably(self): + self.assertEqual(core.cell(True), "true") + self.assertEqual(core.cell(False), "false") + self.assertEqual(core.cell(None), "") + self.assertEqual(core.cell([{"name": "A"}, {"name": "B"}]), "A, B") + + +class TestSorting(unittest.TestCase): + def test_a_queue_envelope_is_sorted_in_place_of_items(self): + payload = {"queue": [{"n": 2}, {"n": 1}]} + + self.assertEqual(core.apply_sort(payload, ["n"])["queue"], [{"n": 1}, {"n": 2}]) + + def test_later_keys_break_ties_left_by_earlier_ones(self): + items = [ + {"artist": "B", "name": "1"}, + {"artist": "A", "name": "2"}, + {"artist": "A", "name": "1"}, + ] + + ordered = core.sort_items(items, ["artist", "name"]) + + self.assertEqual( + [(item["artist"], item["name"]) for item in ordered], + [("A", "1"), ("A", "2"), ("B", "1")], + ) + + def test_mixed_types_sort_without_raising(self): + items = [{"n": "text"}, {"n": 2}, {"n": None}, {"n": True}] + + ordered = core.sort_items(items, ["n"]) + + # Numbers (and booleans) first, then text, then absent values. + self.assertEqual([item["n"] for item in ordered], [True, 2, "text", None]) + + def test_text_sorts_case_insensitively(self): + items = [{"n": "beta"}, {"n": "Alpha"}] + + self.assertEqual( + [item["n"] for item in core.sort_items(items, ["n"])], ["Alpha", "beta"] + ) + + def test_an_empty_sort_spec_is_ignored(self): + payload = {"items": [{"n": 2}, {"n": 1}]} + + self.assertIs(core.apply_sort(payload, []), payload) + + +class TestBatching(unittest.IsolatedAsyncioTestCase): + async def test_reads_are_chunked_and_concatenated_in_request_order(self): + chunks = [] + + async def action(chunk): + chunks.append(list(chunk)) + return [f"item:{value}" for value in chunk] + + result = await core.gather_batches(action, list("abcde"), 2) + + self.assertEqual(chunks, [["a", "b"], ["c", "d"], ["e"]]) + self.assertEqual(result, [f"item:{value}" for value in "abcde"]) + + async def test_read_batches_run_concurrently(self): + in_flight = 0 + peak = 0 + + async def action(chunk): + nonlocal in_flight, peak + in_flight += 1 + peak = max(peak, in_flight) + await asyncio.sleep(0) + in_flight -= 1 + return chunk + + await core.gather_batches(action, list("abcd"), 1) + + self.assertEqual(peak, 4) + + async def test_no_ids_means_no_request(self): + calls = [] + + async def action(chunk): + calls.append(chunk) + return chunk + + self.assertEqual(await core.gather_batches(action, [], 10), []) + await core.sequential_batches(action, [], 10) + + self.assertEqual(calls, []) + + async def test_writes_finish_one_chunk_before_starting_the_next(self): + # Partial failures have to stay comprehensible, so writes never overlap. + events = [] + + async def action(chunk): + events.append(("start", tuple(chunk))) + await asyncio.sleep(0) + events.append(("done", tuple(chunk))) + + await core.sequential_batches(action, list("abc"), 2) + + self.assertEqual( + events, + [ + ("start", ("a", "b")), + ("done", ("a", "b")), + ("start", ("c",)), + ("done", ("c",)), + ], + ) + + +def _state(**overrides): + payload = {"is_playing": True, "progress_ms": 0} + payload.update(overrides) + return PlaybackState.model_validate(payload) + + +class TestPlaybackPredicates(unittest.TestCase): + def test_nothing_playing_satisfies_no_predicate(self): + self.assertFalse(core.is_playing(None)) + self.assertFalse(core.is_paused(None)) + self.assertFalse(core.is_fresh_track(None)) + + def test_a_track_already_in_progress_is_not_fresh(self): + self.assertFalse(core.is_fresh_track(_state(progress_ms=60_000))) + self.assertTrue(core.is_fresh_track(_state(progress_ms=1_000))) + + def test_plays_uri_rejects_the_track_that_was_already_playing(self): + matches = core.plays_uri("spotify:track:new") + previous = _state(item={"type": "track", "uri": "spotify:track:old"}) + + self.assertFalse(matches(previous)) + self.assertTrue( + matches(_state(item={"type": "track", "uri": "spotify:track:new"})) + ) + + def test_without_a_uri_any_freshly_started_track_counts(self): + self.assertIs(core.plays_uri(None), core.is_fresh_track) + + +class TestSettledPlayback(unittest.IsolatedAsyncioTestCase): + def setUp(self): + patcher = patch.object(core, "SETTLE_DELAY_SECONDS", 0) + patcher.start() + self.addCleanup(patcher.stop) + + def _spotify(self, states): + reads = [] + + async def state(): + reads.append(1) + return states[min(len(reads) - 1, len(states) - 1)] + + return SimpleNamespace(player=SimpleNamespace(state=state)), reads + + async def test_polling_stops_as_soon_as_the_predicate_holds(self): + spotify, reads = self._spotify([_state(is_playing=False), _state()]) + + result = await core.settled_playback(spotify, until=core.is_playing) + + self.assertEqual(len(reads), 2) + self.assertTrue(result.is_playing) + + async def test_polling_gives_up_and_reports_what_it_last_saw(self): + spotify, reads = self._spotify([_state(is_playing=False)]) + + result = await core.settled_playback(spotify, until=core.is_playing) + + self.assertEqual(len(reads), core.SETTLE_ATTEMPTS) + self.assertFalse(result.is_playing) + + async def test_no_wait_reads_once_even_with_a_predicate(self): + spotify, reads = self._spotify([_state(is_playing=False)]) + + await core.settled_playback(spotify, until=core.is_playing, wait=False) + + self.assertEqual(len(reads), 1) + + async def test_without_a_predicate_there_is_nothing_to_wait_for(self): + spotify, reads = self._spotify([_state(is_playing=False)]) + + await core.settled_playback(spotify) + + self.assertEqual(len(reads), 1) + + +class TestPlaybackSummary(unittest.TestCase): + def test_an_episode_reports_its_publisher_and_show(self): + state = PlaybackState.model_validate( + { + "is_playing": True, + "item": episode( + type="episode", + name="Folge 1", + show=simplified_show(name="Der Podcast", publisher="ARD"), + ), + } + ) + + summary = core.playback_summary(state) + + # Episodes have no artists, so the publisher fills the same column. + self.assertEqual(summary["artists"], ["ARD"]) + self.assertEqual(summary["album"], "Der Podcast") + self.assertEqual(summary["track"], "Folge 1") + + def test_every_summary_has_the_same_keys(self): + state = PlaybackState.model_validate( + {"is_playing": True, "item": {"type": "track", "name": "Track"}} + ) + + self.assertEqual( + core.playback_summary(state).keys(), core.playback_summary(None).keys() + ) + + def test_progress_shuffle_and_repeat_reach_the_summary(self): + state = PlaybackState.model_validate( + { + "is_playing": True, + "progress_ms": 4200, + "shuffle_state": True, + "repeat_state": "context", + "item": {"type": "track", "name": "Track", "duration_ms": 180_000}, + } + ) + + summary = core.playback_summary(state) + + self.assertEqual(summary["progress_ms"], 4200) + self.assertEqual(summary["duration_ms"], 180_000) + self.assertTrue(summary["shuffle"]) + self.assertEqual(summary["repeat"], "context") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_library.py b/tests/test_cli_library.py new file mode 100644 index 0000000..44e6776 --- /dev/null +++ b/tests/test_cli_library.py @@ -0,0 +1,219 @@ +import unittest +from types import SimpleNamespace + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli.core import BATCH_ALBUMS, BATCH_FOLLOW, BATCH_TRACKS +from tests.cli_harness import CliTestCase + + +class LibraryTestCase(CliTestCase): + def library(self, *, saved=None): + """A library namespace that records every write and read it receives.""" + self.written = [] + self.checked = [] + + def writer(name): + async def write(ids): + self.written.append((name, list(ids))) + + return write + + def checker(name): + async def check(ids): + self.checked.append((name, list(ids))) + return [saved if saved is not None else True] * len(ids) + + return check + + return { + "library": SimpleNamespace( + save_tracks=writer("save_tracks"), + remove_tracks=writer("remove_tracks"), + check_tracks=checker("check_tracks"), + save_albums=writer("save_albums"), + remove_albums=writer("remove_albums"), + check_albums=checker("check_albums"), + ) + } + + +class TestSaveAndRemove(LibraryTestCase): + def test_ids_beyond_spotifys_limit_are_split_across_requests(self): + ids = [f"t{index}" for index in range(BATCH_TRACKS + 1)] + + self.run_json(["library", "save-tracks", ",".join(ids)], self.library()) + + chunks = [chunk for name, chunk in self.written if name == "save_tracks"] + self.assertEqual([len(chunk) for chunk in chunks], [BATCH_TRACKS, 1]) + self.assertEqual([item for chunk in chunks for item in chunk], ids) + + def test_albums_use_their_own_smaller_limit(self): + ids = [f"a{index}" for index in range(BATCH_ALBUMS + 1)] + + self.run_json(["library", "save-albums", ",".join(ids)], self.library()) + + chunks = [chunk for name, chunk in self.written if name == "save_albums"] + self.assertEqual([len(chunk) for chunk in chunks], [BATCH_ALBUMS, 1]) + + def test_a_single_id_makes_a_single_request(self): + self.run_json(["library", "save-tracks", "t1"], self.library()) + + self.assertEqual(self.written, [("save_tracks", ["t1"])]) + + def test_the_write_is_read_back_so_the_result_is_the_real_state(self): + rows = self.run_json(["library", "save-tracks", "t1,t2"], self.library()) + + self.assertEqual(self.checked, [("check_tracks", ["t1", "t2"])]) + self.assertEqual( + rows, [{"id": "t1", "saved": True}, {"id": "t2", "saved": True}] + ) + + def test_removing_reports_the_resulting_state_too(self): + rows = self.run_json( + ["library", "remove-tracks", "t1"], self.library(saved=False) + ) + + self.assertEqual(self.written, [("remove_tracks", ["t1"])]) + self.assertEqual(rows, [{"id": "t1", "saved": False}]) + + def test_checking_alone_never_writes(self): + rows = self.run_json(["library", "check-tracks", "t1 t2"], self.library()) + + self.assertEqual(self.written, []) + self.assertEqual([row["id"] for row in rows], ["t1", "t2"]) + + def test_write_and_read_scopes_are_both_requested(self): + from spotifyify import SpotifyScope + + self.run_json(["library", "save-tracks", "t1"], self.library()) + + self.assertEqual( + set(self.requested_scopes()), + {SpotifyScope.USER_LIBRARY_MODIFY, SpotifyScope.USER_LIBRARY_READ}, + ) + + def test_a_check_alone_asks_only_for_the_read_scope(self): + from spotifyify import SpotifyScope + + self.run_json(["library", "check-tracks", "t1"], self.library()) + + self.assertEqual(self.requested_scopes(), [SpotifyScope.USER_LIBRARY_READ]) + + +class TestSavedStatePairing(LibraryTestCase): + def test_ids_keep_their_own_flag(self): + async def check_tracks(ids): + return [True, False, True] + + namespaces = self.library() + namespaces["library"].check_tracks = check_tracks + + rows = self.run_json(["library", "check-tracks", "a,b,c"], namespaces) + + self.assertEqual( + rows, + [ + {"id": "a", "saved": True}, + {"id": "b", "saved": False}, + {"id": "c", "saved": True}, + ], + ) + + def test_a_short_answer_never_pairs_an_id_with_the_wrong_flag(self): + # Better to report fewer rows than to shift flags onto the wrong ids. + async def check_tracks(ids): + return [True] + + namespaces = self.library() + namespaces["library"].check_tracks = check_tracks + + rows = self.run_json(["library", "check-tracks", "a,b,c"], namespaces) + + self.assertEqual(rows, [{"id": "a", "saved": True}]) + + +class TestFollowing(CliTestCase): + def users(self): + self.written = [] + + def writer(name): + async def write(type, ids): + self.written.append((name, type, list(ids))) + + return write + + async def check_following(type, ids): + self.checked = (type, list(ids)) + return [True] * len(ids) + + return { + "users": SimpleNamespace( + follow=writer("follow"), + unfollow=writer("unfollow"), + check_following=check_following, + ) + } + + def test_the_type_is_forwarded_with_every_chunk(self): + ids = [f"a{index}" for index in range(BATCH_FOLLOW + 1)] + + self.run_json(["users", "follow", "artist", ",".join(ids)], self.users()) + + self.assertEqual( + [len(chunk) for _, _, chunk in self.written], [BATCH_FOLLOW, 1] + ) + self.assertEqual({type for _, type, _ in self.written}, {"artist"}) + + def test_following_is_read_back_after_the_write(self): + rows = self.run_json(["users", "follow", "artist", "a1"], self.users()) + + self.assertEqual(self.checked, ("artist", ["a1"])) + self.assertEqual(rows, [{"id": "a1", "following": True}]) + + def test_unfollow_uses_the_unfollow_endpoint(self): + self.run_json(["users", "unfollow", "artist", "a1"], self.users()) + + self.assertEqual(self.written, [("unfollow", "artist", ["a1"])]) + + +class TestBulkFetch(CliTestCase): + def test_a_bulk_read_is_split_and_rejoined_in_order(self): + requested = [] + + async def get_many(ids, **kwargs): + requested.append(list(ids)) + return [{"id": item_id, "name": item_id} for item_id in ids] + + ids = [f"t{index}" for index in range(BATCH_TRACKS + 2)] + + rows = self.run_json( + ["tracks", "get", ",".join(ids)], + {"tracks": SimpleNamespace(get_many=get_many)}, + ) + + self.assertEqual([len(chunk) for chunk in requested], [BATCH_TRACKS, 2]) + self.assertEqual([row["id"] for row in rows], ids) + + def test_the_market_reaches_every_chunk(self): + markets = [] + + async def get_many(ids, **kwargs): + markets.append(kwargs.get("market")) + return [{"id": item_id} for item_id in ids] + + ids = [f"t{index}" for index in range(BATCH_TRACKS + 1)] + + self.run_json( + ["--market", "DE", "tracks", "get", ",".join(ids)], + {"tracks": SimpleNamespace(get_many=get_many)}, + ) + + self.assertEqual(markets, ["DE", "DE"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py new file mode 100644 index 0000000..e782b77 --- /dev/null +++ b/tests/test_cli_options.py @@ -0,0 +1,221 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli import options +from spotifyify.exceptions import SpotifyAPIError, SpotifyAuthError, SpotifyifyError +from tests.cli_harness import CliTestCase + + +class TestErrorTranslation(CliTestCase): + """Expected API failures become exit codes, not tracebacks.""" + + def _failing(self, error): + async def find(query, **kwargs): + raise error + + return {"tracks": SimpleNamespace(find=find)} + + def test_an_api_error_exits_one_with_the_message_on_stderr(self): + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyAPIError(404, "Not found")), + ) + + self.assertEqual(result.exit_code, options.EXIT_API_ERROR) + self.assertIn("404: Not found", result.output) + + def test_an_auth_error_exits_three_so_callers_can_re_authenticate(self): + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyAuthError("token expired")), + ) + + self.assertEqual(result.exit_code, options.EXIT_AUTH_ERROR) + self.assertIn("token expired", result.output) + + def test_a_rate_limit_error_is_reported_as_an_api_error(self): + from spotifyify.exceptions import SpotifyRateLimitError + + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyRateLimitError("slow down", retry_after=1.0)), + ) + + self.assertEqual(result.exit_code, options.EXIT_API_ERROR) + + def test_the_two_error_exit_codes_stay_distinguishable(self): + self.assertNotEqual(options.EXIT_API_ERROR, options.EXIT_AUTH_ERROR) + + def test_a_failing_command_prints_no_partial_json(self): + result = self.run_cli( + ["tracks", "search", "x"], + self._failing(SpotifyAPIError(500, "boom")), + ) + + self.assertNotIn("[", result.stdout) + + def test_unexpected_errors_are_not_swallowed(self): + # Only the failures the CLI has a contract for get translated; anything + # else has to surface instead of masquerading as a clean exit. + async def find(query, **kwargs): + raise SpotifyifyError("something unmodelled") + + result = self.run_cli( + ["tracks", "search", "x"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertIsInstance(result.exception, SpotifyifyError) + + +class TestFieldSelection(CliTestCase): + def _tracks(self): + async def find(query, **kwargs): + return { + "items": [ + { + "id": "t1", + "name": "Track", + "artists": [{"name": "Ikkimel"}], + "album": {"name": "Album"}, + "uri": "spotify:track:t1", + "popularity": 73, + } + ] + } + + return {"tracks": SimpleNamespace(find=find)} + + def test_fields_replace_the_declared_columns(self): + rows = self.run_json(["tracks", "search", "x", "--field", "id"], self._tracks()) + + self.assertEqual(rows, [{"id": "t1"}]) + + def test_fields_can_be_repeated_or_comma_separated(self): + repeated = self.run_json( + ["tracks", "search", "x", "-f", "id", "-f", "name"], self._tracks() + ) + joined = self.run_json( + ["tracks", "search", "x", "-f", "id,name"], self._tracks() + ) + + self.assertEqual(repeated, joined) + self.assertEqual(list(repeated[0]), ["id", "name"]) + + def test_fields_reach_beyond_the_declared_columns(self): + rows = self.run_json( + ["tracks", "search", "x", "-f", "popularity"], self._tracks() + ) + + self.assertEqual(rows, [{"popularity": 73}]) + + def test_field_order_follows_the_command_line(self): + rows = self.run_json(["tracks", "search", "x", "-f", "name,id"], self._tracks()) + + self.assertEqual(list(rows[0]), ["name", "id"]) + + def test_fields_select_from_the_playback_summary_not_the_raw_state(self): + from spotifyify.schemas import PlaybackState + + async def state(**kwargs): + return PlaybackState.model_validate( + {"is_playing": True, "item": {"type": "track", "name": "HAMPELMANN"}} + ) + + rows = self.run_json( + ["player", "state", "-f", "state,track"], + {"player": SimpleNamespace(state=state)}, + ) + + self.assertEqual(rows, [{"state": "playing", "track": "HAMPELMANN"}]) + + def test_raw_output_ignores_the_field_selection(self): + result = self.run_cli( + ["tracks", "search", "x", "-f", "id"], + self._tracks(), + env={"SPOTIFYIFY_RAW": "1"}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("popularity", result.output) + + +class TestPrintResult(unittest.TestCase): + def test_projection_runs_before_the_columns_are_selected(self): + printed = [] + + with patch.object(options, "print_json", printed.append): + options.print_result( + {"is_playing": True}, + columns=("state",), + project=lambda value: {"state": "playing"}, + ) + + self.assertEqual(printed, [[{"state": "playing"}]]) + + def test_output_is_valid_json_even_with_no_items(self): + printed = [] + + with patch.object(options, "print_json", printed.append): + options.print_result({"items": []}, columns=("id",)) + + self.assertEqual(printed, [[]]) + + +class TestLimitBounds(CliTestCase): + def _tracks(self, seen): + async def find(query, **kwargs): + seen.update(kwargs) + return {"items": []} + + return {"tracks": SimpleNamespace(find=find)} + + def test_the_limit_reaches_the_client(self): + seen = {} + + self.run_json(["tracks", "search", "x", "--limit", "50"], self._tracks(seen)) + + self.assertEqual(seen["limit"], 50) + + def test_a_limit_beyond_what_spotify_accepts_is_rejected_before_any_call(self): + seen = {} + + result = self.run_cli( + ["tracks", "search", "x", "--limit", "51"], self._tracks(seen) + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(seen, {}) + + def test_a_zero_limit_is_rejected(self): + result = self.run_cli( + ["tracks", "search", "x", "--limit", "0"], self._tracks({}) + ) + + self.assertNotEqual(result.exit_code, 0) + + +class TestOutputEncoding(CliTestCase): + def test_non_ascii_names_are_emitted_unescaped(self): + async def find(query, **kwargs): + return {"items": [{"id": "t1", "name": "Grüße 東京"}]} + + result = self.run_cli( + ["tracks", "search", "x", "-f", "name"], + {"tracks": SimpleNamespace(find=find)}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("Grüße 東京", result.output) + self.assertEqual(json.loads(result.output)[0]["name"], "Grüße 東京") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_player.py b/tests/test_cli_player.py index 4bbdccc..aa3d85d 100644 --- a/tests/test_cli_player.py +++ b/tests/test_cli_player.py @@ -1,6 +1,7 @@ +import json import unittest from types import SimpleNamespace -from unittest.mock import AsyncMock, call +from unittest.mock import AsyncMock, call, patch try: import typer # noqa: F401 @@ -9,7 +10,8 @@ from spotifyify.cli.player import _play_with_device_fallback, _select_fallback_device from spotifyify.exceptions import SpotifyAPIError -from spotifyify.schemas import Device +from spotifyify.schemas import Device, PlaybackState +from tests.cli_harness import CliTestCase class TestDeviceFallback(unittest.IsolatedAsyncioTestCase): @@ -114,3 +116,246 @@ async def test_reraises_when_discovery_has_no_controllable_device(self): self.assertIs(raised.exception, error) self.assertEqual(player.play.await_count, 1) + + async def test_other_failures_are_not_mistaken_for_a_missing_device(self): + error = SpotifyAPIError(403, "Premium required") + player = SimpleNamespace(play=AsyncMock(side_effect=error), devices=AsyncMock()) + spotify = SimpleNamespace(player=player) + + with self.assertRaises(SpotifyAPIError) as raised: + await _play_with_device_fallback(spotify, device_id=None) + + self.assertIs(raised.exception, error) + player.devices.assert_not_awaited() + + +def _state(**overrides): + payload = {"is_playing": True, "progress_ms": 0, "device": {"name": "Wohnzimmer"}} + payload.update(overrides) + return PlaybackState.model_validate(payload) + + +class PlayerCommandTestCase(CliTestCase): + def player(self, states=None, **overrides): + """A player namespace recording its calls, answering with `states`.""" + self.calls = [] + answers = list(states or [_state()]) + + def recorder(name): + async def command(*args, **kwargs): + self.calls.append((name, args, kwargs)) + + return command + + async def state(**kwargs): + self.calls.append(("state", (), kwargs)) + return answers.pop(0) if len(answers) > 1 else answers[0] + + namespace = SimpleNamespace( + play=recorder("play"), + pause=recorder("pause"), + skip=recorder("skip"), + previous=recorder("previous"), + seek=recorder("seek"), + shuffle=recorder("shuffle"), + repeat=recorder("repeat"), + volume=recorder("volume"), + add_to_queue=recorder("add_to_queue"), + transfer=recorder("transfer"), + state=state, + ) + for name, value in overrides.items(): + setattr(namespace, name, value) + return {"player": namespace} + + def call_names(self): + return [name for name, _, _ in self.calls] + + +class TestPlaybackReporting(PlayerCommandTestCase): + def test_nothing_playing_is_reported_as_stopped(self): + async def state(**kwargs): + return None + + rows = self.run_json(["player", "state"], self.player(state=state)) + + self.assertEqual( + rows, [{"state": "stopped", "track": "", "artists": [], "device": ""}] + ) + + def test_pause_waits_for_playback_to_actually_stop(self): + states = [_state(), _state(is_playing=False)] + + with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): + rows = self.run_json(["player", "pause"], self.player(states)) + + self.assertEqual(self.call_names().count("state"), 2) + self.assertEqual(rows[0]["state"], "paused") + + def test_skip_waits_for_a_freshly_started_track(self): + states = [_state(progress_ms=90_000), _state(progress_ms=100)] + + with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): + self.run_json(["player", "skip"], self.player(states)) + + self.assertEqual(self.call_names(), ["skip", "state", "state"]) + + def test_volume_and_seek_report_state_without_waiting_for_it(self): + # There is nothing to wait for: neither changes what is playing. + self.run_json(["player", "volume", "40"], self.player()) + self.assertEqual(self.call_names(), ["volume", "state"]) + + self.run_json(["player", "seek", "1000"], self.player()) + self.assertEqual(self.call_names(), ["seek", "state"]) + + def test_transfer_with_play_waits_for_playback_to_start(self): + states = [_state(is_playing=False), _state()] + + with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): + rows = self.run_json( + ["player", "transfer", "kitchen", "--play"], self.player(states) + ) + + self.assertEqual(self.call_names().count("state"), 2) + self.assertEqual(rows[0]["state"], "playing") + + def test_transfer_without_play_has_nothing_to_wait_for(self): + self.run_json( + ["player", "transfer", "kitchen"], self.player([_state(is_playing=False)]) + ) + + self.assertEqual(self.call_names(), ["transfer", "state"]) + + +class TestPlayerArguments(PlayerCommandTestCase): + def test_the_root_device_flag_targets_every_playback_command(self): + self.run_json(["--device-id", "kitchen", "player", "pause"], self.player()) + + self.assertEqual(self.calls[0][2]["device_id"], "kitchen") + + def test_the_device_environment_variable_does_the_same(self): + self.run_json( + ["player", "pause"], self.player(), env={"SPOTIFYIFY_DEVICE_ID": "kitchen"} + ) + + self.assertEqual(self.calls[0][2]["device_id"], "kitchen") + + def test_uris_can_be_repeated_or_comma_separated(self): + self.run_json( + ["player", "play", "--uri", "spotify:track:a,spotify:track:b"], + self.player(), + ) + + self.assertEqual( + self.calls[0][2]["uris"], ["spotify:track:a", "spotify:track:b"] + ) + + def test_a_json_offset_is_forwarded_as_an_object(self): + self.run_json( + [ + "player", + "play", + "--context-uri", + "spotify:album:a1", + "--offset-json", + '{"position": 3}', + ], + self.player(), + ) + + self.assertEqual(self.calls[0][2]["offset"], {"position": 3}) + + def test_a_malformed_offset_is_rejected_before_anything_plays(self): + result = self.run_cli( + ["player", "play", "--offset-json", "{position: 3}"], self.player() + ) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.calls, []) + + def test_a_negative_position_is_rejected(self): + result = self.run_cli(["player", "seek", "--", "-1"], self.player()) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.calls, []) + + def test_a_volume_above_one_hundred_is_rejected(self): + result = self.run_cli(["player", "volume", "101"], self.player()) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.calls, []) + + +class TestQueueing(PlayerCommandTestCase): + def test_every_uri_is_queued_in_the_order_it_was_given(self): + self.run_json( + [ + "player", + "add-to-queue", + "spotify:track:a,spotify:track:b", + "spotify:track:c", + ], + self.player(), + ) + + queued = [args[0] for name, args, _ in self.calls if name == "add_to_queue"] + self.assertEqual( + queued, ["spotify:track:a", "spotify:track:b", "spotify:track:c"] + ) + + def test_the_queue_is_listed_with_its_declared_columns(self): + async def queue(): + return { + "queue": [ + { + "id": "t1", + "name": "HAMPELMANN", + "artists": [{"name": "Ikkimel"}], + "uri": "spotify:track:t1", + "available_markets": ["DE"], + } + ] + } + + rows = self.run_json(["player", "queue"], self.player(queue=queue)) + + self.assertEqual(list(rows[0]), ["id", "name", "artists", "uri"]) + self.assertEqual(rows[0]["artists"], ["Ikkimel"]) + + +class TestDeviceListing(PlayerCommandTestCase): + DEVICES = [ + Device(id="z", name="Workstation", type="Computer"), + Device(id="a", name="Desktop", type="Computer"), + ] + + def devices(self): + async def devices(): + return list(self.DEVICES) + + return self.player(devices=devices) + + def test_devices_are_ordered_reproducibly(self): + # Spotify returns devices in no defined order, so repeated calls (and + # anything cached on top of them) would otherwise differ run to run. + rows = self.run_json(["player", "devices"], self.devices()) + + self.assertEqual([row["id"] for row in rows], ["a", "z"]) + + def test_raw_output_keeps_the_order_spotify_sent(self): + result = self.run_cli( + ["player", "devices"], self.devices(), env={"SPOTIFYIFY_RAW": "1"} + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + [device["id"] for device in json.loads(result.output)], ["z", "a"] + ) + + def test_no_devices_is_an_empty_list_not_an_error(self): + async def devices(): + return [] + + rows = self.run_json(["player", "devices"], self.player(devices=devices)) + + self.assertEqual(rows, []) diff --git a/tests/test_cli_quick.py b/tests/test_cli_quick.py new file mode 100644 index 0000000..417c31d --- /dev/null +++ b/tests/test_cli_quick.py @@ -0,0 +1,275 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +try: + import typer # noqa: F401 +except ModuleNotFoundError: + raise unittest.SkipTest("typer is an optional CLI dependency") from None + +from spotifyify.cli import quick +from spotifyify.schemas import PlaybackState +from tests.cli_harness import CliTestCase + + +class TestQueryBuilding(unittest.TestCase): + def test_filters_are_emitted_in_track_artist_album_order(self): + query = quick._build_query( + None, track="WHO'S THAT", artist="Ikkimel", album="Chaos" + ) + + self.assertEqual(query, 'track:"WHO\'S THAT" artist:"Ikkimel" album:"Chaos"') + + def test_free_text_follows_the_filters(self): + query = quick._build_query( + ["live", "session"], track=None, artist="Ikkimel", album=None + ) + + self.assertEqual(query, 'artist:"Ikkimel" live session') + + def test_quotes_inside_a_value_cannot_break_out_of_the_filter(self): + # An unescaped quote would end the filter and turn the rest into free + # text, silently searching for something else entirely. + query = quick._build_query(None, track='a" artist:"b', artist=None, album=None) + + self.assertEqual(query, 'track:"a artist: b"') + self.assertEqual(query.count('"'), 2) + + def test_surrounding_whitespace_is_trimmed(self): + self.assertEqual( + quick._build_query(None, track=" Track ", artist=None, album=None), + 'track:"Track"', + ) + + def test_nothing_given_produces_an_empty_query(self): + self.assertEqual( + quick._build_query(None, track=None, artist=None, album=None), "" + ) + self.assertEqual( + quick._build_query([], track=None, artist=None, album=None), "" + ) + + def test_first_hit_of_an_empty_result_is_none(self): + self.assertIsNone(quick._first(SimpleNamespace(items=[]))) + self.assertIsNone(quick._first(SimpleNamespace(items=None))) + self.assertIsNone(quick._first(None)) + + +def _playing(name="HAMPELMANN", uri="spotify:track:t1"): + return PlaybackState.model_validate( + { + "is_playing": True, + "progress_ms": 0, + "device": {"name": "Wohnzimmer"}, + "item": { + "type": "track", + "name": name, + "uri": uri, + "artists": [{"name": "Ikkimel"}], + }, + } + ) + + +class QuickPlayTestCase(CliTestCase): + """Records what the top-level play command resolved and started.""" + + def namespaces(self, *, tracks=None, albums=None, artists=None): + self.searched = {} + self.played = {} + + def finder(kind, hit): + async def find(query, **kwargs): + self.searched[kind] = {"query": query, **kwargs} + return SimpleNamespace(items=[hit] if hit is not None else []) + + return find + + async def play(**kwargs): + self.played.update(kwargs) + + async def state(**kwargs): + return _playing() + + return { + "tracks": SimpleNamespace(find=finder("tracks", tracks)), + "albums": SimpleNamespace(find=finder("albums", albums)), + "artists": SimpleNamespace(find=finder("artists", artists)), + "player": SimpleNamespace(play=play, state=state), + } + + +class TestWhatGetsPlayed(QuickPlayTestCase): + def test_a_track_name_plays_that_one_track(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "--track", "WHO'S THAT"], namespaces) + + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + self.assertIsNone(self.played["context_uri"]) + + def test_free_text_is_treated_as_a_track_search(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "hampelmann"], namespaces) + + self.assertEqual(self.searched["tracks"]["query"], "hampelmann") + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + + def test_an_album_without_a_track_becomes_the_playback_context(self): + namespaces = self.namespaces(albums=SimpleNamespace(uri="spotify:album:a1")) + + self.run_json(["play", "--album", "Chaos"], namespaces) + + self.assertEqual(self.played["context_uri"], "spotify:album:a1") + self.assertIsNone(self.played["uris"]) + self.assertNotIn("tracks", self.searched) + + def test_an_artist_alone_becomes_the_playback_context(self): + namespaces = self.namespaces(artists=SimpleNamespace(uri="spotify:artist:a1")) + + self.run_json(["play", "--artist", "Ikkimel"], namespaces) + + self.assertEqual(self.played["context_uri"], "spotify:artist:a1") + self.assertIsNone(self.played["uris"]) + + def test_a_track_narrowed_by_artist_still_plays_the_track(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json( + ["play", "--artist", "Ikkimel", "--track", "WHO'S THAT"], namespaces + ) + + self.assertEqual( + self.searched["tracks"]["query"], 'track:"WHO\'S THAT" artist:"Ikkimel"' + ) + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + + def test_an_album_narrowed_by_free_text_plays_the_track(self): + # Free text names one thing, so the album is only a filter here. + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "hampelmann", "--album", "Chaos"], namespaces) + + self.assertEqual(self.searched["tracks"]["query"], 'album:"Chaos" hampelmann') + self.assertEqual(self.played["uris"], ["spotify:track:t1"]) + + def test_only_one_hit_is_fetched(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "--track", "x"], namespaces) + + self.assertEqual(self.searched["tracks"]["limit"], 1) + + +class TestQuickPlayFailures(QuickPlayTestCase): + def test_no_match_exits_four_and_never_starts_playback(self): + namespaces = self.namespaces(tracks=None) + + result = self.run_cli(["play", "--track", "nothing at all"], namespaces) + + self.assertEqual(result.exit_code, quick.EXIT_NO_MATCH) + self.assertIn("No match", result.output) + self.assertEqual(self.played, {}) + + def test_an_artist_search_without_a_hit_also_exits_four(self): + result = self.run_cli(["play", "--artist", "nobody"], self.namespaces()) + + self.assertEqual(result.exit_code, quick.EXIT_NO_MATCH) + + def test_nothing_to_search_for_is_a_usage_error(self): + result = self.run_cli(["play"], self.namespaces()) + + self.assertNotEqual(result.exit_code, 0) + self.assertEqual(self.searched, {}) + + +class TestQuickPlayDefaults(QuickPlayTestCase): + def test_the_root_market_flag_reaches_the_search(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["--market", "DE", "play", "--track", "x"], namespaces) + + self.assertEqual(self.searched["tracks"]["market"], "DE") + + def test_the_market_environment_variable_reaches_the_search(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json( + ["play", "--track", "x"], namespaces, env={"SPOTIFYIFY_MARKET": "DE"} + ) + + self.assertEqual(self.searched["tracks"]["market"], "DE") + + def test_the_root_device_flag_targets_playback(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["--device-id", "kitchen", "play", "--track", "x"], namespaces) + + self.assertEqual(self.played["device_id"], "kitchen") + + def test_an_artist_search_is_not_narrowed_by_market(self): + # Spotify's artist search takes no market, so passing one would 400. + namespaces = self.namespaces(artists=SimpleNamespace(uri="spotify:artist:a1")) + + self.run_json(["--market", "DE", "play", "--artist", "Ikkimel"], namespaces) + + self.assertNotIn("market", self.searched["artists"]) + + +class TestQuickPlayReporting(QuickPlayTestCase): + def test_the_resolved_track_is_reported_not_the_previous_one(self): + states = [_playing("Amber Dusk", "spotify:track:old"), _playing()] + + async def state(**kwargs): + return states.pop(0) if len(states) > 1 else states[0] + + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + namespaces["player"].state = state + + with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): + rows = self.run_json(["play", "--track", "x"], namespaces) + + self.assertEqual(rows[0]["track"], "HAMPELMANN") + + def test_no_wait_reports_the_first_read(self): + reads = [] + + async def state(**kwargs): + reads.append(1) + return _playing("Amber Dusk", "spotify:track:old") + + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + namespaces["player"].state = state + + rows = self.run_json(["play", "--track", "x", "--no-wait"], namespaces) + + self.assertEqual(len(reads), 1) + self.assertEqual(rows[0]["track"], "Amber Dusk") + + def test_playback_columns_are_reported(self): + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + rows = self.run_json(["play", "--track", "x"], namespaces) + + self.assertEqual(list(rows[0]), ["state", "track", "artists", "device"]) + + def test_it_asks_for_both_playback_scopes(self): + from spotifyify import SpotifyScope + + namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) + + self.run_json(["play", "--track", "x"], namespaces) + + # Reporting the resulting state needs the read scope alongside modify. + self.assertEqual( + set(self.requested_scopes()), + { + SpotifyScope.USER_MODIFY_PLAYBACK_STATE, + SpotifyScope.USER_READ_PLAYBACK_STATE, + }, + ) + + +if __name__ == "__main__": + unittest.main() From d6778022950a75cf57849cd4b9b8e29413f759de Mon Sep 17 00:00:00 2001 From: mathisarends Date: Sun, 26 Jul 2026 20:59:17 +0200 Subject: [PATCH 12/12] Test cli even more --- tests/cli_harness.py | 6 ++++++ tests/test_cli.py | 40 +++++++--------------------------------- tests/test_cli_player.py | 15 ++++++--------- tests/test_cli_quick.py | 4 +--- 4 files changed, 20 insertions(+), 45 deletions(-) diff --git a/tests/cli_harness.py b/tests/cli_harness.py index 5de25f6..9a47c52 100644 --- a/tests/cli_harness.py +++ b/tests/cli_harness.py @@ -36,6 +36,12 @@ def setUp(self): self.skipTest("typer is optional") from typer.testing import CliRunner + # Commands that wait for playback to settle poll with a real delay; a + # fake client answers instantly, so the wait is pure test runtime. + delay = patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0) + delay.start() + self.addCleanup(delay.stop) + self.runner = CliRunner() def run_cli(self, args, namespaces=None, env=None): diff --git a/tests/test_cli.py b/tests/test_cli.py index b79692d..1d771b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from spotifyify import cli from spotifyify.schemas import PlaybackState +from tests.cli_harness import CliTestCase class TestCli(unittest.TestCase): @@ -231,35 +232,9 @@ def test_get_is_the_only_bulk_fetch_command_name(self): self.assertIsNone(artists.get_command(context, "get-many")) -class FakeSpotifyify: - """Stands in for the real client inside the CLI's async runner.""" - - namespaces: dict = {} - - def __init__(self, **kwargs): - self.scopes = kwargs.get("scopes") - for name, value in self.namespaces.items(): - setattr(self, name, value) - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc_info): - return False - - -class TestEndToEnd(unittest.TestCase): - def setUp(self): - if cli.typer is None: - self.skipTest("typer is optional") - from typer.testing import CliRunner - - self.runner = CliRunner() - +class TestEndToEnd(CliTestCase): def _run(self, args, namespaces, env=None): - FakeSpotifyify.namespaces = namespaces - with patch("spotifyify.cli.core.Spotifyify", FakeSpotifyify): - return self.runner.invoke(cli.app, args, env=env) + return self.run_cli(args, namespaces, env=env) def test_search_prints_declared_columns_as_json_by_default(self): async def find(query, **kwargs): @@ -450,11 +425,10 @@ async def play(**kwargs): async def state(**kwargs): return PlaybackState.model_validate(states.pop(0) if states else states) - with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): - result = self._run( - ["player", "play", "--uri", "spotify:track:new"], - {"player": SimpleNamespace(play=play, state=state)}, - ) + result = self._run( + ["player", "play", "--uri", "spotify:track:new"], + {"player": SimpleNamespace(play=play, state=state)}, + ) self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(json.loads(result.output)[0]["track"], "HAMPELMANN") diff --git a/tests/test_cli_player.py b/tests/test_cli_player.py index aa3d85d..51a6489 100644 --- a/tests/test_cli_player.py +++ b/tests/test_cli_player.py @@ -1,7 +1,7 @@ import json import unittest from types import SimpleNamespace -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, call try: import typer # noqa: F401 @@ -186,8 +186,7 @@ async def state(**kwargs): def test_pause_waits_for_playback_to_actually_stop(self): states = [_state(), _state(is_playing=False)] - with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): - rows = self.run_json(["player", "pause"], self.player(states)) + rows = self.run_json(["player", "pause"], self.player(states)) self.assertEqual(self.call_names().count("state"), 2) self.assertEqual(rows[0]["state"], "paused") @@ -195,8 +194,7 @@ def test_pause_waits_for_playback_to_actually_stop(self): def test_skip_waits_for_a_freshly_started_track(self): states = [_state(progress_ms=90_000), _state(progress_ms=100)] - with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): - self.run_json(["player", "skip"], self.player(states)) + self.run_json(["player", "skip"], self.player(states)) self.assertEqual(self.call_names(), ["skip", "state", "state"]) @@ -211,10 +209,9 @@ def test_volume_and_seek_report_state_without_waiting_for_it(self): def test_transfer_with_play_waits_for_playback_to_start(self): states = [_state(is_playing=False), _state()] - with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): - rows = self.run_json( - ["player", "transfer", "kitchen", "--play"], self.player(states) - ) + rows = self.run_json( + ["player", "transfer", "kitchen", "--play"], self.player(states) + ) self.assertEqual(self.call_names().count("state"), 2) self.assertEqual(rows[0]["state"], "playing") diff --git a/tests/test_cli_quick.py b/tests/test_cli_quick.py index 417c31d..79b15d3 100644 --- a/tests/test_cli_quick.py +++ b/tests/test_cli_quick.py @@ -1,6 +1,5 @@ import unittest from types import SimpleNamespace -from unittest.mock import patch try: import typer # noqa: F401 @@ -227,8 +226,7 @@ async def state(**kwargs): namespaces = self.namespaces(tracks=SimpleNamespace(uri="spotify:track:t1")) namespaces["player"].state = state - with patch("spotifyify.cli.core.SETTLE_DELAY_SECONDS", 0): - rows = self.run_json(["play", "--track", "x"], namespaces) + rows = self.run_json(["play", "--track", "x"], namespaces) self.assertEqual(rows[0]["track"], "HAMPELMANN")