Skip to content

Commit 7165b04

Browse files
authored
Move queries and types to respective modules (#5775)
This PR moves query and type definitions away from `library.py` to `dbcore` to improve modularity and organization. **Key Changes:** * **Query and Type Relocation:** * `PathQuery` and `SingletonQuery` moved from `beets.library` to `beets.dbcore.query`. * `DateType`, `PathType` (and its variants `NullPathType`), `MusicalKey`, and `DurationType` moved from `beets.library` to `beets.dbcore.types`. * The `BLOB_TYPE` definition was moved from `beets.library` to `beets.dbcore.query` and then referenced in `beets.dbcore.types`. * The `human_seconds_short` utility function was moved from `beets.ui` to `beets.util` due to circular dependency. * **Test Modernization:** * The `PathQueryTest` class in `test/test_query.py` has been rewritten to use `pytest.mark.parametrize` for more concise and readable test cases. * **Import Updates:** All internal references to these moved classes and functions have been updated across the codebase.
2 parents 2a896d4 + 9d088ab commit 7165b04

21 files changed

+573
-706
lines changed

.git-blame-ignore-revs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ f36bc497c8c8f89004f3f6879908d3f0b25123e1
4848
# Fix formatting
4949
c490ac5810b70f3cf5fd8649669838e8fdb19f4d
5050
# Importer restructure
51-
9147577b2b19f43ca827e9650261a86fb0450cef
51+
9147577b2b19f43ca827e9650261a86fb0450cef
52+
# Copy paste query, types from library to dbcore
53+
1a045c91668c771686f4c871c84f1680af2e944b

beets/dbcore/query.py

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,33 @@
1616

1717
from __future__ import annotations
1818

19+
import os
1920
import re
2021
import unicodedata
2122
from abc import ABC, abstractmethod
2223
from collections.abc import Iterator, MutableSequence, Sequence
2324
from datetime import datetime, timedelta
24-
from functools import reduce
25+
from functools import cached_property, reduce
2526
from operator import mul, or_
2627
from re import Pattern
2728
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union
2829

2930
from beets import util
3031

3132
if TYPE_CHECKING:
32-
from beets.dbcore import Model
33-
from beets.dbcore.db import AnyModel
33+
from beets.dbcore.db import AnyModel, Model
3434

3535
P = TypeVar("P", default=Any)
3636
else:
3737
P = TypeVar("P")
3838

39+
# To use the SQLite "blob" type, it doesn't suffice to provide a byte
40+
# string; SQLite treats that as encoded text. Wrapping it in a
41+
# `memoryview` tells it that we actually mean non-text data.
42+
# needs to be defined in here due to circular import.
43+
# TODO: remove it from this module and define it in dbcore/types.py instead
44+
BLOB_TYPE = memoryview
45+
3946

4047
class ParsingError(ValueError):
4148
"""Abstract class for any unparsable user-requested album/query
@@ -267,6 +274,91 @@ def string_match(cls, pattern: str, value: str) -> bool:
267274
return pattern.lower() in value.lower()
268275

269276

277+
class PathQuery(FieldQuery[bytes]):
278+
"""A query that matches all items under a given path.
279+
280+
Matching can either be case-insensitive or case-sensitive. By
281+
default, the behavior depends on the OS: case-insensitive on Windows
282+
and case-sensitive otherwise.
283+
"""
284+
285+
def __init__(self, field: str, pattern: bytes, fast: bool = True) -> None:
286+
"""Create a path query.
287+
288+
`pattern` must be a path, either to a file or a directory.
289+
"""
290+
path = util.normpath(pattern)
291+
292+
# Case sensitivity depends on the filesystem that the query path is located on.
293+
self.case_sensitive = util.case_sensitive(path)
294+
295+
# Use a normalized-case pattern for case-insensitive matches.
296+
if not self.case_sensitive:
297+
# We need to lowercase the entire path, not just the pattern.
298+
# In particular, on Windows, the drive letter is otherwise not
299+
# lowercased.
300+
# This also ensures that the `match()` method below and the SQL
301+
# from `col_clause()` do the same thing.
302+
path = path.lower()
303+
304+
super().__init__(field, path, fast)
305+
306+
@cached_property
307+
def dir_path(self) -> bytes:
308+
return os.path.join(self.pattern, b"")
309+
310+
@staticmethod
311+
def is_path_query(query_part: str) -> bool:
312+
"""Try to guess whether a unicode query part is a path query.
313+
314+
The path query must
315+
1. precede the colon in the query, if a colon is present
316+
2. contain either ``os.sep`` or ``os.altsep`` (Windows)
317+
3. this path must exist on the filesystem.
318+
"""
319+
query_part = query_part.split(":")[0]
320+
321+
return (
322+
# make sure the query part contains a path separator
323+
bool(set(query_part) & {os.sep, os.altsep})
324+
and os.path.exists(util.normpath(query_part))
325+
)
326+
327+
def match(self, obj: Model) -> bool:
328+
"""Check whether a model object's path matches this query.
329+
330+
Performs either an exact match against the pattern or checks if the path
331+
starts with the given directory path. Case sensitivity depends on the object's
332+
filesystem as determined during initialization.
333+
"""
334+
path = obj.path if self.case_sensitive else obj.path.lower()
335+
return (path == self.pattern) or path.startswith(self.dir_path)
336+
337+
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
338+
"""Generate an SQL clause that implements path matching in the database.
339+
340+
Returns a tuple of SQL clause string and parameter values list that matches
341+
paths either exactly or by directory prefix. Handles case sensitivity
342+
appropriately using BYTELOWER for case-insensitive matches.
343+
"""
344+
if self.case_sensitive:
345+
left, right = self.field, "?"
346+
else:
347+
left, right = f"BYTELOWER({self.field})", "BYTELOWER(?)"
348+
349+
return f"({left} = {right}) || (substr({left}, 1, ?) = {right})", [
350+
BLOB_TYPE(self.pattern),
351+
len(dir_blob := BLOB_TYPE(self.dir_path)),
352+
dir_blob,
353+
]
354+
355+
def __repr__(self) -> str:
356+
return (
357+
f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
358+
f"fast={self.fast}, case_sensitive={self.case_sensitive})"
359+
)
360+
361+
270362
class RegexpQuery(StringFieldQuery[Pattern[str]]):
271363
"""A query that matches a regular expression in a specific Model field.
272364
@@ -844,6 +936,24 @@ def _convert(self, s: str) -> float | None:
844936
)
845937

846938

939+
class SingletonQuery(FieldQuery[str]):
940+
"""This query is responsible for the 'singleton' lookup.
941+
942+
It is based on the FieldQuery and constructs a SQL clause
943+
'album_id is NULL' which yields the same result as the previous filter
944+
in Python but is more performant since it's done in SQL.
945+
946+
Using util.str2bool ensures that lookups like singleton:true, singleton:1
947+
and singleton:false, singleton:0 are handled consistently.
948+
"""
949+
950+
def __new__(cls, field: str, value: str, *args, **kwargs):
951+
query = NoneQuery("album_id")
952+
if util.str2bool(value):
953+
return query
954+
return NotQuery(query)
955+
956+
847957
# Sorting.
848958

849959

beets/dbcore/types.py

Lines changed: 147 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,20 @@
1616

1717
from __future__ import annotations
1818

19+
import re
20+
import time
1921
import typing
2022
from abc import ABC
2123
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
2224

23-
from beets.util import str2bool
25+
import beets
26+
from beets import util
27+
from beets.util.units import human_seconds_short, raw_seconds_short
2428

25-
from .query import (
26-
BooleanQuery,
27-
FieldQueryType,
28-
NumericQuery,
29-
SQLiteType,
30-
SubstringQuery,
31-
)
29+
from . import query
30+
31+
SQLiteType = query.SQLiteType
32+
BLOB_TYPE = query.BLOB_TYPE
3233

3334

3435
class ModelType(typing.Protocol):
@@ -61,7 +62,7 @@ class Type(ABC, Generic[T, N]):
6162
"""The SQLite column type for the value.
6263
"""
6364

64-
query: FieldQueryType = SubstringQuery
65+
query: query.FieldQueryType = query.SubstringQuery
6566
"""The `Query` subclass to be used when querying the field.
6667
"""
6768

@@ -160,7 +161,7 @@ class BaseInteger(Type[int, N]):
160161
"""A basic integer type."""
161162

162163
sql = "INTEGER"
163-
query = NumericQuery
164+
query = query.NumericQuery
164165
model_type = int
165166

166167
def normalize(self, value: Any) -> int | N:
@@ -241,7 +242,7 @@ class BaseFloat(Type[float, N]):
241242
"""
242243

243244
sql = "REAL"
244-
query: FieldQueryType = NumericQuery
245+
query: query.FieldQueryType = query.NumericQuery
245246
model_type = float
246247

247248
def __init__(self, digits: int = 1):
@@ -271,7 +272,7 @@ class BaseString(Type[T, N]):
271272
"""A Unicode string type."""
272273

273274
sql = "TEXT"
274-
query = SubstringQuery
275+
query = query.SubstringQuery
275276

276277
def normalize(self, value: Any) -> T | N:
277278
if value is None:
@@ -312,14 +313,145 @@ class Boolean(Type):
312313
"""A boolean type."""
313314

314315
sql = "INTEGER"
315-
query = BooleanQuery
316+
query = query.BooleanQuery
316317
model_type = bool
317318

318319
def format(self, value: bool) -> str:
319320
return str(bool(value))
320321

321322
def parse(self, string: str) -> bool:
322-
return str2bool(string)
323+
return util.str2bool(string)
324+
325+
326+
class DateType(Float):
327+
# TODO representation should be `datetime` object
328+
# TODO distinguish between date and time types
329+
query = query.DateQuery
330+
331+
def format(self, value):
332+
return time.strftime(
333+
beets.config["time_format"].as_str(), time.localtime(value or 0)
334+
)
335+
336+
def parse(self, string):
337+
try:
338+
# Try a formatted date string.
339+
return time.mktime(
340+
time.strptime(string, beets.config["time_format"].as_str())
341+
)
342+
except ValueError:
343+
# Fall back to a plain timestamp number.
344+
try:
345+
return float(string)
346+
except ValueError:
347+
return self.null
348+
349+
350+
class BasePathType(Type[bytes, N]):
351+
"""A dbcore type for filesystem paths.
352+
353+
These are represented as `bytes` objects, in keeping with
354+
the Unix filesystem abstraction.
355+
"""
356+
357+
sql = "BLOB"
358+
query = query.PathQuery
359+
model_type = bytes
360+
361+
def parse(self, string: str) -> bytes:
362+
return util.normpath(string)
363+
364+
def normalize(self, value: Any) -> bytes | N:
365+
if isinstance(value, str):
366+
# Paths stored internally as encoded bytes.
367+
return util.bytestring_path(value)
368+
369+
elif isinstance(value, BLOB_TYPE):
370+
# We unwrap buffers to bytes.
371+
return bytes(value)
372+
373+
else:
374+
return value
375+
376+
def from_sql(self, sql_value):
377+
return self.normalize(sql_value)
378+
379+
def to_sql(self, value: bytes) -> BLOB_TYPE:
380+
if isinstance(value, bytes):
381+
value = BLOB_TYPE(value)
382+
return value
383+
384+
385+
class NullPathType(BasePathType[None]):
386+
@property
387+
def null(self) -> None:
388+
return None
389+
390+
def format(self, value: bytes | None) -> str:
391+
return util.displayable_path(value or b"")
392+
393+
394+
class PathType(BasePathType[bytes]):
395+
@property
396+
def null(self) -> bytes:
397+
return b""
398+
399+
def format(self, value: bytes) -> str:
400+
return util.displayable_path(value or b"")
401+
402+
403+
class MusicalKey(String):
404+
"""String representing the musical key of a song.
405+
406+
The standard format is C, Cm, C#, C#m, etc.
407+
"""
408+
409+
ENHARMONIC = {
410+
r"db": "c#",
411+
r"eb": "d#",
412+
r"gb": "f#",
413+
r"ab": "g#",
414+
r"bb": "a#",
415+
}
416+
417+
null = None
418+
419+
def parse(self, key):
420+
key = key.lower()
421+
for flat, sharp in self.ENHARMONIC.items():
422+
key = re.sub(flat, sharp, key)
423+
key = re.sub(r"[\W\s]+minor", "m", key)
424+
key = re.sub(r"[\W\s]+major", "", key)
425+
return key.capitalize()
426+
427+
def normalize(self, key):
428+
if key is None:
429+
return None
430+
else:
431+
return self.parse(key)
432+
433+
434+
class DurationType(Float):
435+
"""Human-friendly (M:SS) representation of a time interval."""
436+
437+
query = query.DurationQuery
438+
439+
def format(self, value):
440+
if not beets.config["format_raw_length"].get(bool):
441+
return human_seconds_short(value or 0.0)
442+
else:
443+
return value
444+
445+
def parse(self, string):
446+
try:
447+
# Try to format back hh:ss to seconds.
448+
return raw_seconds_short(string)
449+
except ValueError:
450+
# Fall back to a plain float.
451+
try:
452+
return float(string)
453+
except ValueError:
454+
return self.null
323455

324456

325457
# Shared instances of common types.
@@ -331,6 +463,7 @@ def parse(self, string: str) -> bool:
331463
NULL_FLOAT = NullFloat()
332464
STRING = String()
333465
BOOLEAN = Boolean()
466+
DATE = DateType()
334467
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
335468

336469
# Will set the proper null char in mediafile

0 commit comments

Comments
 (0)