mirror of
https://github.com/beetbox/beets.git
synced 2026-07-16 14:07:40 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user