From 8d03803fcf1b18385e477cb9273553e2431447ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 7 Jul 2026 20:39:54 +0100 Subject: [PATCH 1/9] typing: type beets.dbcore.db --- beets/dbcore/db.py | 86 ++++++++++++++++++++------------------ beets/library/models.py | 7 ++-- beets/util/functemplate.py | 4 +- 3 files changed, 53 insertions(+), 44 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 78c443239..873c81d31 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -52,6 +52,8 @@ if TYPE_CHECKING: from sqlite3 import Connection from types import TracebackType + from beets.util import PathLike + from .query import FieldQueryType, Query, SQLiteType from .sort import FieldSort, Sort @@ -74,7 +76,7 @@ class DBAccessError(Exception): class DBCustomFunctionError(Exception): """A sqlite function registered by beets failed.""" - def __init__(self): + def __init__(self) -> None: super().__init__( "beets defined SQLite function failed; " "see the other errors above for details" @@ -183,7 +185,7 @@ class LazyDict(UserDict[str, Any]): if key in self._raw: del self._raw[key] - def __contains__(self, key: Any) -> bool: + def __contains__(self, key: object) -> bool: """Determine whether `key` is an attribute on this object.""" return key in self.data or key in self._raw @@ -294,7 +296,7 @@ class Model(ABC, Generic[D]): """ @cached_classproperty - def _relation(cls): + def _relation(cls) -> type[Model]: """The model that this model is closely related to.""" return cls @@ -336,7 +338,7 @@ class Model(ABC, Generic[D]): raise NotFoundError(f"No matching {model_cls.__name__} found") from None @classmethod - def _getters(cls: type[Model]): + def _getters(cls) -> dict[str, Callable[[Self], object]]: """Return a mapping from field names to getter functions.""" # We could cache this if it becomes a performance problem to # gather the getter mapping every time. @@ -356,7 +358,7 @@ class Model(ABC, Generic[D]): db: D | None = None, fixed_values: JSONDict | None = None, flex_values: JSONDict | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Create a new model instance. @@ -378,7 +380,7 @@ class Model(ABC, Generic[D]): f"({', '.join(f'{k}={v!r}' for k, v in dict(self).items())})" ) - def clear_dirty(self): + def clear_dirty(self) -> None: """Mark all fields as *clean* (i.e., not needing to be stored to the database). Also update the revision. """ @@ -416,7 +418,7 @@ class Model(ABC, Generic[D]): # Essential field accessors. @classmethod - def _type(cls, key) -> types.Type: + def _type(cls, key: str) -> types.Type: """Get the type of a field, a `Type` instance. If the field has no explicit type, it is given the base `Type`, @@ -444,7 +446,7 @@ class Model(ABC, Generic[D]): """Convert the attribute type according to the SQL type""" return cls._type(key).from_sql(value) - def _get(self, key, default: Any = None, raise_: bool = False): + def _get(self, key: str, default: Any = None, raise_: bool = False) -> Any: """Get the value for a field, or `default`. Alternatively, raise a KeyError if the field is not available. """ @@ -463,13 +465,13 @@ class Model(ABC, Generic[D]): get = _get - def __getitem__(self, key): + def __getitem__(self, key: str) -> Any: """Get the value for a field. Raise a KeyError if the field is not available. """ return self._get(key, raise_=True) - def _setitem(self, key, value): + def _setitem(self, key: str, value: Any) -> bool: """Assign the value for a field, return whether new and old value differ. """ @@ -491,11 +493,11 @@ class Model(ABC, Generic[D]): return changed - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: """Assign the value for a field.""" self._setitem(key, value) - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: """Remove a flexible attribute from the model.""" if key in self._values_flex: # Flexible. del self._values_flex[key] @@ -527,7 +529,7 @@ class Model(ABC, Generic[D]): # Act like a dictionary. - def update(self, values): + def update(self, values: Mapping[str, Any]) -> None: """Assign all values in the given dict.""" for key, value in values.items(): self[key] = value @@ -539,7 +541,7 @@ class Model(ABC, Generic[D]): for key in self: yield key, self[key] - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: """Determine whether `key` is an attribute on this object.""" return key in self.keys(computed=True) @@ -551,7 +553,7 @@ class Model(ABC, Generic[D]): # Convenient attribute access. - def __getattr__(self, key): + def __getattr__(self, key: str) -> Any: if key.startswith("_"): raise AttributeError(f"model has no attribute {key!r}") try: @@ -559,13 +561,13 @@ class Model(ABC, Generic[D]): except KeyError: raise AttributeError(f"no such field {key!r}") - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Any) -> None: if key.startswith("_"): super().__setattr__(key, value) else: self[key] = value - def __delattr__(self, key): + def __delattr__(self, key: str) -> None: if key.startswith("_"): super().__delattr__(key) else: @@ -573,7 +575,7 @@ class Model(ABC, Generic[D]): # Database interaction (CRUD methods). - def store(self, fields: Iterable[str] | None = None): + def store(self, fields: Iterable[str] | None = None) -> None: """Save the object's metadata into the library database. :param fields: the fields to be stored. If not specified, all fields will be. @@ -619,7 +621,7 @@ class Model(ABC, Generic[D]): self.clear_dirty() - def load(self): + def load(self) -> None: """Refresh the object's metadata from the library database. If check_revision is true, the database is only queried loaded when a @@ -632,7 +634,7 @@ class Model(ABC, Generic[D]): self.__dict__.update(self.get_fresh_from_db().__dict__) self.clear_dirty() - def remove(self): + def remove(self) -> None: """Remove the object's associated rows from the database.""" with self.db.transaction() as tx: tx.mutate(f"DELETE FROM {self._table} WHERE id=?", (self.id,)) @@ -696,18 +698,18 @@ class Model(ABC, Generic[D]): # Parsing. @classmethod - def _parse(cls, key, string: str) -> Any: + def _parse(cls, key: str, string: str) -> Any: """Parse a string as a value for the given key.""" if not isinstance(string, str): raise TypeError("_parse() argument must be a string") return cls._type(key).parse(string) - def set_parse(self, key, string: str): + def set_parse(self, key: str, string: str) -> None: """Set the object's key to a value represented by a string.""" self[key] = self._parse(key, string) - def __getstate__(self): + def __getstate__(self) -> JSONDict: """Return the state of the object for pickling. Remove the database connection as sqlite connections are not picklable. @@ -733,10 +735,10 @@ class Results(Generic[AnyModel]): model_class: type[AnyModel], rows: list[sqlite3.Row], db: D, - flex_rows, + flex_rows: list[sqlite3.Row], query: Query | None = None, - sort=None, - ): + sort: Sort | None = None, + ) -> None: """Create a result set that will construct objects of type `model_class`. @@ -862,7 +864,7 @@ class Results(Generic[AnyModel]): """Does this result contain any objects?""" return bool(len(self)) - def __getitem__(self, n): + def __getitem__(self, n: int) -> AnyModel: """Get the nth item in this result set. This is inefficient: all items up to n are materialized and thrown away. """ @@ -900,7 +902,7 @@ class Transaction: current transaction. """ - def __init__(self, db: Database): + def __init__(self, db: Database) -> None: self.db = db def __enter__(self) -> Transaction: @@ -982,21 +984,23 @@ class Transaction: else: self._mutated = True - def mutate(self, statement: str, subvals: Sequence[SQLiteType] = ()) -> Any: + def mutate( + self, statement: str, subvals: Sequence[SQLiteType] = () + ) -> int | None: """Run one write statement with shared mutation/error handling.""" with self._handle_mutate(): return self.db._connection().execute(statement, subvals).lastrowid def mutate_many( self, statement: str, subvals: Sequence[tuple[SQLiteType, ...]] = () - ) -> Any: + ) -> int | None: """Run batched writes with shared mutation/error handling.""" with self._handle_mutate(): return ( self.db._connection().executemany(statement, subvals).lastrowid ) - def script(self, statements: str): + def script(self, statements: str) -> None: """Execute a string containing multiple SQL statements.""" # We don't know whether this mutates, but quite likely it does. self._mutated = True @@ -1027,7 +1031,9 @@ class Migration(ABC): finally: self.db._connection().row_factory = original_factory - def migrate_model(self, model_cls: type[Model], *args, **kwargs) -> None: + def migrate_model( + self, model_cls: type[Model], *args: Any, **kwargs: Any + ) -> None: """Run this migration once for a model's backing table.""" table = model_cls._table if not self.db.migration_exists(self.name, table): @@ -1076,7 +1082,7 @@ class Database: data is written in a transaction. """ - def __init__(self, path, timeout: float = 5.0): + def __init__(self, path: PathLike, timeout: float = 5.0) -> None: if sqlite3.threadsafety == 0: raise RuntimeError( "sqlite3 must be compiled with multi-threading support" @@ -1204,8 +1210,8 @@ class Database: conn.row_factory = sqlite3.Row return conn - def add_functions(self, conn): - def regexp(value, pattern): + def add_functions(self, conn: sqlite3.Connection) -> None: + def regexp(value: Any, pattern: str) -> bool: if isinstance(value, bytes): value = value.decode() return re.search(pattern, str(value)) is not None @@ -1234,7 +1240,7 @@ class Database: create_function("unidecode", 1, unidecode) create_function("bytelower", 1, bytelower) - def _close(self): + def _close(self) -> None: """Close the all connections to the underlying SQLite database from all threads. This does not render the database object unusable; new connections can still be opened on demand. @@ -1265,7 +1271,7 @@ class Database: """ return Transaction(self) - def load_extension(self, path: str): + def load_extension(self, path: str) -> None: """Load an SQLite extension into all open connections.""" if not self.supports_extensions: raise ValueError( @@ -1280,7 +1286,7 @@ class Database: # Schema setup and migration. - def _make_table(self, table: str, fields: Mapping[str, types.Type]): + def _make_table(self, table: str, fields: Mapping[str, types.Type]) -> None: """Set up the schema of the database. `fields` is a mapping from field names to `Type`s. Columns are added if necessary. """ @@ -1304,7 +1310,7 @@ class Database: with self.transaction() as tx: tx.script(setup_sql) - def _make_attribute_table(self, flex_table: str): + def _make_attribute_table(self, flex_table: str) -> None: """Create a table and associated index for flexible attributes for the given entity (if they don't exist). """ @@ -1320,7 +1326,7 @@ class Database: ON {flex_table} (entity_id); """) - def _create_indices(self, table: str, indices: Sequence[Index]): + def _create_indices(self, table: str, indices: Sequence[Index]) -> None: """Create indices for the given table if they don't exist.""" with self.transaction() as tx: for index in indices: diff --git a/beets/library/models.py b/beets/library/models.py index d126de83b..95cd30c13 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar, TypeVar from mediafile import MediaFile, UnreadableFileError +from typing_extensions import Self import beets from beets import dbcore, logging, plugins, util @@ -33,7 +34,7 @@ from .fields import TYPE_BY_FIELD from .queries import parse_query_string if TYPE_CHECKING: - from collections.abc import KeysView + from collections.abc import Callable, KeysView from beets.dbcore.query import FieldQuery, FieldQueryType from beets.dbcore.sort import FieldSort @@ -348,7 +349,7 @@ class Album(LibModel): return Path(os.fsdecode(self.artpath)) if self.artpath else None @classmethod - def _getters(cls): + def _getters(cls) -> dict[str, Callable[[Self], object]]: # In addition to plugin-provided computed fields, also expose # the album's directory as `path`. getters = plugins.album_field_getters() @@ -764,7 +765,7 @@ class Item(LibModel): self.__album = album @classmethod - def _getters(cls): + def _getters(cls) -> dict[str, Callable[[Self], object]]: getters = plugins.item_field_getters() getters["singleton"] = lambda i: i.album_id is None getters["filesize"] = Item.try_filesize # In bytes. diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index a3541ab34..5547bcb0e 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -12,6 +12,8 @@ This is sort of like a tiny, horrible degeneration of a real templating engine like Jinja2 or Mustache. """ +from __future__ import annotations + import ast import dis import functools @@ -495,7 +497,7 @@ def _parse(template): @functools.lru_cache(maxsize=128) -def template(fmt): +def template(fmt) -> Template: return Template(fmt) From 7a553aad7e25cb3906f07d0491f47863dea6741e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 7 Jul 2026 20:40:43 +0100 Subject: [PATCH 2/9] typing: fix beets.dbcore.db --- beets/dbcore/db.py | 25 ++++++++++++++----------- beets/library/models.py | 20 +++++++++++--------- beetsplug/_utils/vfs.py | 2 ++ 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 873c81d31..572435e44 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -242,13 +242,15 @@ class Model(ABC, Generic[D]): flags are used to track which fields need to be stored. """ + id: int | None + # Abstract components (to be provided by subclasses). - _table: str + _table: ClassVar[str] """The main SQLite table name. """ - _flex_table: str + _flex_table: ClassVar[str] """The flex field SQLite table name. """ @@ -257,12 +259,12 @@ class Model(ABC, Generic[D]): keys are field names and the values are `Type` objects. """ - _search_fields: Sequence[str] = () + _search_fields: ClassVar[Sequence[str]] = () """The fields that should be queried by default by unqualified query terms. """ - _indices: Sequence[Index] = () + _indices: ClassVar[Sequence[Index]] = () """A sequence of `Index` objects that describe the indices to be created for this table. """ @@ -298,7 +300,7 @@ class Model(ABC, Generic[D]): @cached_classproperty def _relation(cls) -> type[Model]: """The model that this model is closely related to.""" - return cls + return cls # type: ignore[return-value] @cached_classproperty def relation_join(cls) -> str: @@ -332,7 +334,8 @@ class Model(ABC, Generic[D]): def get_fresh_from_db(self) -> Self: """Load this object from the database.""" model_cls = self.__class__ - if obj := self.db._get(model_cls, self.id): + + if self.id is not None and (obj := self.db._get(model_cls, self.id)): return obj raise NotFoundError(f"No matching {model_cls.__name__} found") from None @@ -400,7 +403,7 @@ class Model(ABC, Generic[D]): return self._db - def copy(self) -> Model: + def copy(self) -> Self: """Create a copy of the model object. The field values and other state is duplicated, but the new copy @@ -1032,13 +1035,13 @@ class Migration(ABC): self.db._connection().row_factory = original_factory def migrate_model( - self, model_cls: type[Model], *args: Any, **kwargs: Any + self, model_cls: type[Model], current_fields: set[str] ) -> None: """Run this migration once for a model's backing table.""" table = model_cls._table if not self.db.migration_exists(self.name, table): self._before_migration_backup(table) - self._migrate_data(model_cls, *args, **kwargs) + self._migrate_data(model_cls, current_fields) self.db.record_migration(self.name, table) def _before_migration_backup(self, table: str) -> None: @@ -1194,8 +1197,8 @@ class Database: 0, ): # If possible, disable double-quoted strings - conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DDL, 0) - conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DML, 0) + conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DDL, False) + conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DML, False) self.add_functions(conn) diff --git a/beets/library/models.py b/beets/library/models.py index 95cd30c13..184544524 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -352,10 +352,11 @@ class Album(LibModel): def _getters(cls) -> dict[str, Callable[[Self], object]]: # In addition to plugin-provided computed fields, also expose # the album's directory as `path`. - getters = plugins.album_field_getters() - getters["path"] = Album.item_dir - getters["albumtotal"] = Album._albumtotal - return getters + return { + **plugins.album_field_getters(), + "path": Album.item_dir, + "albumtotal": Album._albumtotal, + } def items(self): """Return an iterable over the items associated with this @@ -766,11 +767,12 @@ class Item(LibModel): @classmethod def _getters(cls) -> dict[str, Callable[[Self], object]]: - getters = plugins.item_field_getters() - getters["singleton"] = lambda i: i.album_id is None - getters["filesize"] = Item.try_filesize # In bytes. - getters["has_cover_art"] = Item.has_cover_art - return getters + return { + **plugins.item_field_getters(), + "singleton": lambda i: i.album_id is None, + "filesize": Item.try_filesize, # In bytes. + "has_cover_art": Item.has_cover_art, + } def duplicates_query(self, fields: list[str]) -> dbcore.AndQuery: """Return a query for entities with same values in the given fields.""" diff --git a/beetsplug/_utils/vfs.py b/beetsplug/_utils/vfs.py index 740e901b4..58b626d9f 100644 --- a/beetsplug/_utils/vfs.py +++ b/beetsplug/_utils/vfs.py @@ -43,6 +43,8 @@ def libtree(lib: Library) -> Node: """ root = Node({}, {}) for item in lib.items(): + if item.id is None: + continue dest = item.destination(relative_to_libdir=True) parts = util.components(util.as_string(dest)) _insert(root, parts, item.id) From ca8e9f69a8a0254aa767dc86f6d808d02f6af05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Jul 2026 15:24:06 +0100 Subject: [PATCH 3/9] typing: type beets.dbcore.sort --- beets/dbcore/sort.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/beets/dbcore/sort.py b/beets/dbcore/sort.py index d45577ea6..501070707 100644 --- a/beets/dbcore/sort.py +++ b/beets/dbcore/sort.py @@ -32,20 +32,20 @@ class Sort: def __hash__(self) -> int: return 0 - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return type(self) is type(other) - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}()" class MultipleSort(Sort): """Sort that encapsulates multiple sub-sorts.""" - def __init__(self, sorts: list[Sort] | None = None): + def __init__(self, sorts: list[Sort] | None = None) -> None: self.sorts = sorts or [] - def add_sort(self, sort: Sort): + def add_sort(self, sort: Sort) -> None: self.sorts.append(sort) def order_clause(self) -> str: @@ -72,7 +72,7 @@ class MultipleSort(Sort): return True return False - def sort(self, items): + def sort(self, items: list[AnyModel]) -> list[AnyModel]: slow_sorts = [] switch_slow = False for sort in reversed(self.sorts): @@ -88,13 +88,13 @@ class MultipleSort(Sort): items = sort.sort(items) return items - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({self.sorts!r})" - def __hash__(self): + def __hash__(self) -> int: return hash(tuple(self.sorts)) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return super().__eq__(other) and self.sorts == other.sorts @@ -105,7 +105,7 @@ class FieldSort(Sort): def __init__( self, field: str, ascending: bool = True, case_insensitive: bool = True - ): + ) -> None: self.field = field self.ascending = ascending self.case_insensitive = case_insensitive @@ -144,7 +144,7 @@ class FieldSort(Sort): def __hash__(self) -> int: return hash((self.field, self.ascending)) - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return ( super().__eq__(other) and self.field == other.field @@ -190,7 +190,7 @@ class NullSort(Sort): def __bool__(self) -> bool: return False - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return type(self) is type(other) or other is None def __hash__(self) -> int: @@ -202,7 +202,7 @@ class SmartArtistSort(FieldSort): prioritizing the sort field over the raw field. """ - def order_clause(self): + def order_clause(self) -> str: order = "ASC" if self.ascending else "DESC" collate = "COLLATE NOCASE" if self.case_insensitive else "" field = self.field @@ -210,7 +210,7 @@ class SmartArtistSort(FieldSort): return f"COALESCE(NULLIF({field}_sort, ''), {field}) {collate} {order}" def sort(self, objs: list[AnyModel]) -> list[AnyModel]: - def key(o): + def key(o: Model) -> str | bytes: val = o[f"{self.field}_sort"] or o[self.field] return val.lower() if self.case_insensitive else val From a000232509bfb6c0a53aa94f83374ff257a38e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Jul 2026 15:24:27 +0100 Subject: [PATCH 4/9] typing: fix beets.dbcore.sort --- beets/dbcore/sort.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/sort.py b/beets/dbcore/sort.py index 501070707..159696c16 100644 --- a/beets/dbcore/sort.py +++ b/beets/dbcore/sort.py @@ -95,7 +95,11 @@ class MultipleSort(Sort): return hash(tuple(self.sorts)) def __eq__(self, other: object) -> bool: - return super().__eq__(other) and self.sorts == other.sorts + return ( + isinstance(other, MultipleSort) + and super().__eq__(other) + and self.sorts == other.sorts + ) class FieldSort(Sort): @@ -146,7 +150,8 @@ class FieldSort(Sort): def __eq__(self, other: object) -> bool: return ( - super().__eq__(other) + isinstance(other, FieldSort) + and super().__eq__(other) and self.field == other.field and self.ascending == other.ascending ) @@ -210,8 +215,8 @@ class SmartArtistSort(FieldSort): return f"COALESCE(NULLIF({field}_sort, ''), {field}) {collate} {order}" def sort(self, objs: list[AnyModel]) -> list[AnyModel]: - def key(o: Model) -> str | bytes: - val = o[f"{self.field}_sort"] or o[self.field] + def key(obj: Model) -> str | bytes: + val = obj[f"{self.field}_sort"] or obj[self.field] return val.lower() if self.case_insensitive else val return sorted(objs, key=key, reverse=not self.ascending) From b1701c2e12b2de8e5462affe46ecc6816a5fc508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Jul 2026 15:22:02 +0100 Subject: [PATCH 5/9] typing: type beets.dbcore.query --- beets/dbcore/query.py | 62 ++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 46df008e1..0c6ade5bd 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -47,7 +47,9 @@ class InvalidQueryError(ParsingError): The query should be a unicode string or a list, which will be space-joined. """ - def __init__(self, query, explanation): + def __init__( + self, query: str | Sequence[str] | Query, explanation: Exception + ) -> None: if isinstance(query, list): query = " ".join(query) message = f"'{query}': {explanation}" @@ -61,7 +63,9 @@ class InvalidQueryArgumentValueError(ParsingError): query) InvalidQueryError can be raised. """ - def __init__(self, what, expected, detail=None): + def __init__( + self, what: str, expected: str, detail: str | None = None + ) -> None: message = f"'{what}' is not {expected}" if detail: message = f"{message}: {detail}" @@ -89,7 +93,7 @@ class Query(ABC): """ @abstractmethod - def match(self, obj: Model): + def match(self, obj: Model) -> bool: """Check whether this query matches a given Model. Can be used to perform queries on arbitrary sets of Model. """ @@ -100,7 +104,7 @@ class Query(ABC): def __repr__(self) -> str: return f"{self.__class__.__name__}()" - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return type(self) is type(other) def __hash__(self) -> int: @@ -136,7 +140,7 @@ class FieldQuery(Query, Generic[P]): """Return a set with field names that this query operates on.""" return {self.field_name} - def __init__(self, field_name: str, pattern: P, fast: bool = True): + def __init__(self, field_name: str, pattern: P, fast: bool = True) -> None: self.table, _, self.field_name = field_name.rpartition(".") self.pattern = pattern self.fast = fast @@ -151,7 +155,7 @@ class FieldQuery(Query, Generic[P]): return None, () @classmethod - def value_match(cls, pattern: P, value: Any): + def value_match(cls, pattern: P, value: Any) -> bool: """Determine whether the value matches the pattern.""" raise NotImplementedError @@ -164,7 +168,7 @@ class FieldQuery(Query, Generic[P]): f"fast={self.fast})" ) - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return ( super().__eq__(other) and self.field_name == other.field_name @@ -191,7 +195,7 @@ class MatchQuery(FieldQuery[AnySQLiteType]): class NoneQuery(FieldQuery[None]): """A query that checks whether a field is null.""" - def __init__(self, field, fast: bool = True): + def __init__(self, field: str, fast: bool = True) -> None: super().__init__(field, None, fast) def col_clause(self) -> tuple[str, Sequence[SQLiteType]]: @@ -210,7 +214,7 @@ class StringFieldQuery(FieldQuery[P]): """ @classmethod - def value_match(cls, pattern: P, value: Any): + def value_match(cls, pattern: P, value: Any) -> bool: """Determine whether the value matches the pattern. The value may have any type. """ @@ -371,7 +375,9 @@ class RegexpQuery(StringFieldQuery[Pattern[str]]): expression. """ - def __init__(self, field_name: str, pattern: str, fast: bool = True): + def __init__( + self, field_name: str, pattern: str, fast: bool = True + ) -> None: pattern = self._normalize(pattern) try: pattern_re = re.compile(pattern) @@ -403,7 +409,9 @@ class BooleanQuery(MatchQuery[int]): string reflecting a boolean. """ - def __init__(self, field_name: str, pattern: bool, fast: bool = True): + def __init__( + self, field_name: str, pattern: bool, fast: bool = True + ) -> None: if isinstance(pattern, str): pattern = util.str2bool(pattern) @@ -438,7 +446,9 @@ class NumericQuery(FieldQuery[str]): except ValueError: raise InvalidQueryArgumentValueError(s, "an int or a float") - def __init__(self, field_name: str, pattern: str, fast: bool = True): + def __init__( + self, field_name: str, pattern: str, fast: bool = True + ) -> None: super().__init__(field_name, pattern, fast) parts = pattern.split("..", 1) @@ -520,7 +530,7 @@ class CollectionQuery(Query): """Return a set with field names that this query operates on.""" return reduce(or_, (sq.field_names for sq in self.subqueries)) - def __init__(self, subqueries: Sequence[Query] = ()): + def __init__(self, subqueries: Sequence[Query] = ()) -> None: self.subqueries = subqueries # Act like a sequence. @@ -528,13 +538,13 @@ class CollectionQuery(Query): def __len__(self) -> int: return len(self.subqueries) - def __getitem__(self, key): + def __getitem__(self, key: int) -> Query: return self.subqueries[key] def __iter__(self) -> Iterator[Query]: return iter(self.subqueries) - def __contains__(self, subq) -> bool: + def __contains__(self, subq: Query) -> bool: return subq in self.subqueries def clause_with_joiner( @@ -558,7 +568,7 @@ class CollectionQuery(Query): def __repr__(self) -> str: return f"{self.__class__.__name__}({self.subqueries!r})" - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return super().__eq__(other) and self.subqueries == other.subqueries def __hash__(self) -> int: @@ -575,10 +585,10 @@ class MutableCollectionQuery(CollectionQuery): subqueries: MutableSequence[Query] - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: Query) -> None: self.subqueries[key] = value - def __delitem__(self, key): + def __delitem__(self, key: int) -> None: del self.subqueries[key] @@ -612,7 +622,7 @@ class NotQuery(Query): """Return a set with field names that this query operates on.""" return self.subquery.field_names - def __init__(self, subquery): + def __init__(self, subquery: Query) -> None: self.subquery = subquery def clause(self) -> tuple[str | None, Sequence[SQLiteType]]: @@ -629,7 +639,7 @@ class NotQuery(Query): def __repr__(self) -> str: return f"{self.__class__.__name__}({self.subquery!r})" - def __eq__(self, other) -> bool: + def __eq__(self, other: object) -> bool: return super().__eq__(other) and self.subquery == other.subquery def __hash__(self) -> int: @@ -696,7 +706,7 @@ class Period: } relative_re = "(?P[+|-]?)(?P[0-9]+)(?P[y|m|w|d])" - def __init__(self, date: datetime, precision: str): + def __init__(self, date: datetime, precision: str) -> None: """Create a period with the given date (a `datetime` object) and precision (a string, one of "year", "month", "day", "hour", "minute", or "second"). @@ -798,7 +808,7 @@ class DateInterval: A right endpoint of None means towards infinity. """ - def __init__(self, start: datetime | None, end: datetime | None): + def __init__(self, start: datetime | None, end: datetime | None) -> None: if start is not None and end is not None and not start < end: raise ValueError(f"start date {start} is not before end date {end}") self.start = start @@ -834,7 +844,9 @@ class DateQuery(FieldQuery[str]): using an ellipsis interval syntax similar to that of NumericQuery. """ - def __init__(self, field_name: str, pattern: str, fast: bool = True): + def __init__( + self, field_name: str, pattern: str, fast: bool = True + ) -> None: super().__init__(field_name, pattern, fast) start, end = _parse_periods(pattern) self.interval = DateInterval.from_periods(start, end) @@ -908,7 +920,9 @@ class SingletonQuery(FieldQuery[str]): and singleton:false, singleton:0 are handled consistently. """ - def __new__(cls, field: str, value: str, *args, **kwargs): + def __new__( + cls, field: str, value: str, *args: Any, **kwargs: Any + ) -> Query: query = NoneQuery("album_id") if util.str2bool(value): return query From b31c3bf9d41ec2d4767eab1168b2781a0e5880a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Jul 2026 15:22:12 +0100 Subject: [PATCH 6/9] typing: fix beets.dbcore.query --- beets/dbcore/query.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 0c6ade5bd..7a5fecfc3 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -170,7 +170,7 @@ class FieldQuery(Query, Generic[P]): def __eq__(self, other: object) -> bool: return ( - super().__eq__(other) + type(self) is type(other) and self.field_name == other.field_name and self.pattern == other.pattern ) @@ -569,7 +569,7 @@ class CollectionQuery(Query): return f"{self.__class__.__name__}({self.subqueries!r})" def __eq__(self, other: object) -> bool: - return super().__eq__(other) and self.subqueries == other.subqueries + return type(self) is type(other) and self.subqueries == other.subqueries def __hash__(self) -> int: """Since subqueries are mutable, this object should not be hashable. @@ -640,7 +640,7 @@ class NotQuery(Query): return f"{self.__class__.__name__}({self.subquery!r})" def __eq__(self, other: object) -> bool: - return super().__eq__(other) and self.subquery == other.subquery + return isinstance(other, NotQuery) and self.subquery == other.subquery def __hash__(self) -> int: return hash(("not", hash(self.subquery))) @@ -920,7 +920,7 @@ class SingletonQuery(FieldQuery[str]): and singleton:false, singleton:0 are handled consistently. """ - def __new__( + def __new__( # type: ignore[misc] cls, field: str, value: str, *args: Any, **kwargs: Any ) -> Query: query = NoneQuery("album_id") From ea3d6af6169d33be009b86bf5607baae7cf9652a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Jul 2026 15:14:10 +0100 Subject: [PATCH 7/9] typing: type beets.dbcore.types --- beets/dbcore/types.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index ef9152e70..de05fc9f4 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -25,7 +25,7 @@ class ModelType(typing.Protocol): given type. """ - def __init__(self, value: Any = None): ... + def __init__(self, value: Any = None) -> None: ... # Generic type variables, used for the value type T and null type N (if @@ -139,7 +139,7 @@ class Default(Type[str, None]): model_type = str @property - def null(self): + def null(self) -> None: return None @@ -176,7 +176,7 @@ class BasePaddedInt(BaseInteger[N]): padded with zeroes. """ - def __init__(self, digits: int): + def __init__(self, digits: int) -> None: self.digits = digits def format(self, value: int | N) -> str: @@ -192,7 +192,7 @@ class ScaledInt(Integer): constant and adds a suffix. Good for units with large magnitudes. """ - def __init__(self, unit: int, suffix: str = ""): + def __init__(self, unit: int, suffix: str = "") -> None: self.unit = unit self.suffix = suffix @@ -209,7 +209,7 @@ class Id(NullInteger): def null(self) -> None: return None - def __init__(self, primary: bool = True): + def __init__(self, primary: bool = True) -> None: if primary: self.sql = "INTEGER PRIMARY KEY" @@ -223,7 +223,7 @@ class BaseFloat(Type[float, N]): query: query.FieldQueryType = query.NumericQuery model_type = float - def __init__(self, digits: int = 1): + def __init__(self, digits: int = 1) -> None: self.digits = digits def format(self, value: float | N) -> str: @@ -277,13 +277,13 @@ class DelimitedString(BaseString[list, list]): # type: ignore[type-arg] model_type = list fmt_delimiter = "; " - def __init__(self, db_delimiter: str): + def __init__(self, db_delimiter: str) -> None: self.db_delimiter = db_delimiter - def format(self, value: list[str]): + def format(self, value: list[str]) -> str: return self.fmt_delimiter.join(value) - def parse(self, string: str): + def parse(self, string: str) -> list[str]: if not string: return [] @@ -317,7 +317,7 @@ class DelimitedString(BaseString[list, list]): # type: ignore[type-arg] return self.parse(value) return self.model_type(value) - def to_sql(self, model_value: list[str]): + def to_sql(self, model_value: list[str]) -> str: return self.db_delimiter.join(model_value) @@ -340,12 +340,12 @@ class DateType(Float): # TODO distinguish between date and time types query = query.DateQuery - def format(self, value): + def format(self, value: float) -> str: return time.strftime( beets.config["time_format"].as_str(), time.localtime(value or 0) ) - def parse(self, string): + def parse(self, string: str) -> float | N: try: # Try a formatted date string. return time.mktime( @@ -384,7 +384,7 @@ class BasePathType(Type[bytes, N]): return value - def from_sql(self, sql_value): + def from_sql(self, sql_value: SQLiteType) -> bytes | N: return pathutils.expand_path_from_db(self.normalize(sql_value)) def to_sql(self, value: pathutils.MaybeBytes) -> BLOB_TYPE | None: @@ -429,7 +429,7 @@ class MusicalKey(String): null = None - def parse(self, key): + def parse(self, key: str) -> str | None: key = key.lower() for flat, sharp in self.ENHARMONIC.items(): key = re.sub(flat, sharp, key) @@ -437,7 +437,7 @@ class MusicalKey(String): key = re.sub(r"[\W\s]+major", "", key) return key.capitalize() - def normalize(self, key): + def normalize(self, key: Any) -> str | None: if key is None: return None return self.parse(key) @@ -448,12 +448,12 @@ class DurationType(Float): query = query.DurationQuery - def format(self, value): + def format(self, value: float) -> str: if not beets.config["format_raw_length"].get(bool): return human_seconds_short(value or 0.0) return value - def parse(self, string): + def parse(self, string: str) -> float | N: try: # Try to format back hh:ss to seconds. return raw_seconds_short(string) From 1266256f5e9a77d2ba7ffb64c8e79caca806554f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 6 Jul 2026 15:19:21 +0100 Subject: [PATCH 8/9] typing: fix beets.dbcore.types --- beets/dbcore/types.py | 7 +++++-- test/dbcore/test_types.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index de05fc9f4..80f1d8714 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -385,7 +385,10 @@ class BasePathType(Type[bytes, N]): return value def from_sql(self, sql_value: SQLiteType) -> bytes | N: - return pathutils.expand_path_from_db(self.normalize(sql_value)) + value = self.normalize(sql_value) + if isinstance(value, bytes): + return pathutils.expand_path_from_db(value) + return value def to_sql(self, value: pathutils.MaybeBytes) -> BLOB_TYPE | None: value = pathutils.normalize_path_for_db(value) @@ -451,7 +454,7 @@ class DurationType(Float): def format(self, value: float) -> str: if not beets.config["format_raw_length"].get(bool): return human_seconds_short(value or 0.0) - return value + return str(value) def parse(self, string: str) -> float | N: try: diff --git a/test/dbcore/test_types.py b/test/dbcore/test_types.py index aa5b5e7c2..4eaab05d3 100644 --- a/test/dbcore/test_types.py +++ b/test/dbcore/test_types.py @@ -56,8 +56,8 @@ def test_durationtype(): assert t.null == t.parse("not61.23") # config format_raw_length beets.config["format_raw_length"] = True - assert 61.23 == t.format(61.23) - assert 3601.23 == t.format(3601.23) + assert "61.23" == t.format(61.23) + assert "3601.23" == t.format(3601.23) @pytest.mark.parametrize( From 0f00bc9c1f7863ebaaa898a19ce136f58d7209d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Mon, 13 Jul 2026 13:47:44 +0100 Subject: [PATCH 9/9] Add commits to ignore --- .git-blame-ignore-revs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 94367db7c..3cbe1703a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -229,3 +229,11 @@ aef9c64cb07c8f828d0dd6e69a5485abf2c43396 1418a80e0531fba1e01d0ad2a4decdefaed6b365 # typing: add types to beets.test.helper 29c887db5bff1cfc4c6ea60505636742fa41cccf +# typing: type beets.dbcore.db +8d03803fcf1b18385e477cb9273553e2431447ce +# typing: type beets.dbcore.sort +ca8e9f69a8a0254aa767dc86f6d808d02f6af05d +# typing: type beets.dbcore.query +b1701c2e12b2de8e5462affe46ecc6816a5fc508 +# typing: type beets.dbcore.types +ea3d6af6169d33be009b86bf5607baae7cf9652a