Significantly overhaul configuration management

Everything now uses bog-standard Python dataclasses, with Pydantic
providing validation and type conversion through separate classes using
its type adapter feature. It's also possible to define your classes
using Pydantic's own model type directly, making the type adapter
unnecessary, but I didn't want to do things that way because no actual
validation is needed when constructing a Song instance for example.
Having Pydantic do its thing only on-demand was preferable.

I tried a number of validation libraries before settling on Pydantic for
this. It's not the fastest option out there (msgspec is I think), but it
makes adding support for third-party types like yarl.URL really easy, it
generates a nice clean JSON Schema which is easy enough to adjust to my
requirements through its GenerateJsonSchema hooks, and raw speed isn't
all that important anyway since this is a single-user desktop program
that reads its configuration file once on startup.

Also, MessagePack is now mandatory if you're caching to an external
service. It just didn't make a whole lot sense to explicitly install
mpd-now-playable's Redis or Memcached support and then use pickling with
them.

With all this fussing around done, I'm probably finally ready to
actually use that configuration file to configure new features! Yay!
This commit is contained in:
Danielle McLean 2024-07-01 00:10:17 +10:00
parent 3b7ddfa718
commit 27d8c37139
Signed by: 00dani
GPG key ID: 6854781A0488421C
18 changed files with 355 additions and 169 deletions

View file

@ -9,8 +9,8 @@ The recommended way to install mpd-now-playable and its dependencies is with [pi
```shell
pipx install mpd-now-playable
# or, if you'd like to use a separate cache service, one of these:
pipx install mpd-now-playable[redis,msgpack]
pipx install mpd-now-playable[memcached,msgpack]
pipx install mpd-now-playable[redis]
pipx install mpd-now-playable[memcached]
```
Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use.

123
pdm.lock
View file

@ -5,7 +5,7 @@
groups = ["default", "all", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:73f93e2fcfc5fc5af9acfdc9604b9f27d3eb5d9c6012f600c671523c2f78bf4c"
content_hash = "sha256:5a1cfd2988fd81c4b8743daaf399bbeb9f1f9d78e161901f0bf76c771ec8052f"
[[package]]
name = "aiocache"
@ -55,19 +55,13 @@ files = [
]
[[package]]
name = "apischema"
version = "0.18.1"
requires_python = ">=3.7"
summary = "JSON (de)serialization, GraphQL and JSON schema generation using Python typing."
name = "annotated-types"
version = "0.7.0"
requires_python = ">=3.8"
summary = "Reusable constraint types to use with typing.Annotated"
files = [
{file = "apischema-0.18.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:220aa56974f765dc100e875c66c688ff57bad3ae48d0aeaee4fb1ec90c5cd0fd"},
{file = "apischema-0.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:653492010d22acdcbe2240f0ceb3ca59de1b6d34640895e03fda15f944e216d8"},
{file = "apischema-0.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56a8acc8da59cf7a1052b5aec71d2f8ed6137b54aa1492709acb2c47f0547107"},
{file = "apischema-0.18.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d466ccc31cbc95b381037ed5b9e82e034f921eb28f8057263aefc2817678036f"},
{file = "apischema-0.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b005d5a4baba32eccec5bb0fbd616f9fd26b6a5fb4f15e9a8bb53a948adade0"},
{file = "apischema-0.18.1-cp312-cp312-win32.whl", hash = "sha256:bd06fc6a52d461bd6540409cb25c1d51aae23b22fcd10b1fb002a3f7f1f15d0f"},
{file = "apischema-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:429f13d9e35379bf8187c41a3c05562f8149358128382a90e415c63db528d6a2"},
{file = "apischema-0.18.1.tar.gz", hash = "sha256:355dc4dea7389f5b25f5326c26f06eebee8107efda7e82db8f09ee122cdf0c98"},
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
@ -113,6 +107,29 @@ files = [
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
requires_python = ">=3.8"
summary = "Python port of markdown-it. Markdown parsing, done right!"
dependencies = [
"mdurl~=0.1",
]
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
requires_python = ">=3.7"
summary = "Markdown URL utilities"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "more-itertools"
version = "10.3.0"
@ -191,6 +208,72 @@ files = [
{file = "ormsgpack-1.5.0.tar.gz", hash = "sha256:00c0743ebaa8d21f1c868fbb609c99151ea79e67fec98b51a29077efd91ce348"},
]
[[package]]
name = "pydantic"
version = "2.7.4"
requires_python = ">=3.8"
summary = "Data validation using Python type hints"
dependencies = [
"annotated-types>=0.4.0",
"pydantic-core==2.18.4",
"typing-extensions>=4.6.1",
]
files = [
{file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"},
{file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"},
]
[[package]]
name = "pydantic-core"
version = "2.18.4"
requires_python = ">=3.8"
summary = "Core functionality for Pydantic validation and serialization"
dependencies = [
"typing-extensions!=4.7.0,>=4.6.0",
]
files = [
{file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"},
{file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"},
{file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"},
{file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"},
{file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"},
{file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"},
{file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"},
{file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"},
{file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"},
{file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"},
{file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"},
]
[[package]]
name = "pygments"
version = "2.18.0"
requires_python = ">=3.8"
summary = "Pygments is a syntax highlighting package written in Python."
files = [
{file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
{file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
]
[[package]]
name = "pyobjc-core"
version = "10.3.1"
@ -335,6 +418,20 @@ files = [
{file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"},
]
[[package]]
name = "rich"
version = "13.7.1"
requires_python = ">=3.7.0"
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
dependencies = [
"markdown-it-py>=2.2.0",
"pygments<3.0.0,>=2.13.0",
]
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
]
[[package]]
name = "ruff"
version = "0.4.10"

View file

@ -12,9 +12,10 @@ dependencies = [
"python-mpd2>=3.1.0",
"xdg-base-dirs>=6.0.1",
"pytomlpp>=1.0.13",
"apischema>=0.18.1",
"yarl>=1.9.4",
"boltons>=24.0.0",
"pydantic>=2.7.4",
"rich>=13.7.1",
]
readme = "README.md"
@ -29,14 +30,9 @@ classifiers = [
]
[project.optional-dependencies]
redis = ["aiocache[redis]"]
memcached = ["aiocache[memcached]"]
msgpack = [
"ormsgpack>=1.5.0",
]
all = [
"mpd-now-playable[redis,memcached,msgpack]",
]
redis = ["aiocache[redis]", "ormsgpack>=1.5.0"]
memcached = ["aiocache[memcached]", "ormsgpack>=1.5.0"]
all = ["mpd-now-playable[redis,memcached]"]
[project.urls]
Homepage = "https://git.00dani.me/00dani/mpd-now-playable"
@ -51,6 +47,7 @@ build-backend = "pdm.backend"
[tool.mypy]
mypy_path = 'stubs'
plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin']
[tool.ruff.lint]
select = [

View file

@ -1,52 +1,47 @@
{
"$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"URL": {
"format": "uri",
"type": "string"
}
},
"properties": {
"$schema": {
"$ref": "#/definitions/URL"
},
"cache": {
"allOf": [
{
"$ref": "#/definitions/URL"
}
],
"description": "A URL describing a cache service for mpd-now-playable to use. Supported protocols are memory://, redis://, and memcached://."
},
"mpd": {
"additionalProperties": false,
"default": {
"host": "127.0.0.1",
"port": 6600
},
"$defs": {
"MpdConfig": {
"properties": {
"host": {
"default": "127.0.0.1",
"description": "The hostname or IP address of your MPD server. If you're running MPD on your local machine, you don't need to configure this.",
"format": "hostname",
"title": "Host",
"type": "string"
},
"password": {
"description": "The password required to connect to your MPD instance, if you need one.",
"type": "string"
"format": "password",
"title": "Password",
"type": "string",
"writeOnly": true
},
"port": {
"default": 6600,
"description": "The port on which to connect to MPD. Unless you're managing multiple MPD servers on one machine for some reason, you probably haven't changed this from the default port, 6600.",
"maximum": 65535,
"minimum": 1,
"title": "Port",
"type": "integer"
}
},
"title": "MpdConfig",
"type": "object"
}
},
"$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"cache": {
"description": "A URL describing a cache service for mpd-now-playable to use. Supported protocols are memory://, redis://, and memcached://.",
"format": "uri",
"title": "Cache",
"type": "string"
},
"mpd": {
"$ref": "#/$defs/MpdConfig"
}
},
"title": "Config",
"type": "object"
}

View file

@ -1,37 +1,41 @@
from __future__ import annotations
from contextlib import suppress
from typing import Any, Optional, TypeVar
from typing import Any, Generic, Optional, TypeVar
from aiocache import Cache
from aiocache.serializers import BaseSerializer, PickleSerializer
from aiocache.serializers import BaseSerializer
from pydantic.type_adapter import TypeAdapter
from yarl import URL
T = TypeVar("T")
HAS_ORMSGPACK = False
with suppress(ImportError):
import ormsgpack
HAS_ORMSGPACK = True
class OrmsgpackSerializer(BaseSerializer):
class OrmsgpackSerializer(BaseSerializer, Generic[T]):
DEFAULT_ENCODING = None
def dumps(self, value: object) -> bytes:
return ormsgpack.packb(value)
def __init__(self, schema: TypeAdapter[T]):
super().__init__()
self.schema = schema
def loads(self, value: Optional[bytes]) -> object:
def dumps(self, value: T) -> bytes:
return ormsgpack.packb(self.schema.dump_python(value))
def loads(self, value: Optional[bytes]) -> T | None:
if value is None:
return None
return ormsgpack.unpackb(value)
data = ormsgpack.unpackb(value)
return self.schema.validate_python(data)
def make_cache(url: URL, namespace: str = "") -> Cache[T]:
def make_cache(schema: TypeAdapter[T], url: URL, namespace: str = "") -> Cache[T]:
backend = Cache.get_scheme_class(url.scheme)
if backend == Cache.MEMORY:
return Cache(backend)
kwargs: dict[str, Any] = dict(url.query)
if url.path:
@ -48,6 +52,6 @@ def make_cache(url: URL, namespace: str = "") -> Cache[T]:
namespace = ":".join(s for s in [kwargs.pop("namespace", ""), namespace] if s)
serializer = OrmsgpackSerializer if HAS_ORMSGPACK else PickleSerializer
serializer = OrmsgpackSerializer(schema)
return Cache(backend, serializer=serializer(), namespace=namespace, **kwargs)
return Cache(backend, serializer=serializer, namespace=namespace, **kwargs)

View file

@ -1,6 +1,7 @@
import asyncio
from corefoundationasyncio import CoreFoundationEventLoop
from rich import print
from .__version__ import __version__
from .cocoa.now_playing import CocoaNowPlaying
@ -11,6 +12,7 @@ from .mpd.listener import MpdStateListener
async def listen() -> None:
print(f"mpd-now-playable v{__version__}")
config = loadConfig()
print(config)
listener = MpdStateListener(config.cache)
now_playing = CocoaNowPlaying(listener)
await listener.start(config.mpd)

View file

@ -109,7 +109,7 @@ def song_to_media_item(song: Song) -> NSMutableDictionary:
if song.art:
nowplaying_info[MPMediaItemPropertyArtwork] = ns_image_to_media_item_artwork(
data_to_ns_image(song.art)
data_to_ns_image(song.art.data)
)
return nowplaying_info

View file

@ -1,28 +1,37 @@
from dataclasses import field
from typing import NewType, Optional, TypeVar
from typing import Annotated, NewType
from apischema import schema
from apischema.conversions import deserializer
from apischema.metadata import none_as_undefined
from yarl import URL
from annotated_types import Ge, Le
from pydantic import (
Field,
PlainSerializer,
PlainValidator,
SecretStr,
Strict,
WithJsonSchema,
)
from yarl import URL as Yarl
__all__ = ("Host", "Port", "optional")
T = TypeVar("T")
Host = NewType("Host", str)
schema(format="hostname")(Host)
Port = NewType("Port", int)
schema(min=1, max=65535)(Port)
schema(format="uri")(URL)
__all__ = ("Host", "Password", "Port", "Url")
def optional() -> Optional[T]:
return field(default=None, metadata=none_as_undefined)
def from_yarl(url: Yarl) -> str:
return url.human_repr()
@deserializer
def from_yarl(url: str) -> URL:
return URL(url)
def to_yarl(value: object) -> Yarl:
if isinstance(value, str):
return Yarl(value)
raise NotImplementedError(f"Cannot convert {type(object)} to URL")
Host = NewType(
"Host", Annotated[str, Strict(), Field(json_schema_extra={"format": "hostname"})]
)
Password = NewType("Password", Annotated[SecretStr, Strict()])
Port = NewType("Port", Annotated[int, Strict(), Ge(1), Le(65535)])
Url = Annotated[
Yarl,
PlainValidator(to_yarl),
PlainSerializer(from_yarl, return_type=str),
WithJsonSchema({"type": "string", "format": "uri"}),
]

View file

@ -2,7 +2,6 @@ from collections.abc import Mapping
from os import environ
from typing import TypeVar
from apischema import deserialize
from boltons.iterutils import remap
from pytomlpp import load
from xdg_base_dirs import xdg_config_home
@ -33,7 +32,7 @@ def loadConfigFromFile() -> Config:
path = xdg_config_home() / "mpd-now-playable" / "config.toml"
data = load(path)
print(f"Loaded your configuration from {path}")
return deserialize(Config, data)
return Config.schema.validate_python(data)
def loadConfigFromEnv() -> Config:
@ -44,7 +43,7 @@ def loadConfigFromEnv() -> Config:
if password is None and host is not None and "@" in host:
password, host = host.split("@", maxsplit=1)
data = {"cache": cache, "mpd": {"port": port, "host": host, "password": password}}
return deserialize(Config, withoutNones(data))
return Config.schema.validate_python(withoutNones(data))
def loadConfig() -> Config:

View file

@ -1,18 +1,16 @@
from dataclasses import dataclass, field
from typing import Optional
from apischema import alias
from yarl import URL
from .fields import Host, Port, optional
from ..tools.schema.define import schema
from .fields import Host, Password, Port, Url
__all__ = ("MpdConfig", "Config")
@dataclass(frozen=True)
@dataclass(slots=True)
class MpdConfig:
#: The password required to connect to your MPD instance, if you need one.
password: Optional[str] = optional()
password: Optional[Password] = None
#: The hostname or IP address of your MPD server. If you're running MPD
#: on your local machine, you don't need to configure this.
host: Host = Host("127.0.0.1")
@ -22,14 +20,10 @@ class MpdConfig:
port: Port = Port(6600)
@dataclass(frozen=True)
@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json")
@dataclass(slots=True)
class Config:
schema: URL = field(
default=URL("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json"),
metadata=alias("$schema"),
)
#: A URL describing a cache service for mpd-now-playable to use. Supported
#: protocols are memory://, redis://, and memcached://.
cache: Optional[URL] = optional()
cache: Optional[Url] = None
mpd: MpdConfig = field(default_factory=MpdConfig)

View file

@ -1,46 +1,5 @@
from json import dump
from pathlib import Path
from pprint import pp
from shutil import get_terminal_size
from typing import Any, Mapping
from apischema import schema, settings
from apischema.json_schema import JsonSchemaVersion, deserialization_schema
from apischema.schemas import Schema
from class_doc import extract_docs_from_cls_obj
from ..tools.schema.generate import write
from .model import Config
def field_base_schema(tp: type, name: str, alias: str) -> Schema | None:
desc_lines = extract_docs_from_cls_obj(tp).get(name, [])
if desc_lines:
print((tp, name, alias))
return schema(description=" ".join(desc_lines))
return None
settings.base_schema.field = field_base_schema
def generate() -> Mapping[str, Any]:
return deserialization_schema(
Config,
version=JsonSchemaVersion.DRAFT_7,
)
def write() -> None:
schema = dict(generate())
schema["$id"] = Config.schema.human_repr()
schema_file = Path(__file__).parent / Config.schema.name
print(f"Writing this schema to {schema_file}")
pp(schema, sort_dicts=True, width=get_terminal_size().columns)
with open(schema_file, "w") as fp:
dump(schema, fp, indent="\t", sort_keys=True)
fp.write("\n")
if __name__ == "__main__":
write()
write(Config)

View file

@ -1,10 +1,9 @@
from __future__ import annotations
from typing import TypedDict
from yarl import URL
from ..cache import Cache, make_cache
from ..song import Artwork, ArtworkSchema, to_artwork
from ..tools.asyncio import run_background_task
from ..tools.types import un_maybe_plural
from .types import CurrentSongResponse, MpdStateHandler
@ -12,10 +11,6 @@ from .types import CurrentSongResponse, MpdStateHandler
CACHE_TTL = 60 * 60 # seconds = 1 hour
class ArtCacheEntry(TypedDict):
data: bytes | None
def calc_album_key(song: CurrentSongResponse) -> str:
artist = sorted(
un_maybe_plural(song.get("albumartist", song.get("artist", "Unknown Artist")))
@ -33,18 +28,18 @@ MEMORY = URL("memory://")
class MpdArtworkCache:
mpd: MpdStateHandler
album_cache: Cache[ArtCacheEntry]
track_cache: Cache[ArtCacheEntry]
album_cache: Cache[Artwork]
track_cache: Cache[Artwork]
def __init__(self, mpd: MpdStateHandler, cache_url: URL = MEMORY):
self.mpd = mpd
self.album_cache = make_cache(cache_url, "album")
self.track_cache = make_cache(cache_url, "track")
self.album_cache = make_cache(ArtworkSchema, cache_url, "album")
self.track_cache = make_cache(ArtworkSchema, cache_url, "track")
async def get_cached_artwork(self, song: CurrentSongResponse) -> bytes | None:
art = await self.track_cache.get(calc_track_key(song))
if art:
return art["data"]
return art.data
# If we don't have track artwork cached, go find some.
run_background_task(self.cache_artwork(song))
@ -52,12 +47,12 @@ class MpdArtworkCache:
# Even if we don't have cached track art, we can try looking for cached album art.
art = await self.album_cache.get(calc_album_key(song))
if art:
return art["data"]
return art.data
return None
async def cache_artwork(self, song: CurrentSongResponse) -> None:
art = ArtCacheEntry(data=await self.mpd.get_art(song["file"]))
art = to_artwork(await self.mpd.get_art(song["file"]))
try:
await self.album_cache.add(calc_album_key(song), art, ttl=CACHE_TTL)
except ValueError:

View file

@ -4,18 +4,19 @@ from uuid import UUID
from mpd.asyncio import MPDClient
from mpd.base import CommandError
from rich import print as rprint
from yarl import URL
from ..config.model import MpdConfig
from ..player import Player
from ..song import PlaybackState, Song, SongListener
from ..song import Artwork, PlaybackState, Song, SongListener, to_artwork
from ..tools.types import convert_if_exists, un_maybe_plural
from .artwork_cache import MpdArtworkCache
from .types import CurrentSongResponse, StatusResponse
def mpd_current_to_song(
status: StatusResponse, current: CurrentSongResponse, art: bytes | None
status: StatusResponse, current: CurrentSongResponse, art: Artwork
) -> Song:
return Song(
state=PlaybackState(status["state"]),
@ -57,7 +58,7 @@ class MpdStateListener(Player):
await self.client.connect(conf.host, conf.port)
if conf.password is not None:
print("Authorising to MPD with your password...")
await self.client.password(conf.password)
await self.client.password(conf.password.get_secret_value())
print(f"Connected to MPD v{self.client.mpd_version}")
async def refresh(self) -> None:
@ -91,8 +92,8 @@ class MpdStateListener(Player):
if starting_idle_count != self.idle_count:
return
song = mpd_current_to_song(status, current, art)
print(song)
song = mpd_current_to_song(status, current, to_artwork(art))
rprint(song)
listener.update(song)
async def get_art(self, file: str) -> bytes | None:

View file

@ -1,9 +1,10 @@
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Protocol
from typing import Literal, Protocol
from uuid import UUID
from attrs import define, field
from pydantic.type_adapter import TypeAdapter
class PlaybackState(StrEnum):
@ -12,7 +13,28 @@ class PlaybackState(StrEnum):
stop = "stop"
@define
@dataclass(slots=True)
class HasArtwork:
data: bytes = field(repr=False)
@dataclass(slots=True)
class NoArtwork:
def __bool__(self) -> Literal[False]:
return False
Artwork = HasArtwork | NoArtwork
ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork)
def to_artwork(art: bytes | None) -> Artwork:
if art is None:
return NoArtwork()
return HasArtwork(art)
@dataclass(slots=True)
class Song:
state: PlaybackState
queue_index: int
@ -30,7 +52,7 @@ class Song:
genre: list[str]
duration: float
elapsed: float
art: bytes | None = field(repr=lambda a: "<has art>" if a else "<no art>")
art: Artwork
class SongListener(Protocol):

View file

@ -0,0 +1,22 @@
from typing import Callable, Protocol, Self, TypeVar
from pydantic.type_adapter import TypeAdapter
from yarl import URL
T = TypeVar("T")
class ModelWithSchema(Protocol):
@property
def id(self) -> URL: ...
@property
def schema(self) -> TypeAdapter[Self]: ...
def schema(schema_id: str) -> Callable[[type[T]], type[T]]:
def decorate(clazz: type[T]) -> type[T]:
type.__setattr__(clazz, "id", URL(schema_id))
type.__setattr__(clazz, "schema", TypeAdapter(clazz))
return clazz
return decorate

View file

@ -0,0 +1,47 @@
from json import dump
from pathlib import Path
from class_doc import extract_docs_from_cls_obj
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue
from pydantic_core import core_schema as s
from rich.pretty import pprint
from .define import ModelWithSchema
__all__ = ("write",)
def write(model: ModelWithSchema) -> None:
schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema)
schema["$id"] = model.id.human_repr()
schema_file = Path(__file__).parents[4] / "schemata" / model.id.name
print(f"Writing this schema to {schema_file}")
pprint(schema)
with open(schema_file, "w") as fp:
dump(schema, fp, indent="\t", sort_keys=True)
fp.write("\n")
class MyGenerateJsonSchema(GenerateJsonSchema):
def generate(
self, schema: s.CoreSchema, mode: JsonSchemaMode = "validation"
) -> dict[str, object]:
json_schema = super().generate(schema, mode=mode)
json_schema["$schema"] = self.schema_dialect
return json_schema
def default_schema(self, schema: s.WithDefaultSchema) -> JsonSchemaValue:
result = super().default_schema(schema)
if "default" in result and result["default"] is None:
del result["default"]
return result
def dataclass_schema(self, schema: s.DataclassSchema) -> JsonSchemaValue:
result = super().dataclass_schema(schema)
docs = extract_docs_from_cls_obj(schema["cls"])
for field, lines in docs.items():
result["properties"][field]["description"] = " ".join(lines)
return result
def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue:
return self.generate_inner(schema["schema"])

View file

@ -0,0 +1,43 @@
from typing import Callable
from mypy.plugin import ClassDefContext, Plugin
from mypy.plugins.common import add_attribute_to_class
def add_schema_classvars(ctx: ClassDefContext) -> None:
api = ctx.api
cls = ctx.cls
URL = api.named_type("yarl.URL")
Adapter = api.named_type(
"pydantic.type_adapter.TypeAdapter", [api.named_type(cls.fullname)]
)
add_attribute_to_class(
api,
cls,
"id",
URL,
final=True,
is_classvar=True,
)
add_attribute_to_class(
api,
cls,
"schema",
Adapter,
final=True,
is_classvar=True,
)
class SchemaDecoratorPlugin(Plugin):
def get_class_decorator_hook(
self, fullname: str
) -> Callable[[ClassDefContext], None] | None:
if fullname != "mpd_now_playable.tools.schema.define.schema":
return None
return add_schema_classvars
def plugin(version: str) -> type[SchemaDecoratorPlugin]:
return SchemaDecoratorPlugin