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 ```shell
pipx install mpd-now-playable pipx install mpd-now-playable
# or, if you'd like to use a separate cache service, one of these: # 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[redis]
pipx install mpd-now-playable[memcached,msgpack] 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. 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"] groups = ["default", "all", "dev"]
strategy = ["cross_platform"] strategy = ["cross_platform"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:73f93e2fcfc5fc5af9acfdc9604b9f27d3eb5d9c6012f600c671523c2f78bf4c" content_hash = "sha256:5a1cfd2988fd81c4b8743daaf399bbeb9f1f9d78e161901f0bf76c771ec8052f"
[[package]] [[package]]
name = "aiocache" name = "aiocache"
@ -55,19 +55,13 @@ files = [
] ]
[[package]] [[package]]
name = "apischema" name = "annotated-types"
version = "0.18.1" version = "0.7.0"
requires_python = ">=3.7" requires_python = ">=3.8"
summary = "JSON (de)serialization, GraphQL and JSON schema generation using Python typing." summary = "Reusable constraint types to use with typing.Annotated"
files = [ files = [
{file = "apischema-0.18.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:220aa56974f765dc100e875c66c688ff57bad3ae48d0aeaee4fb1ec90c5cd0fd"}, {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "apischema-0.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:653492010d22acdcbe2240f0ceb3ca59de1b6d34640895e03fda15f944e216d8"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
{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"},
] ]
[[package]] [[package]]
@ -113,6 +107,29 @@ files = [
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, {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]] [[package]]
name = "more-itertools" name = "more-itertools"
version = "10.3.0" version = "10.3.0"
@ -191,6 +208,72 @@ files = [
{file = "ormsgpack-1.5.0.tar.gz", hash = "sha256:00c0743ebaa8d21f1c868fbb609c99151ea79e67fec98b51a29077efd91ce348"}, {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]] [[package]]
name = "pyobjc-core" name = "pyobjc-core"
version = "10.3.1" version = "10.3.1"
@ -335,6 +418,20 @@ files = [
{file = "redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61"}, {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]] [[package]]
name = "ruff" name = "ruff"
version = "0.4.10" version = "0.4.10"

View file

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

View file

@ -1,52 +1,47 @@
{ {
"$id": "https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json", "$defs": {
"$schema": "http://json-schema.org/draft-07/schema#", "MpdConfig": {
"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
},
"properties": { "properties": {
"host": { "host": {
"default": "127.0.0.1", "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.", "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", "format": "hostname",
"title": "Host",
"type": "string" "type": "string"
}, },
"password": { "password": {
"description": "The password required to connect to your MPD instance, if you need one.", "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": { "port": {
"default": 6600, "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.", "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, "maximum": 65535,
"minimum": 1, "minimum": 1,
"title": "Port",
"type": "integer" "type": "integer"
} }
}, },
"title": "MpdConfig",
"type": "object" "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" "type": "object"
} }

View file

@ -1,37 +1,41 @@
from __future__ import annotations from __future__ import annotations
from contextlib import suppress from contextlib import suppress
from typing import Any, Optional, TypeVar from typing import Any, Generic, Optional, TypeVar
from aiocache import Cache 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 from yarl import URL
T = TypeVar("T") T = TypeVar("T")
HAS_ORMSGPACK = False
with suppress(ImportError): with suppress(ImportError):
import ormsgpack import ormsgpack
HAS_ORMSGPACK = True
class OrmsgpackSerializer(BaseSerializer, Generic[T]):
class OrmsgpackSerializer(BaseSerializer):
DEFAULT_ENCODING = None DEFAULT_ENCODING = None
def dumps(self, value: object) -> bytes: def __init__(self, schema: TypeAdapter[T]):
return ormsgpack.packb(value) 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: if value is None:
return 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) backend = Cache.get_scheme_class(url.scheme)
if backend == Cache.MEMORY: if backend == Cache.MEMORY:
return Cache(backend) return Cache(backend)
kwargs: dict[str, Any] = dict(url.query) kwargs: dict[str, Any] = dict(url.query)
if url.path: 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) 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 import asyncio
from corefoundationasyncio import CoreFoundationEventLoop from corefoundationasyncio import CoreFoundationEventLoop
from rich import print
from .__version__ import __version__ from .__version__ import __version__
from .cocoa.now_playing import CocoaNowPlaying from .cocoa.now_playing import CocoaNowPlaying
@ -11,6 +12,7 @@ from .mpd.listener import MpdStateListener
async def listen() -> None: async def listen() -> None:
print(f"mpd-now-playable v{__version__}") print(f"mpd-now-playable v{__version__}")
config = loadConfig() config = loadConfig()
print(config)
listener = MpdStateListener(config.cache) listener = MpdStateListener(config.cache)
now_playing = CocoaNowPlaying(listener) now_playing = CocoaNowPlaying(listener)
await listener.start(config.mpd) await listener.start(config.mpd)

View file

@ -109,7 +109,7 @@ def song_to_media_item(song: Song) -> NSMutableDictionary:
if song.art: if song.art:
nowplaying_info[MPMediaItemPropertyArtwork] = ns_image_to_media_item_artwork( 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 return nowplaying_info

View file

@ -1,28 +1,37 @@
from dataclasses import field from typing import Annotated, NewType
from typing import NewType, Optional, TypeVar
from apischema import schema from annotated_types import Ge, Le
from apischema.conversions import deserializer from pydantic import (
from apischema.metadata import none_as_undefined Field,
from yarl import URL PlainSerializer,
PlainValidator,
SecretStr,
Strict,
WithJsonSchema,
)
from yarl import URL as Yarl
__all__ = ("Host", "Port", "optional") __all__ = ("Host", "Password", "Port", "Url")
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)
def optional() -> Optional[T]: def from_yarl(url: Yarl) -> str:
return field(default=None, metadata=none_as_undefined) return url.human_repr()
@deserializer def to_yarl(value: object) -> Yarl:
def from_yarl(url: str) -> URL: if isinstance(value, str):
return URL(url) 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 os import environ
from typing import TypeVar from typing import TypeVar
from apischema import deserialize
from boltons.iterutils import remap from boltons.iterutils import remap
from pytomlpp import load from pytomlpp import load
from xdg_base_dirs import xdg_config_home from xdg_base_dirs import xdg_config_home
@ -33,7 +32,7 @@ def loadConfigFromFile() -> Config:
path = xdg_config_home() / "mpd-now-playable" / "config.toml" path = xdg_config_home() / "mpd-now-playable" / "config.toml"
data = load(path) data = load(path)
print(f"Loaded your configuration from {path}") print(f"Loaded your configuration from {path}")
return deserialize(Config, data) return Config.schema.validate_python(data)
def loadConfigFromEnv() -> Config: def loadConfigFromEnv() -> Config:
@ -44,7 +43,7 @@ def loadConfigFromEnv() -> Config:
if password is None and host is not None and "@" in host: if password is None and host is not None and "@" in host:
password, host = host.split("@", maxsplit=1) password, host = host.split("@", maxsplit=1)
data = {"cache": cache, "mpd": {"port": port, "host": host, "password": password}} 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: def loadConfig() -> Config:

View file

@ -1,18 +1,16 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from apischema import alias from ..tools.schema.define import schema
from yarl import URL from .fields import Host, Password, Port, Url
from .fields import Host, Port, optional
__all__ = ("MpdConfig", "Config") __all__ = ("MpdConfig", "Config")
@dataclass(frozen=True) @dataclass(slots=True)
class MpdConfig: class MpdConfig:
#: The password required to connect to your MPD instance, if you need one. #: 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 #: 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. #: on your local machine, you don't need to configure this.
host: Host = Host("127.0.0.1") host: Host = Host("127.0.0.1")
@ -22,14 +20,10 @@ class MpdConfig:
port: Port = Port(6600) 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: 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 #: A URL describing a cache service for mpd-now-playable to use. Supported
#: protocols are memory://, redis://, and memcached://. #: protocols are memory://, redis://, and memcached://.
cache: Optional[URL] = optional() cache: Optional[Url] = None
mpd: MpdConfig = field(default_factory=MpdConfig) mpd: MpdConfig = field(default_factory=MpdConfig)

View file

@ -1,46 +1,5 @@
from json import dump from ..tools.schema.generate import write
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 .model import Config 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__": if __name__ == "__main__":
write() write(Config)

View file

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

View file

@ -4,18 +4,19 @@ from uuid import UUID
from mpd.asyncio import MPDClient from mpd.asyncio import MPDClient
from mpd.base import CommandError from mpd.base import CommandError
from rich import print as rprint
from yarl import URL from yarl import URL
from ..config.model import MpdConfig from ..config.model import MpdConfig
from ..player import Player 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 ..tools.types import convert_if_exists, un_maybe_plural
from .artwork_cache import MpdArtworkCache from .artwork_cache import MpdArtworkCache
from .types import CurrentSongResponse, StatusResponse from .types import CurrentSongResponse, StatusResponse
def mpd_current_to_song( def mpd_current_to_song(
status: StatusResponse, current: CurrentSongResponse, art: bytes | None status: StatusResponse, current: CurrentSongResponse, art: Artwork
) -> Song: ) -> Song:
return Song( return Song(
state=PlaybackState(status["state"]), state=PlaybackState(status["state"]),
@ -57,7 +58,7 @@ class MpdStateListener(Player):
await self.client.connect(conf.host, conf.port) await self.client.connect(conf.host, conf.port)
if conf.password is not None: if conf.password is not None:
print("Authorising to MPD with your password...") 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}") print(f"Connected to MPD v{self.client.mpd_version}")
async def refresh(self) -> None: async def refresh(self) -> None:
@ -91,8 +92,8 @@ class MpdStateListener(Player):
if starting_idle_count != self.idle_count: if starting_idle_count != self.idle_count:
return return
song = mpd_current_to_song(status, current, art) song = mpd_current_to_song(status, current, to_artwork(art))
print(song) rprint(song)
listener.update(song) listener.update(song)
async def get_art(self, file: str) -> bytes | None: 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 enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Literal, Protocol
from uuid import UUID from uuid import UUID
from attrs import define, field from pydantic.type_adapter import TypeAdapter
class PlaybackState(StrEnum): class PlaybackState(StrEnum):
@ -12,7 +13,28 @@ class PlaybackState(StrEnum):
stop = "stop" 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: class Song:
state: PlaybackState state: PlaybackState
queue_index: int queue_index: int
@ -30,7 +52,7 @@ class Song:
genre: list[str] genre: list[str]
duration: float duration: float
elapsed: float elapsed: float
art: bytes | None = field(repr=lambda a: "<has art>" if a else "<no art>") art: Artwork
class SongListener(Protocol): 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