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:
parent
3b7ddfa718
commit
27d8c37139
18 changed files with 355 additions and 169 deletions
|
@ -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
123
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"
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"}),
|
||||
]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
0
src/mpd_now_playable/tools/schema/__init__.py
Normal file
0
src/mpd_now_playable/tools/schema/__init__.py
Normal file
22
src/mpd_now_playable/tools/schema/define.py
Normal file
22
src/mpd_now_playable/tools/schema/define.py
Normal 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
|
47
src/mpd_now_playable/tools/schema/generate.py
Normal file
47
src/mpd_now_playable/tools/schema/generate.py
Normal 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"])
|
43
src/mpd_now_playable/tools/schema/plugin.py
Normal file
43
src/mpd_now_playable/tools/schema/plugin.py
Normal 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
|
Loading…
Reference in a new issue