diff --git a/README.md b/README.md index 60b2df9..8e48af7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pdm.lock b/pdm.lock index dab350d..f703af1 100644 --- a/pdm.lock +++ b/pdm.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 4c7aaf9..64b5b6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/mpd_now_playable/config/config-v1.json b/schemata/config-v1.json similarity index 73% rename from src/mpd_now_playable/config/config-v1.json rename to schemata/config-v1.json index c5d3fc3..436ba7b 100644 --- a/src/mpd_now_playable/config/config-v1.json +++ b/schemata/config-v1.json @@ -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" } diff --git a/src/mpd_now_playable/cache.py b/src/mpd_now_playable/cache.py index 0973a5f..6b27f71 100644 --- a/src/mpd_now_playable/cache.py +++ b/src/mpd_now_playable/cache.py @@ -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) diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index bd25127..6f6e44f 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -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) diff --git a/src/mpd_now_playable/cocoa/now_playing.py b/src/mpd_now_playable/cocoa/now_playing.py index 13681d7..1121329 100644 --- a/src/mpd_now_playable/cocoa/now_playing.py +++ b/src/mpd_now_playable/cocoa/now_playing.py @@ -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 diff --git a/src/mpd_now_playable/config/fields.py b/src/mpd_now_playable/config/fields.py index 37ff8b1..f73a7f9 100644 --- a/src/mpd_now_playable/config/fields.py +++ b/src/mpd_now_playable/config/fields.py @@ -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"}), +] diff --git a/src/mpd_now_playable/config/load.py b/src/mpd_now_playable/config/load.py index 9e79c09..a45a0eb 100644 --- a/src/mpd_now_playable/config/load.py +++ b/src/mpd_now_playable/config/load.py @@ -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: diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 88d197f..4bd06bf 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -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) diff --git a/src/mpd_now_playable/config/schema.py b/src/mpd_now_playable/config/schema.py index 93c0c10..341487b 100644 --- a/src/mpd_now_playable/config/schema.py +++ b/src/mpd_now_playable/config/schema.py @@ -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) diff --git a/src/mpd_now_playable/mpd/artwork_cache.py b/src/mpd_now_playable/mpd/artwork_cache.py index 3d9ed1b..0b1ebbe 100644 --- a/src/mpd_now_playable/mpd/artwork_cache.py +++ b/src/mpd_now_playable/mpd/artwork_cache.py @@ -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: diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index 62229b0..96e7f30 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -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: diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py index 76f17a8..940177e 100644 --- a/src/mpd_now_playable/song.py +++ b/src/mpd_now_playable/song.py @@ -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: "" if a else "") + art: Artwork class SongListener(Protocol): diff --git a/src/mpd_now_playable/tools/schema/__init__.py b/src/mpd_now_playable/tools/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mpd_now_playable/tools/schema/define.py b/src/mpd_now_playable/tools/schema/define.py new file mode 100644 index 0000000..f33c907 --- /dev/null +++ b/src/mpd_now_playable/tools/schema/define.py @@ -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 diff --git a/src/mpd_now_playable/tools/schema/generate.py b/src/mpd_now_playable/tools/schema/generate.py new file mode 100644 index 0000000..181a214 --- /dev/null +++ b/src/mpd_now_playable/tools/schema/generate.py @@ -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"]) diff --git a/src/mpd_now_playable/tools/schema/plugin.py b/src/mpd_now_playable/tools/schema/plugin.py new file mode 100644 index 0000000..441348c --- /dev/null +++ b/src/mpd_now_playable/tools/schema/plugin.py @@ -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