Skip to content

Commit 952a879

Browse files
committed
Move 'library-specific' types and queries to respective modules
1 parent 0379f68 commit 952a879

17 files changed

+366
-368
lines changed

beets/dbcore/query.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import os
1920
import re
2021
import unicodedata
2122
from abc import ABC, abstractmethod
@@ -36,6 +37,11 @@
3637
else:
3738
P = TypeVar("P")
3839

40+
# To use the SQLite "blob" type, it doesn't suffice to provide a byte
41+
# string; SQLite treats that as encoded text. Wrapping it in a
42+
# `memoryview` tells it that we actually mean non-text data.
43+
BLOB_TYPE = memoryview
44+
3945

4046
class ParsingError(ValueError):
4147
"""Abstract class for any unparsable user-requested album/query
@@ -267,6 +273,97 @@ def string_match(cls, pattern: str, value: str) -> bool:
267273
return pattern.lower() in value.lower()
268274

269275

276+
class PathQuery(FieldQuery[bytes]):
277+
"""A query that matches all items under a given path.
278+
279+
Matching can either be case-insensitive or case-sensitive. By
280+
default, the behavior depends on the OS: case-insensitive on Windows
281+
and case-sensitive otherwise.
282+
"""
283+
284+
# For tests
285+
force_implicit_query_detection = False
286+
287+
def __init__(self, field, pattern, fast=True, case_sensitive=None):
288+
"""Create a path query.
289+
290+
`pattern` must be a path, either to a file or a directory.
291+
292+
`case_sensitive` can be a bool or `None`, indicating that the
293+
behavior should depend on the filesystem.
294+
"""
295+
super().__init__(field, pattern, fast)
296+
297+
path = util.normpath(pattern)
298+
299+
# By default, the case sensitivity depends on the filesystem
300+
# that the query path is located on.
301+
if case_sensitive is None:
302+
case_sensitive = util.case_sensitive(path)
303+
self.case_sensitive = case_sensitive
304+
305+
# Use a normalized-case pattern for case-insensitive matches.
306+
if not case_sensitive:
307+
# We need to lowercase the entire path, not just the pattern.
308+
# In particular, on Windows, the drive letter is otherwise not
309+
# lowercased.
310+
# This also ensures that the `match()` method below and the SQL
311+
# from `col_clause()` do the same thing.
312+
path = path.lower()
313+
314+
# Match the path as a single file.
315+
self.file_path = path
316+
# As a directory (prefix).
317+
self.dir_path = os.path.join(path, b"")
318+
319+
@classmethod
320+
def is_path_query(cls, query_part):
321+
"""Try to guess whether a unicode query part is a path query.
322+
323+
Condition: separator precedes colon and the file exists.
324+
"""
325+
colon = query_part.find(":")
326+
if colon != -1:
327+
query_part = query_part[:colon]
328+
329+
# Test both `sep` and `altsep` (i.e., both slash and backslash on
330+
# Windows).
331+
if not (
332+
os.sep in query_part or (os.altsep and os.altsep in query_part)
333+
):
334+
return False
335+
336+
if cls.force_implicit_query_detection:
337+
return True
338+
return os.path.exists(util.syspath(util.normpath(query_part)))
339+
340+
def match(self, item):
341+
path = item.path if self.case_sensitive else item.path.lower()
342+
return (path == self.file_path) or path.startswith(self.dir_path)
343+
344+
def col_clause(self):
345+
file_blob = BLOB_TYPE(self.file_path)
346+
dir_blob = BLOB_TYPE(self.dir_path)
347+
348+
if self.case_sensitive:
349+
query_part = "({0} = ?) || (substr({0}, 1, ?) = ?)"
350+
else:
351+
query_part = "(BYTELOWER({0}) = BYTELOWER(?)) || \
352+
(substr(BYTELOWER({0}), 1, ?) = BYTELOWER(?))"
353+
354+
return query_part.format(self.field), (
355+
file_blob,
356+
len(dir_blob),
357+
dir_blob,
358+
)
359+
360+
def __repr__(self) -> str:
361+
return (
362+
f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
363+
f"fast={self.fast}, case_sensitive={self.case_sensitive})"
364+
)
365+
366+
270367
class RegexpQuery(StringFieldQuery[Pattern[str]]):
271368
"""A query that matches a regular expression in a specific Model field.
272369
@@ -844,6 +941,24 @@ def _convert(self, s: str) -> float | None:
844941
)
845942

846943

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

849964

beets/dbcore/types.py

Lines changed: 145 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,21 @@
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
2427

25-
from .query import (
26-
BooleanQuery,
27-
FieldQueryType,
28-
NumericQuery,
29-
SQLiteType,
30-
SubstringQuery,
31-
)
28+
from . import query
29+
30+
SQLiteType = query.SQLiteType
31+
# needs to be defined in query due to circular import.
32+
# TODO: remove the need for it in query.py and define it here instead.
33+
BLOB_TYPE = query.BLOB_TYPE
3234

3335

3436
class ModelType(typing.Protocol):
@@ -61,7 +63,7 @@ class Type(ABC, Generic[T, N]):
6163
"""The SQLite column type for the value.
6264
"""
6365

64-
query: FieldQueryType = SubstringQuery
66+
query: query.FieldQueryType = query.SubstringQuery
6567
"""The `Query` subclass to be used when querying the field.
6668
"""
6769

@@ -160,7 +162,7 @@ class BaseInteger(Type[int, N]):
160162
"""A basic integer type."""
161163

162164
sql = "INTEGER"
163-
query = NumericQuery
165+
query = query.NumericQuery
164166
model_type = int
165167

166168
def normalize(self, value: Any) -> int | N:
@@ -241,7 +243,7 @@ class BaseFloat(Type[float, N]):
241243
"""
242244

243245
sql = "REAL"
244-
query: FieldQueryType = NumericQuery
246+
query: query.FieldQueryType = query.NumericQuery
245247
model_type = float
246248

247249
def __init__(self, digits: int = 1):
@@ -271,7 +273,7 @@ class BaseString(Type[T, N]):
271273
"""A Unicode string type."""
272274

273275
sql = "TEXT"
274-
query = SubstringQuery
276+
query = query.SubstringQuery
275277

276278
def normalize(self, value: Any) -> T | N:
277279
if value is None:
@@ -312,14 +314,142 @@ class Boolean(Type):
312314
"""A boolean type."""
313315

314316
sql = "INTEGER"
315-
query = BooleanQuery
317+
query = query.BooleanQuery
316318
model_type = bool
317319

318320
def format(self, value: bool) -> str:
319321
return str(bool(value))
320322

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

324454

325455
# Shared instances of common types.
@@ -331,6 +461,7 @@ def parse(self, string: str) -> bool:
331461
NULL_FLOAT = NullFloat()
332462
STRING = String()
333463
BOOLEAN = Boolean()
464+
DATE = DateType()
334465
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
335466

336467
# Will set the proper null char in mediafile

0 commit comments

Comments
 (0)