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
|
```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
123
pdm.lock
|
@ -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"
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"}),
|
||||||
|
]
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
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