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
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…
Add table
Add a link
Reference in a new issue