-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create initial project implementation (#2)
- Loading branch information
Showing
19 changed files
with
642 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
"""Entry-point for the command-line interface.""" | ||
|
||
from stlt.cli import stlt | ||
|
||
|
||
if __name__ == "__main__": | ||
stlt() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
# noqa: D104 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
[oauth] | ||
client_id = "CLIENT_ID" | ||
client_secret = "CLIENT_SECRET" | ||
redirect_uri = "REDIRECT_URI" | ||
scope = "SCOPE" | ||
|
||
[cache] | ||
auth_cache = "CACHE" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
"""Command-line interface using `click`.""" | ||
|
||
from pathlib import Path | ||
|
||
import attr | ||
import click | ||
from rich import print | ||
from spotipy import Spotify # type: ignore | ||
from spotipy.cache_handler import CacheFileHandler # type: ignore | ||
from spotipy.oauth2 import SpotifyOAuth # type: ignore | ||
|
||
from stlt.config import load_config | ||
from stlt.constants import CONFIG_FILE_PATH | ||
from stlt.view import create_album_view, create_track_view | ||
|
||
|
||
pass_spotify = click.make_pass_decorator(Spotify) | ||
|
||
|
||
@click.group( | ||
name="stlt", help="A command-line tool for getting songs to listen to in Spotify." | ||
) | ||
@click.option( | ||
"--config-file", | ||
default=CONFIG_FILE_PATH, | ||
help=f"Override the default config file path ({CONFIG_FILE_PATH}).", | ||
type=Path, | ||
) | ||
@click.pass_context | ||
def stlt(context: click.Context, config_file: Path) -> None: # noqa: D103 | ||
context.ensure_object(dict) | ||
config = load_config(config_file) | ||
context.obj = Spotify( | ||
auth_manager=SpotifyOAuth( | ||
**attr.asdict(config.oauth), | ||
cache_handler=CacheFileHandler(config.cache.auth_cache), | ||
) | ||
) | ||
|
||
|
||
@stlt.group(name="saved") | ||
@pass_spotify | ||
def saved(client: Spotify) -> None: | ||
"""Query the user's saved items.""" | ||
|
||
|
||
@saved.command(name="albums") | ||
@click.option("-l", "--limit", default=20) | ||
@click.option("-c", "--columns", default=3) | ||
@pass_spotify | ||
def saved_albums(client: Spotify, limit: int, columns: int) -> None: | ||
"""List the user's saved albums.""" | ||
response = client.current_user_saved_albums(limit=limit) | ||
print(create_album_view(response["items"], columns=columns)) | ||
|
||
|
||
@saved.command(name="tracks") | ||
@click.option("-l", "--limit", default=20) | ||
@click.option("-c", "--columns", default=3) | ||
@pass_spotify | ||
def saved_tracks(client: Spotify, limit: int, columns: int) -> None: | ||
"""List the user's saved tracks.""" | ||
response = client.current_user_saved_tracks(limit=limit) | ||
print(create_track_view(response["items"], columns=columns)) | ||
|
||
|
||
@stlt.command(name="login") | ||
@pass_spotify | ||
def login(client: Spotify) -> None: | ||
"""Log in using Spotify OAuth.""" | ||
if client.auth_manager.cache_handler.get_cached_token() is not None: | ||
click.echo("Logged in!") | ||
else: | ||
click.echo("Logging you in...") | ||
client.auth_manager.get_access_token() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
"""Reading and writing user configuration and secrets.""" | ||
from __future__ import annotations | ||
|
||
from abc import ABC | ||
from importlib import resources | ||
from pathlib import Path | ||
import typing as t | ||
|
||
import attr | ||
import toml | ||
|
||
from stlt import assets | ||
from stlt.errors import ConfigError | ||
|
||
|
||
DEFAULT_CONFIG_FILE = toml.loads(resources.read_text(assets, "config.toml")) | ||
|
||
|
||
_FromTomlType = t.TypeVar("_FromTomlType", bound="_FromToml") | ||
|
||
|
||
class _FromToml(ABC): | ||
"""Implements deserialization from `toml` into `attrs`.""" | ||
|
||
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: # pragma: no cover | ||
... | ||
|
||
@classmethod | ||
def from_dict(cls: t.Type[_FromTomlType], data: t.Mapping) -> _FromTomlType: | ||
"""Deserialize some `data` into an `attrs`-based `cls`.""" | ||
kwargs = {} | ||
|
||
for field in attr.fields(cls): | ||
name = field.name | ||
meta = field.metadata | ||
|
||
try: | ||
if section := meta.get("section", False): | ||
kwargs[name] = _nested_get(data, section) | ||
else: | ||
kwargs[name] = data[name] | ||
except KeyError as e: | ||
if "section" in meta: | ||
err = f"Missing required section {e.args[0]}" | ||
else: | ||
err = f"Missing required key {name}" | ||
raise ConfigError(err) | ||
|
||
if builder := meta.get("builder", False): | ||
kwargs[name] = builder(kwargs[name]) | ||
|
||
return cls(**kwargs) | ||
|
||
|
||
@attr.s(slots=True) | ||
class OAuthConfig(_FromToml): | ||
"""Configuration data class for `SpotifyOAuth`.""" | ||
|
||
client_id: str = attr.ib() | ||
client_secret: str = attr.ib() | ||
redirect_uri: str = attr.ib() | ||
scope: str = attr.ib() | ||
|
||
|
||
@attr.s | ||
class CacheConfig(_FromToml): | ||
"""Configuration data class for the cache.""" | ||
|
||
auth_cache: Path = attr.ib(converter=Path) | ||
|
||
|
||
@attr.s(slots=True) | ||
class Config(_FromToml): | ||
"""Configuration data class for the project.""" | ||
|
||
oauth: OAuthConfig = attr.ib( | ||
metadata={"section": ["oauth"], "builder": OAuthConfig.from_dict} | ||
) | ||
|
||
cache: CacheConfig = attr.ib( | ||
metadata={"section": ["cache"], "builder": CacheConfig.from_dict} | ||
) | ||
|
||
|
||
def ensure_config(config: Path) -> None: | ||
"""Ensure that the `config` file exists and is valid.""" | ||
config.parent.mkdir(parents=True, exist_ok=True) | ||
|
||
if config.exists(): | ||
return None | ||
|
||
with config.open("w") as f: | ||
toml.dump(DEFAULT_CONFIG_FILE, f) | ||
|
||
|
||
def load_config(config: Path) -> Config: | ||
"""Deserialize the `config` file into a `Config`.""" | ||
ensure_config(config) | ||
return Config.from_dict(toml.load(config)) | ||
|
||
|
||
def _nested_get(mapping: t.Mapping[str, t.Any], keys: list[str]) -> t.Any: | ||
current = mapping | ||
for key in keys: | ||
current = current[key] | ||
return current |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
"""Application-wide constants.""" | ||
|
||
from pathlib import Path | ||
|
||
|
||
CONFIG_FILE_PATH = Path.home() / ".config" / "stlt" / "config.toml" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
"""Error types that appear within the library.""" | ||
|
||
|
||
class StltError(Exception): | ||
"""Base error type for the library.""" | ||
|
||
|
||
class ConfigError(Exception): | ||
"""Error type raised on configuration.""" |
Oops, something went wrong.