Significantly overhaul configuration management

Everything now uses bog-standard Python dataclasses, with Pydantic
providing validation and type conversion through separate classes using
its type adapter feature. It's also possible to define your classes
using Pydantic's own model type directly, making the type adapter
unnecessary, but I didn't want to do things that way because no actual
validation is needed when constructing a Song instance for example.
Having Pydantic do its thing only on-demand was preferable.

I tried a number of validation libraries before settling on Pydantic for
this. It's not the fastest option out there (msgspec is I think), but it
makes adding support for third-party types like yarl.URL really easy, it
generates a nice clean JSON Schema which is easy enough to adjust to my
requirements through its GenerateJsonSchema hooks, and raw speed isn't
all that important anyway since this is a single-user desktop program
that reads its configuration file once on startup.

Also, MessagePack is now mandatory if you're caching to an external
service. It just didn't make a whole lot sense to explicitly install
mpd-now-playable's Redis or Memcached support and then use pickling with
them.

With all this fussing around done, I'm probably finally ready to
actually use that configuration file to configure new features! Yay!
This commit is contained in:
Danielle McLean 2024-07-01 00:10:17 +10:00
parent 3b7ddfa718
commit 27d8c37139
Signed by: 00dani
GPG key ID: 6854781A0488421C
18 changed files with 355 additions and 169 deletions

View file

@ -0,0 +1,22 @@
from typing import Callable, Protocol, Self, TypeVar
from pydantic.type_adapter import TypeAdapter
from yarl import URL
T = TypeVar("T")
class ModelWithSchema(Protocol):
@property
def id(self) -> URL: ...
@property
def schema(self) -> TypeAdapter[Self]: ...
def schema(schema_id: str) -> Callable[[type[T]], type[T]]:
def decorate(clazz: type[T]) -> type[T]:
type.__setattr__(clazz, "id", URL(schema_id))
type.__setattr__(clazz, "schema", TypeAdapter(clazz))
return clazz
return decorate

View file

@ -0,0 +1,47 @@
from json import dump
from pathlib import Path
from class_doc import extract_docs_from_cls_obj
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue
from pydantic_core import core_schema as s
from rich.pretty import pprint
from .define import ModelWithSchema
__all__ = ("write",)
def write(model: ModelWithSchema) -> None:
schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema)
schema["$id"] = model.id.human_repr()
schema_file = Path(__file__).parents[4] / "schemata" / model.id.name
print(f"Writing this schema to {schema_file}")
pprint(schema)
with open(schema_file, "w") as fp:
dump(schema, fp, indent="\t", sort_keys=True)
fp.write("\n")
class MyGenerateJsonSchema(GenerateJsonSchema):
def generate(
self, schema: s.CoreSchema, mode: JsonSchemaMode = "validation"
) -> dict[str, object]:
json_schema = super().generate(schema, mode=mode)
json_schema["$schema"] = self.schema_dialect
return json_schema
def default_schema(self, schema: s.WithDefaultSchema) -> JsonSchemaValue:
result = super().default_schema(schema)
if "default" in result and result["default"] is None:
del result["default"]
return result
def dataclass_schema(self, schema: s.DataclassSchema) -> JsonSchemaValue:
result = super().dataclass_schema(schema)
docs = extract_docs_from_cls_obj(schema["cls"])
for field, lines in docs.items():
result["properties"][field]["description"] = " ".join(lines)
return result
def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue:
return self.generate_inner(schema["schema"])

View file

@ -0,0 +1,43 @@
from typing import Callable
from mypy.plugin import ClassDefContext, Plugin
from mypy.plugins.common import add_attribute_to_class
def add_schema_classvars(ctx: ClassDefContext) -> None:
api = ctx.api
cls = ctx.cls
URL = api.named_type("yarl.URL")
Adapter = api.named_type(
"pydantic.type_adapter.TypeAdapter", [api.named_type(cls.fullname)]
)
add_attribute_to_class(
api,
cls,
"id",
URL,
final=True,
is_classvar=True,
)
add_attribute_to_class(
api,
cls,
"schema",
Adapter,
final=True,
is_classvar=True,
)
class SchemaDecoratorPlugin(Plugin):
def get_class_decorator_hook(
self, fullname: str
) -> Callable[[ClassDefContext], None] | None:
if fullname != "mpd_now_playable.tools.schema.define.schema":
return None
return add_schema_classvars
def plugin(version: str) -> type[SchemaDecoratorPlugin]:
return SchemaDecoratorPlugin