Skip to content

Commit

Permalink
Create initial project implementation (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
purefunctor committed Jun 14, 2021
2 parents 452885d + 9d9dd17 commit fc89dc1
Show file tree
Hide file tree
Showing 19 changed files with 642 additions and 16 deletions.
2 changes: 1 addition & 1 deletion .flake8
@@ -1,5 +1,5 @@
[flake8]
exclude = .git,.nox,noxfile.py,__pycache__,tests
exclude = .git,.nox,noxfile.py,__pycache__
max-line-length=88
max-doc-length=72
ignore = ANN101,ANN102
Expand Down
10 changes: 7 additions & 3 deletions .pre-commit-config.yaml
Expand Up @@ -4,8 +4,12 @@ repos:
hooks:
- id: trailing-whitespace
- id: mixed-line-ending
- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
- repo: local
hooks:
- id: flake8
exclude: tests
name: Flake8
description: Runs flake8 including extensions
entry: poetry run flake8
language: system
types: [python]
require_serial: true
7 changes: 1 addition & 6 deletions CHANGELOG.md
Expand Up @@ -5,10 +5,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
Breaking changes:

New features:

Bugfixes:

Other improvements:
* Created initial implementation of the command-line interface. (#2)
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -22,10 +22,10 @@ install `pre-commit` hooks when developing the project:
$ poetry run task pre-commit
```

Test your changes; the `cov` task allows you to run tests and generate coverage data that you can
Test your changes; the `test` task allows you to run tests and generate coverage data that you can
view:
```sh
$ poetry run task cov
$ poetry run task test
```

Lint and format your code; the `lint` task reports any linting errors through `flake8` while the
Expand Down
5 changes: 3 additions & 2 deletions noxfile.py
Expand Up @@ -44,15 +44,16 @@ def flake8(session):
session.run(
"flake8",
"stlt",
"tests",
"--format=::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s",
)
else:
session.run("flake8", "stlt")
session.run("flake8", "stlt", "tests")


@nox.session
def test(session):
install_with_constraints(session, "pytest", "coverage[toml]")
install_with_constraints(session, "pytest", "pytest_mock", "coverage[toml]")
session.install(".")
session.run("coverage", "run", "--branch", "-m", "pytest", "-vs")
session.run("coverage", "report", "-m")
35 changes: 34 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pyproject.toml
Expand Up @@ -12,6 +12,7 @@ packages = [
python = "^3.9"
attrs = "^21.2.0"
click = "^8.0.1"
humanize = "^3.7.1"
rich = "^10.2.2"
spotipy = "^2.18.0"
toml = "^0.10.2"
Expand All @@ -27,6 +28,10 @@ mypy = "^0.812"
pre-commit = "^2.13.0"
pytest = "^6.2.4"
taskipy = "^1.8.1"
pytest-mock = "^3.6.1"

[tool.poetry.scripts]
stlt = "stlt.cli:stlt"

[tool.coverage.run]
source = ["stlt"]
Expand Down
7 changes: 7 additions & 0 deletions stlt/__main__.py
@@ -0,0 +1,7 @@
"""Entry-point for the command-line interface."""

from stlt.cli import stlt


if __name__ == "__main__":
stlt()
1 change: 1 addition & 0 deletions stlt/assets/__init__.py
@@ -0,0 +1 @@
# noqa: D104
8 changes: 8 additions & 0 deletions stlt/assets/config.toml
@@ -0,0 +1,8 @@
[oauth]
client_id = "CLIENT_ID"
client_secret = "CLIENT_SECRET"
redirect_uri = "REDIRECT_URI"
scope = "SCOPE"

[cache]
auth_cache = "CACHE"
75 changes: 75 additions & 0 deletions stlt/cli.py
@@ -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()
106 changes: 106 additions & 0 deletions stlt/config.py
@@ -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
6 changes: 6 additions & 0 deletions stlt/constants.py
@@ -0,0 +1,6 @@
"""Application-wide constants."""

from pathlib import Path


CONFIG_FILE_PATH = Path.home() / ".config" / "stlt" / "config.toml"
9 changes: 9 additions & 0 deletions stlt/errors.py
@@ -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."""

0 comments on commit fc89dc1

Please sign in to comment.