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 diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 78c443239..572435e44 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 @@ -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: diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 46df008e1..7a5fecfc3 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,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[+|-]?)(?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__( # type: ignore[misc] + cls, field: str, value: str, *args: Any, **kwargs: Any + ) -> Query: query = NoneQuery("album_id") if util.str2bool(value): return query diff --git a/beets/dbcore/sort.py b/beets/dbcore/sort.py index d45577ea6..159696c16 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,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) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index ef9152e70..80f1d8714 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,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) diff --git a/beets/library/models.py b/beets/library/models.py index d126de83b..184544524 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,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.""" 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) 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) 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(