From fbf867da07634743497b40f497b2a824f2f0e023 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 15 Jul 2025 16:24:57 +0200 Subject: [PATCH 1/5] Added typing_extensions to allow default values in typevar. --- CHANGELOG.md | 1 + backend/beets_flask/server/utility.py | 9 +++++---- backend/pyproject.toml | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd292f7..8ef82330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configuration option for artist separator characters `gui.library.artist_separator` - Docs subpage for configuration (including content) +- `typing_extensions` is now a dependency, to allow for more typing features ### Fixed diff --git a/backend/beets_flask/server/utility.py b/backend/beets_flask/server/utility.py index 59bc8598..7777abdb 100644 --- a/backend/beets_flask/server/utility.py +++ b/backend/beets_flask/server/utility.py @@ -1,5 +1,7 @@ from pathlib import Path -from typing import Callable, TypeVar, cast +from typing import Callable, cast + +from typing_extensions import TypeVar from beets_flask.invoker.job import ExtraJobMeta from beets_flask.logger import log @@ -9,8 +11,7 @@ R = TypeVar("R") D = TypeVar( "D", - # Fixme: Add once we update to python 3.13 - # default=None, + default=None, ) @@ -66,7 +67,7 @@ def pop_extra_meta(params: dict, n_jobs=1) -> list[ExtraJobMeta]: The request args. """ - job_refs: list[str] = pop_query_param( + job_refs: list[str] | None = pop_query_param( params=params, key="job_frontend_refs", convert_func=list, default=None ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f7aead1e..daa8749b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "aiofiles", "numpy", "pandas", + "typing_extensions", ] [project.optional-dependencies] From 08137f6b94bec17c1dcd8a085bc0d1d6e28e2a8c Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 15 Jul 2025 16:32:16 +0200 Subject: [PATCH 2/5] Added a delete route to the base api model. --- CHANGELOG.md | 1 + backend/beets_flask/server/routes/db_models/base.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef82330..a14a0f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configuration option for artist separator characters `gui.library.artist_separator` - Docs subpage for configuration (including content) - `typing_extensions` is now a dependency, to allow for more typing features +- The model api routes now allows for `DELETE` requests to delete resources by id. Not used yet but will be helpful for future features. ### Fixed diff --git a/backend/beets_flask/server/routes/db_models/base.py b/backend/beets_flask/server/routes/db_models/base.py index 6dacb989..90c612b6 100644 --- a/backend/beets_flask/server/routes/db_models/base.py +++ b/backend/beets_flask/server/routes/db_models/base.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Generic, Sequence, TypeVar +from typing import Generic, Sequence, TypeVar from quart import Blueprint, request from sqlalchemy import select @@ -42,6 +42,7 @@ def _register_routes(self) -> None: """Register the routes for the blueprint.""" self.blueprint.route("/", methods=["GET"])(self.get_all) self.blueprint.route("/id/", methods=["GET"])(self.get_by_id) + self.blueprint.route("/id/", methods=["DELETE"])(self.delete_by_id) async def get_all(self): params = dict(request.args) @@ -79,6 +80,16 @@ async def get_by_id(self, id: str): return item.to_dict() + async def delete_by_id(self, id: str): + with db_session_factory() as session: + item = self.model.get_by(self.model.id == id, session=session) + if not item: + return {"message": f"Item with id {id} not found"}, 200 + session.delete(item) + session.commit() + + return {"message": f"Item with id {id} deleted successfully"}, 200 + # ------------------------------- Local Utility ------------------------------ # From fbffffa2140d03cd484dac456d1936fa9218ba4e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 15 Jul 2025 16:40:47 +0200 Subject: [PATCH 3/5] Created a generic sqlalchemy dict type and moved old intdict to new file. --- backend/beets_flask/database/models/base.py | 36 +++------- backend/beets_flask/database/models/types.py | 74 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 backend/beets_flask/database/models/types.py diff --git a/backend/beets_flask/database/models/base.py b/backend/beets_flask/database/models/base.py index 8bee6f27..2d1024da 100644 --- a/backend/beets_flask/database/models/base.py +++ b/backend/beets_flask/database/models/base.py @@ -1,12 +1,11 @@ from __future__ import annotations -import json from datetime import datetime -from typing import Mapping, Self, Sequence +from typing import Any, Mapping, Self, Sequence from uuid import uuid4 import pytz -from sqlalchemy import LargeBinary, select, types +from sqlalchemy import LargeBinary, select from sqlalchemy.orm import ( DeclarativeBase, Mapped, @@ -19,36 +18,19 @@ from beets_flask.logger import log - -class IntDictType(types.TypeDecorator): - """Stores a dict[int, int] as a JSON-encoded string in the database.""" - - impl = types.Text - cache_ok = True - - def process_bind_param(self, value, dialect): - if value is None: - return None - if not isinstance(value, dict) or not all( - isinstance(k, int) and isinstance(v, int) for k, v in value.items() - ): - raise ValueError("Value must be a dict[int, int]") - return json.dumps({str(k): v for k, v in value.items()}) - - def process_result_value(self, value, dialect): - if value is None: - return None - return {int(k): v for k, v in json.loads(value).items()} - - def copy(self, **kw): - return IntDictType(self.impl.length) # type: ignore +from .types import DictType, IntDictType, StrDictType class Base(DeclarativeBase): __abstract__ = True registry = registry( - type_annotation_map={bytes: LargeBinary, dict[int, int]: IntDictType} + type_annotation_map={ + bytes: LargeBinary, + dict[int, int]: IntDictType, + dict[str, str]: StrDictType, + dict[str, Any]: DictType, + } ) id: Mapped[str] = mapped_column(primary_key=True) diff --git a/backend/beets_flask/database/models/types.py b/backend/beets_flask/database/models/types.py new file mode 100644 index 00000000..91cad148 --- /dev/null +++ b/backend/beets_flask/database/models/types.py @@ -0,0 +1,74 @@ +import json +from typing import Any + +from sqlalchemy import types + + +class DictType(types.TypeDecorator): + """Stores a dict[str, Any] as a JSON-encoded string in the database. + + Allows for flexible storage of dictionaries with string keys and values of + any (serializable) type. + """ + + impl = types.Text + cache_ok = True + + allowed_types = (int, str) + allowed_keys_types: tuple[type, ...] = (str,) + allowed_values_types: tuple[type | Any, ...] = (Any,) + + def process_bind_param(self, value, dialect): + if value is None: + return None + if not isinstance(value, dict) or not all( + isinstance(k, self.allowed_types) and isinstance(v, self.allowed_types) + for k, v in value.items() + ): + raise ValueError("Value must be a dict[int|str, int|str].") + if not isinstance(value, dict): + raise ValueError("Value must be a dict") + + # Any type needs some special handling + allowed_types_v: tuple[type, ...] = tuple( + filter(lambda x: x is not Any, self.allowed_types_values) + ) + + if not len(allowed_types_v) == 0: + if not all(isinstance(v, allowed_types_v) for v in value.values()): + raise ValueError( + f"Value must be a dict with values of type {allowed_types_v}." + ) + + if not all(isinstance(k, self.allowed_keys_types) for k in value.keys()): + raise ValueError(f"Keys must be of type {self.allowed_keys_types}.") + + return json.dumps({str(k): v for k, v in value.items()}) + + def process_result_value(self, value, dialect): + if value is None: + return None + return json.loads(value) + + def copy(self, **kw): + return self.__class__(self.impl.length) # type: ignore + + +class IntDictType(DictType): + """Stores a dict[int, int] as a JSON-encoded string in the database.""" + + allowed_types = (int,) + allowed_keys_types = (int,) + allowed_values_types = (str,) + + def process_result_value(self, value, dialect): + if value is None: + return None + return {int(k): int(v) for k, v in json.loads(value).items()} + + +class StrDictType(DictType): + """Stores a dict[str, str] as a JSON-encoded string in the database.""" + + allowed_keys_types = (str,) + allowed_values_types = (str,) From 28e90c9dc9e3e88799ecbbea60bb60d01a7bd7b0 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 15 Jul 2025 16:45:10 +0200 Subject: [PATCH 4/5] Changelog update --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14a0f0c..e54d25d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In albums and items view the clicking on artists does not return any results if the contained a separator character (e.g. `&`) [#132](https://github.com/pSpitzner/beets-flask/issues/138) - Cleanup old actions.tsx file, which included old unused code [#134](https://github.com/pSpitzner/beets-flask/issues/134) +### Changed + +- Created `types.py` file to hold custom sqlalchemy types, and moved `IntDictType` there. + ## [1.0.0] - 25-07-06 This is a breaking change, you will need to update your configs and delete your beets-flask From c14e4e320ee49bc341667e3643212d744796b62e Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Tue, 15 Jul 2025 17:03:46 +0200 Subject: [PATCH 5/5] Fixed copy paste error. --- backend/beets_flask/database/models/types.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/beets_flask/database/models/types.py b/backend/beets_flask/database/models/types.py index 91cad148..4d558a60 100644 --- a/backend/beets_flask/database/models/types.py +++ b/backend/beets_flask/database/models/types.py @@ -14,30 +14,24 @@ class DictType(types.TypeDecorator): impl = types.Text cache_ok = True - allowed_types = (int, str) allowed_keys_types: tuple[type, ...] = (str,) allowed_values_types: tuple[type | Any, ...] = (Any,) def process_bind_param(self, value, dialect): if value is None: return None - if not isinstance(value, dict) or not all( - isinstance(k, self.allowed_types) and isinstance(v, self.allowed_types) - for k, v in value.items() - ): - raise ValueError("Value must be a dict[int|str, int|str].") if not isinstance(value, dict): raise ValueError("Value must be a dict") # Any type needs some special handling allowed_types_v: tuple[type, ...] = tuple( - filter(lambda x: x is not Any, self.allowed_types_values) + filter(lambda x: x is not Any, self.allowed_values_types) ) if not len(allowed_types_v) == 0: if not all(isinstance(v, allowed_types_v) for v in value.values()): raise ValueError( - f"Value must be a dict with values of type {allowed_types_v}." + f"Value must be a dict with values of type {allowed_types_v}. Got: {value.values()}" ) if not all(isinstance(k, self.allowed_keys_types) for k in value.keys()): @@ -57,9 +51,8 @@ def copy(self, **kw): class IntDictType(DictType): """Stores a dict[int, int] as a JSON-encoded string in the database.""" - allowed_types = (int,) allowed_keys_types = (int,) - allowed_values_types = (str,) + allowed_values_types = (int,) def process_result_value(self, value, dialect): if value is None: