typing: add types to beets.dbcore (#6820)

### What changed

- This PR is mainly a typing pass over `beets.dbcore`, especially in
`db.py`, `query.py`, `sort.py`, and `types.py`.
- The main architecture change is in `db.py`: model materialization is
now centralized in `Database._make_model()`, while `Results` only
handles lazy row iteration and uses a materializer callback.
- Reloading is also cleaner now: `Model.get_fresh_from_db()` goes
through `Database._reload()`, which keeps database lookup logic in the
database layer and preserves the model's concrete type.
- Along the way, a few runtime mismatches found by typing were corrected
instead of just adjusting annotations.

### Why

- The goal is to make the typed API match the real behavior of `dbcore`.
- This improves separation of responsibilities: querying and result
iteration stay in `Results`, while row-to-model construction and reload
behavior live in `Database`.

### High-level impact

- `dbcore` has a clearer internal structure and a more explicit API
surface.
- Type information is more accurate across queries, sorting, models, and
field types, which should make future refactors safer.
- A few small correctness fixes are included:
  - `DurationType.format()` now always returns `str`.
- `PathType.from_sql()` only expands paths when the normalized value is
actually `bytes`.
- Several `__eq__` implementations now use explicit `isinstance(...)`
checks.
- Reload behavior now has regression coverage in
`test/dbcore/test_db.py`.
- Overall, this is a low-risk cleanup: mostly typing and API-shape
improvements, with a few small behavior fixes discovered during the
work.
This commit is contained in:
Šarūnas Nejus
2026-07-15 20:28:19 +01:00
committed by GitHub
9 changed files with 173 additions and 127 deletions

View File

@@ -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

View File

@@ -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
@@ -240,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.
"""
@@ -255,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.
"""
@@ -294,9 +298,9 @@ 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
return cls # type: ignore[return-value]
@cached_classproperty
def relation_join(cls) -> str:
@@ -330,13 +334,14 @@ 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
@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 +361,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 +383,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.
"""
@@ -398,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
@@ -416,7 +421,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 +449,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 +468,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 +496,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 +532,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 +544,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 +556,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 +564,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 +578,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 +624,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 +637,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 +701,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 +738,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 +867,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 +905,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 +987,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,12 +1034,14 @@ 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], 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:
@@ -1076,7 +1085,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"
@@ -1188,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)
@@ -1204,8 +1213,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 +1243,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 +1274,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 +1289,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 +1313,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 +1329,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:

View File

@@ -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,9 +168,9 @@ class FieldQuery(Query, Generic[P]):
f"fast={self.fast})"
)
def __eq__(self, other) -> bool:
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
)
@@ -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,8 +568,8 @@ class CollectionQuery(Query):
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.subqueries!r})"
def __eq__(self, other) -> bool:
return super().__eq__(other) and self.subqueries == other.subqueries
def __eq__(self, other: object) -> bool:
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.
@@ -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,8 +639,8 @@ class NotQuery(Query):
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.subquery!r})"
def __eq__(self, other) -> bool:
return super().__eq__(other) and self.subquery == other.subquery
def __eq__(self, other: object) -> bool:
return isinstance(other, NotQuery) and self.subquery == other.subquery
def __hash__(self) -> int:
return hash(("not", hash(self.subquery)))
@@ -696,7 +706,7 @@ class Period:
}
relative_re = "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)(?P<timespan>[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__( # type: ignore[misc]
cls, field: str, value: str, *args: Any, **kwargs: Any
) -> Query:
query = NoneQuery("album_id")
if util.str2bool(value):
return query

View File

@@ -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,14 +88,18 @@ 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):
return super().__eq__(other) and self.sorts == other.sorts
def __eq__(self, other: object) -> bool:
return (
isinstance(other, MultipleSort)
and super().__eq__(other)
and self.sorts == other.sorts
)
class FieldSort(Sort):
@@ -105,7 +109,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,9 +148,10 @@ 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)
isinstance(other, FieldSort)
and super().__eq__(other)
and self.field == other.field
and self.ascending == other.ascending
)
@@ -190,7 +195,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 +207,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,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):
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)

View File

@@ -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,8 +384,11 @@ class BasePathType(Type[bytes, N]):
return value
def from_sql(self, sql_value):
return pathutils.expand_path_from_db(self.normalize(sql_value))
def from_sql(self, sql_value: SQLiteType) -> bytes | N:
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)
@@ -429,7 +432,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 +440,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 +451,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
return str(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)

View File

@@ -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,13 +349,14 @@ 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()
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
@@ -764,12 +766,13 @@ class Item(LibModel):
self.__album = album
@classmethod
def _getters(cls):
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
def _getters(cls) -> dict[str, Callable[[Self], object]]:
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."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(