Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create initial framework for configuration files
- Loading branch information
1 parent
452885d
commit 5a0b0c4
Showing
3 changed files
with
143 additions
and
0 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
"""Reading and writing user configuration and secrets.""" | ||
from __future__ import annotations | ||
|
||
from pathlib import Path | ||
import typing as t | ||
|
||
import attr | ||
import toml | ||
|
||
from stlt.errors import ConfigError | ||
|
||
|
||
EMPTY_CONFIG_FILE = { | ||
"oauth": { | ||
"client_id": "CLIENT_ID", | ||
"client_secret": "CLIENT_SECRET", | ||
"redirect_uri": "REDIRECT_URI", | ||
"scope": "SCOPE", | ||
} | ||
} | ||
|
||
|
||
@attr.s(slots=True) | ||
class OAuthConfig: | ||
"""Configuration data class for `SpotifyOAuth`.""" | ||
|
||
client_id: str = attr.ib() | ||
client_secret: str = attr.ib() | ||
redirect_uri: str = attr.ib() | ||
scope: str = attr.ib() | ||
|
||
@classmethod | ||
def from_dict(cls, data: t.Mapping) -> OAuthConfig: | ||
"""Create an `OAuthConfig` from a `toml`-based mapping.""" | ||
fields = {} | ||
for field in attr.fields(cls): | ||
name = field.name | ||
try: | ||
fields[name] = data[name] | ||
except KeyError: | ||
raise ConfigError(f"Missing required key '{name}'") from None | ||
return cls(**fields) | ||
|
||
|
||
@attr.s(slots=True) | ||
class Config: | ||
"""Configuration data class for the project.""" | ||
|
||
oauth: OAuthConfig = attr.ib() | ||
|
||
@classmethod | ||
def from_dict(cls, data: t.Mapping) -> Config: | ||
"""Create a `Config` from a `toml`-based mapping.""" | ||
builders = { | ||
"oauth": OAuthConfig.from_dict, | ||
} | ||
fields = {} | ||
for field in attr.fields(cls): | ||
name = field.name | ||
try: | ||
fields[name] = builders[name](data[name]) | ||
except KeyError: | ||
raise ConfigError(f"Missing required section '{name}'") from None | ||
return cls(**fields) | ||
|
||
|
||
def ensure_config(config: Path) -> None: | ||
"""Ensure that the `config` file exists and is valid.""" | ||
config.parent.mkdir(parents=True, exist_ok=True) | ||
config.touch(exist_ok=True) | ||
|
||
with config.open("w") as f: | ||
toml.dump(EMPTY_CONFIG_FILE, f) | ||
|
||
|
||
def load_config(config: Path) -> t.Mapping: | ||
"""Deserialize the `config` file into a `Mapping`.""" | ||
ensure_config(config) | ||
return toml.load(config) |
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.""" |
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,55 @@ | ||
import pytest | ||
import toml | ||
|
||
from stlt.config import ( | ||
Config, | ||
EMPTY_CONFIG_FILE, | ||
OAuthConfig, | ||
ensure_config, | ||
load_config, | ||
) | ||
from stlt.errors import ConfigError | ||
|
||
|
||
@pytest.fixture | ||
def path(tmp_path): | ||
return tmp_path / ".config" / "config.toml" | ||
|
||
|
||
class TestEnsureConfig: | ||
def test_creates_a_placeholder_configuration_file(self, path): | ||
ensure_config(path) | ||
|
||
assert toml.load(path) == EMPTY_CONFIG_FILE | ||
|
||
def test_creates_parent_folders(self, path): | ||
ensure_config(path) | ||
|
||
assert path.parent.exists() | ||
|
||
|
||
class TestLoadConfig: | ||
def test_loads_a_placeholder_configuration_file(self, path): | ||
assert load_config(path) == EMPTY_CONFIG_FILE | ||
|
||
|
||
class TestOAuthConfig: | ||
def test_from_dict_parses_a_valid_mapping(self, path): | ||
expected = OAuthConfig(**EMPTY_CONFIG_FILE["oauth"]) | ||
|
||
assert OAuthConfig.from_dict(EMPTY_CONFIG_FILE["oauth"]) == expected | ||
|
||
def test_from_dict_fails_on_missing_keys(self, path): | ||
with pytest.raises(ConfigError, match="Missing required key"): | ||
OAuthConfig.from_dict({}) | ||
|
||
|
||
class TestConfig: | ||
def test_from_dict_parses_a_valid_mapping(self, path): | ||
expected = Config(oauth=OAuthConfig.from_dict(EMPTY_CONFIG_FILE["oauth"])) | ||
|
||
assert Config.from_dict(EMPTY_CONFIG_FILE) == expected | ||
|
||
def test_from_dict_fails_on_missing_sections(self, path): | ||
with pytest.raises(ConfigError, match="Missing required section"): | ||
Config.from_dict({}) |