Skip to content

Commit f0ba201

Browse files
committed
Refactor PathQuery and add docs
1 parent acb39c7 commit f0ba201

File tree

2 files changed

+49
-44
lines changed

2 files changed

+49
-44
lines changed

beets/dbcore/query.py

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,15 @@
2222
from abc import ABC, abstractmethod
2323
from collections.abc import Iterator, MutableSequence, Sequence
2424
from datetime import datetime, timedelta
25-
from functools import reduce
25+
from functools import cached_property, reduce
2626
from operator import mul, or_
2727
from re import Pattern
2828
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union
2929

3030
from beets import util
3131

3232
if TYPE_CHECKING:
33-
from beets.dbcore import Model
34-
from beets.dbcore.db import AnyModel
33+
from beets.dbcore.db import AnyModel, Model
3534

3635
P = TypeVar("P", default=Any)
3736
else:
@@ -283,13 +282,11 @@ class PathQuery(FieldQuery[bytes]):
283282
and case-sensitive otherwise.
284283
"""
285284

286-
def __init__(self, field, pattern, fast=True):
285+
def __init__(self, field: str, pattern: bytes, fast: bool = True) -> None:
287286
"""Create a path query.
288287
289288
`pattern` must be a path, either to a file or a directory.
290289
"""
291-
super().__init__(field, pattern, fast)
292-
293290
path = util.normpath(pattern)
294291

295292
# Case sensitivity depends on the filesystem that the query path is located on.
@@ -304,49 +301,56 @@ def __init__(self, field, pattern, fast=True):
304301
# from `col_clause()` do the same thing.
305302
path = path.lower()
306303

307-
# Match the path as a single file.
308-
self.file_path = path
309-
# As a directory (prefix).
310-
self.dir_path = os.path.join(path, b"")
304+
super().__init__(field, path, fast)
311305

312-
@classmethod
313-
def is_path_query(cls, query_part):
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:
314312
"""Try to guess whether a unicode query part is a path query.
315313
316-
Condition: separator precedes colon and the file exists.
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.
317318
"""
318-
colon = query_part.find(":")
319-
if colon != -1:
320-
query_part = query_part[:colon]
321-
322-
# Test both `sep` and `altsep` (i.e., both slash and backslash on
323-
# Windows).
324-
if not (
325-
os.sep in query_part or (os.altsep and os.altsep in query_part)
326-
):
327-
return False
319+
query_part = query_part.split(":")[0]
328320

329-
return os.path.exists(util.syspath(util.normpath(query_part)))
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+
)
330326

331-
def match(self, item):
332-
path = item.path if self.case_sensitive else item.path.lower()
333-
return (path == self.file_path) or path.startswith(self.dir_path)
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)
334336

335-
def col_clause(self):
336-
file_blob = BLOB_TYPE(self.file_path)
337-
dir_blob = BLOB_TYPE(self.dir_path)
337+
def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
338+
"""Generate an SQL clause that implements path matching in the database.
338339
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+
"""
339344
if self.case_sensitive:
340-
query_part = "({0} = ?) || (substr({0}, 1, ?) = ?)"
345+
left, right = self.field, "?"
341346
else:
342-
query_part = "(BYTELOWER({0}) = BYTELOWER(?)) || \
343-
(substr(BYTELOWER({0}), 1, ?) = BYTELOWER(?))"
347+
left, right = f"BYTELOWER({self.field})", "BYTELOWER(?)"
344348

345-
return query_part.format(self.field), (
346-
file_blob,
347-
len(dir_blob),
349+
return f"({left} = {right}) || (substr({left}, 1, ?) = {right})", [
350+
BLOB_TYPE(self.pattern),
351+
len(dir_blob := BLOB_TYPE(self.dir_path)),
348352
dir_blob,
349-
)
353+
]
350354

351355
def __repr__(self) -> str:
352356
return (

test/test_query.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ class TestPathQuery:
880880

881881
@pytest.fixture(scope="class")
882882
def lib(self, helper):
883-
helper.add_item(path=b"/a/b/c.mp3", title="path item")
883+
helper.add_item(path=b"/aaa/bb/c.mp3", title="path item")
884884
helper.add_item(path=b"/x/y/z.mp3", title="another item")
885885
helper.add_item(path=b"/c/_/title.mp3", title="with underscore")
886886
helper.add_item(path=b"/c/%/title.mp3", title="with percent")
@@ -892,12 +892,13 @@ def lib(self, helper):
892892
@pytest.mark.parametrize(
893893
"q, expected_titles",
894894
[
895-
_p("path:/a/b/c.mp3", ["path item"], id="exact-match"),
896-
_p("path:/a", ["path item"], id="parent-dir-no-slash"),
897-
_p("path:/a/", ["path item"], id="parent-dir-with-slash"),
895+
_p("path:/aaa/bb/c.mp3", ["path item"], id="exact-match"),
896+
_p("path:/aaa", ["path item"], id="parent-dir-no-slash"),
897+
_p("path:/aaa/", ["path item"], id="parent-dir-with-slash"),
898+
_p("path:/aa", [], id="no-match-does-not-match-parent-dir"),
898899
_p("path:/xyzzy/", [], id="no-match"),
899900
_p("path:/b/", [], id="fragment-no-match"),
900-
_p("path:/x/../a/b", ["path item"], id="non-normalized"),
901+
_p("path:/x/../aaa/bb", ["path item"], id="non-normalized"),
901902
_p("path::c\\.mp3$", ["path item"], id="regex"),
902903
_p("path:/c/_", ["with underscore"], id="underscore-escaped"),
903904
_p("path:/c/%", ["with percent"], id="percent-escaped"),
@@ -913,8 +914,8 @@ def test_explicit(self, monkeypatch, lib, q, expected_titles):
913914
@pytest.mark.parametrize(
914915
"q, expected_titles",
915916
[
916-
_p("/a/b", ["path item"], id="slashed-query"),
917-
_p("/a/b , /a/b", ["path item"], id="path-in-or-query"),
917+
_p("/aaa/bb", ["path item"], id="slashed-query"),
918+
_p("/aaa/bb , /aaa", ["path item"], id="path-in-or-query"),
918919
_p("c.mp3", [], id="no-slash-no-match"),
919920
_p("title:/a/b", [], id="slash-with-explicit-field-no-match"),
920921
],

0 commit comments

Comments
 (0)