Files
beets/test/dbcore/test_query.py
Valentyn Kit eb7c832fc9 Make InQuery hashable when its pattern is a list
InQuery inherited FieldQuery.__hash__, which hashes the pattern directly.
A list pattern raises `unhashable type: 'list'`, which broke the
smartplaylist `splupdate` command whenever a `playlist:` query was configured.
2026-07-15 08:02:01 +01:00

627 lines
22 KiB
Python

"""Various tests for querying the library database."""
import logging
import os
import sys
from functools import partial
from pathlib import Path
import pytest
from beets import util
from beets.dbcore import types
from beets.dbcore.query import (
AndQuery,
BooleanQuery,
DateQuery,
FalseQuery,
InQuery,
MatchQuery,
NoneQuery,
NotQuery,
NumericQuery,
OrQuery,
ParsingError,
PathQuery,
RegexpQuery,
StringFieldQuery,
StringQuery,
SubstringQuery,
TrueQuery,
)
from beets.library import Item
from beets.test import _common
# Because the absolute path begins with something like C:, we
# can't disambiguate it from an ordinary query.
WIN32_NO_IMPLICIT_PATHS = "Implicit paths are not supported on Windows"
_p = pytest.param
@pytest.fixture(scope="class")
def helper(class_helper):
"""Use class scope because each query class owns a library shape.
Class fixtures add rows, albums, flex types, paths, or media files and then
query whole collections, so sibling class data would change expectations.
"""
return class_helper
class TestGet:
@pytest.fixture(scope="class")
def lib(self, helper):
album_items = [
helper.create_item(
title="first",
artist="one",
artists=["one", "eleven"],
album="baz",
year=2001,
comp=True,
genres=["rock"],
composers=["composer"],
),
helper.create_item(
title="second",
artist="two",
artists=["two", "twelve"],
album="baz",
year=2002,
comp=True,
genres=["Rock"],
),
]
album = helper.lib.add_album(album_items)
album.albumflex = "foo"
album.store()
helper.add_item(
title="third",
artist="three",
artists=["three", "one"],
album="foo",
year=2003,
comp=False,
genres=["Hard Rock"],
comments="caf\xe9",
)
return helper.lib
@pytest.mark.parametrize(
"q, expected_titles",
[
("", ["first", "second", "third"]),
(None, ["first", "second", "third"]),
(":oNE", []),
(":one", ["first"]),
(":sec :ond", ["second"]),
(":second", ["second"]),
("=rock", ["first"]),
('=~"hard rock"', ["third"]),
(":t$", ["first"]),
("oNE", ["first"]),
("baz", ["first", "second"]),
("sec ond", ["second"]),
("three", ["third"]),
("albumflex:foo", ["first", "second"]),
("artist::t.+r", ["third"]),
("artist:thrEE", ["third"]),
("artists::eleven", ["first"]),
("artists::one", ["first", "third"]),
("ArTiST:three", ["third"]),
("comments:caf\xe9", ["third"]),
("comp:true", ["first", "second"]),
("comp:false", ["third"]),
("genres:=rock", ["first"]),
("genres:=Rock", ["second"]),
('genres:="Hard Rock"', ["third"]),
('genres:=~"hard rock"', ["third"]),
("genres:=~rock", ["first", "second"]),
('genres:="hard rock"', []),
("popebear", []),
("pope:bear", []),
("singleton:true", ["third"]),
("singleton:1", ["third"]),
("singleton:false", ["first", "second"]),
("singleton:0", ["first", "second"]),
("title:ond", ["second"]),
("title::sec", ["second"]),
("year:2001", ["first"]),
("year:2000..2002", ["first", "second"]),
("xyzzy:nonsense", []),
],
)
def test_get_query(self, lib, q, expected_titles):
assert {i.title for i in lib.items(q)} == set(expected_titles)
@pytest.mark.parametrize(
"q, expected_titles",
[
(BooleanQuery("comp", True), ("third",)),
(DateQuery("added", "2000-01-01"), ("first", "second", "third")),
(FalseQuery(), ("first", "second", "third")),
(MatchQuery("year", "2003"), ("first", "second")),
(NoneQuery("rg_track_gain"), ()),
(NumericQuery("year", "2001..2002"), ("third",)),
(
AndQuery(
[BooleanQuery("comp", True), NumericQuery("year", "2002")]
),
("first", "third"),
),
(
OrQuery(
[BooleanQuery("comp", True), NumericQuery("year", "2002")]
),
("third",),
),
(RegexpQuery("artist", "^t"), ("first",)),
(SubstringQuery("album", "ba"), ("third",)),
(TrueQuery(), ()),
],
)
def test_query_logic(self, lib, q, expected_titles):
def get_results(*args):
return {i.title for i in lib.items(*args)}
# not(a and b) <-> not(a) or not(b)
not_q = NotQuery(q)
not_q_results = get_results(not_q)
assert not_q_results == set(expected_titles)
# assert using OrQuery, AndQuery
q_or = OrQuery([q, not_q])
q_and = AndQuery([q, not_q])
assert get_results(q_or) == {"first", "second", "third"}
assert get_results(q_and) == set()
# assert manually checking the item titles
all_titles = get_results()
q_results = get_results(q)
assert q_results.union(not_q_results) == all_titles
assert q_results.intersection(not_q_results) == set()
# round trip
not_not_q = NotQuery(not_q)
assert get_results(q) == get_results(not_not_q)
@pytest.mark.parametrize(
"q, expected_titles",
[
("-artist::t.+r", ["first", "second"]),
("-:t$", ["second", "third"]),
("sec -bar", ["second"]),
("sec -title:bar", ["second"]),
("-ond", ["first", "third"]),
("^ond", ["first", "third"]),
("^title:sec", ["first", "third"]),
("-title:sec", ["first", "third"]),
],
)
def test_negation_prefix(self, lib, q, expected_titles):
actual_titles = {i.title for i in lib.items(q)}
assert actual_titles == set(expected_titles)
@pytest.mark.parametrize(
"make_q",
[
partial(DateQuery, "added", "2001-01-01"),
partial(MatchQuery, "artist", "one"),
partial(NoneQuery, "rg_track_gain"),
partial(NumericQuery, "year", "2002"),
partial(StringQuery, "year", "2001"),
partial(RegexpQuery, "album", "^.a"),
partial(SubstringQuery, "title", "x"),
],
)
def test_fast_vs_slow(self, lib, make_q):
"""Test that the results are the same regardless of the `fast` flag
for negated `FieldQuery`s.
"""
q_fast = make_q(True)
q_slow = make_q(False)
assert list(map(dict, lib.items(q_fast))) == list(
map(dict, lib.items(q_slow))
)
@pytest.mark.parametrize(
"q, legacy_field",
[
pytest.param("genres::rock", None, id="non-legacy-genres-field"),
pytest.param("genre::rock", "genre", id="legacy-genre-field"),
pytest.param(
"composers::composer", None, id="non-legacy-composer-field"
),
pytest.param(
"composer::composer", "composer", id="legacy-composer-field"
),
],
)
def test_legacy_field(self, caplog, lib, q, legacy_field):
with caplog.at_level(logging.WARNING, logger="beets"):
actual_titles = {i.title for i in lib.items(q)}
assert actual_titles == {"first"}
if legacy_field:
assert caplog.records, "No log records were captured"
assert len(caplog.records) == 1
message = str(caplog.records[0].msg)
assert f"The '{legacy_field}' field is deprecated" in message
assert f"Use '{legacy_field}s' instead." in message
class TestMatch:
@pytest.fixture(scope="class")
def item(self):
return _common.item(
album="the album",
disc=6,
year=1,
bitrate=128000,
genres=["Classical", "Baroque"],
)
@pytest.mark.parametrize(
"q, should_match",
[
(RegexpQuery("album", "^the album$"), True),
(RegexpQuery("album", "^album$"), False),
(RegexpQuery("disc", "^6$"), True),
(SubstringQuery("album", "album"), True),
(SubstringQuery("album", "ablum"), False),
(SubstringQuery("disc", "6"), True),
(StringQuery("album", "the album"), True),
(StringQuery("album", "THE ALBUM"), True),
(StringQuery("album", "album"), False),
(MatchQuery("genres", "Classical"), True),
(MatchQuery("genres", "Neoclassical"), False),
(StringQuery("genres", "classical"), True),
(StringQuery("genres", "neoclassical"), False),
(NumericQuery("year", "1"), True),
(NumericQuery("year", "10"), False),
(NumericQuery("bitrate", "100000..200000"), True),
(NumericQuery("bitrate", "200000..300000"), False),
(NumericQuery("bitrate", "100000.."), True),
],
)
def test_match(self, item, q, should_match):
assert q.match(item) == should_match
assert not NotQuery(q).match(item) == should_match
class TestPathQuery:
"""Tests for path-based querying functionality in the database system.
Verifies that path queries correctly match items by their file paths,
handling special characters, case sensitivity, parent directories,
and path separator detection across different platforms.
"""
@staticmethod
def abs_query_path(path: str, trailing_sep: bool = False) -> str:
"""Build a platform-correct absolute query path without normalizing it.
On Windows, leading-slash paths are drive-rooted but Python 3.13 no
longer treats them as absolute. Prefix the current drive so explicit
path queries stay absolute while preserving raw segments such as ``..``.
"""
if os.path.__name__ == "ntpath" and path.startswith("/"):
drive, _ = os.path.splitdrive(os.fsdecode(util.normpath(os.sep)))
path = drive + path
path = path.replace("/", os.sep)
if trailing_sep:
path = os.path.join(path, "")
return path.replace("\\", "\\\\")
@pytest.fixture(scope="class")
def lib(self, helper):
helper.add_item(path=b"/aaa/bb/c.mp3", title="path item")
helper.add_item(path=b"/x/y/z.mp3", title="another item")
helper.add_item(path=b"/c/_/title.mp3", title="with underscore")
helper.add_item(path=b"/c/%/title.mp3", title="with percent")
helper.add_item(path=rb"/c/\x/title.mp3", title="with backslash")
helper.add_item(path=b"/A/B/C2.mp3", title="caps path")
return helper.lib
@pytest.mark.parametrize(
"path, expected_titles, trailing_sep",
[
_p("/aaa/bb/c.mp3", ["path item"], False, id="exact-match"),
_p("/aaa", ["path item"], False, id="parent-dir-no-slash"),
_p("/aaa", ["path item"], True, id="parent-dir-with-slash"),
_p("/aa", [], False, id="no-match-does-not-match-parent-dir"),
_p("/xyzzy", [], True, id="no-match"),
_p("/b", [], True, id="fragment-no-match"),
_p("/x/../aaa/bb", ["path item"], False, id="non-normalized"),
_p(r"c\.mp3$", ["path item"], False, id="regex"),
_p("/c/_", ["with underscore"], False, id="underscore-escaped"),
_p("/c/%", ["with percent"], False, id="percent-escaped"),
_p(r"/c/\x", ["with backslash"], False, id="backslash-escaped"),
],
)
def test_explicit(
self, monkeypatch, lib, path, expected_titles, trailing_sep
):
"""Test explicit path queries with different path specifications."""
monkeypatch.setattr("beets.util.case_sensitive", lambda *_: True)
if path == r"c\.mp3$":
q = f"path::{path}"
elif path == r"/c/\x" and os.path.__name__ != "ntpath":
q = r"path:/c/\\x"
else:
q = f"path:{self.abs_query_path(path, trailing_sep=trailing_sep)}"
assert {i.title for i in lib.items(q)} == set(expected_titles)
@pytest.mark.parametrize(
"query", ["path:", "path::"], ids=["path", "regex"]
)
def test_absolute(self, lib, helper, query):
item_path = helper.lib_path / "item.mp3"
bytes_path = os.fsencode(item_path)
helper.add_item(path=bytes_path, title="absolute item")
q = f"{query}{item_path}".replace("\\", "\\\\")
assert {i.title for i in lib.items(q)} == {"absolute item"}
def test_relative(self, lib, helper):
item_path = helper.lib_path / "relative" / "item.mp3"
bytes_path = os.fsencode(item_path)
helper.add_item(path=bytes_path, title="relative item")
q = "path:relative/item.mp3"
assert {i.title for i in lib.items(q)} == {"relative item"}
@pytest.mark.skipif(sys.platform == "win32", reason=WIN32_NO_IMPLICIT_PATHS)
@pytest.mark.parametrize(
"q, expected_titles",
[
_p("/aaa/bb", ["path item"], id="slashed-query"),
_p("/aaa/bb , /aaa", ["path item"], id="path-in-or-query"),
_p("c.mp3", [], id="no-slash-no-match"),
_p("title:/a/b", [], id="slash-with-explicit-field-no-match"),
],
)
def test_implicit(self, monkeypatch, lib, q, expected_titles):
"""Test implicit path detection when queries contain path separators."""
monkeypatch.setattr(
"beets.dbcore.query.PathQuery.is_path_query", lambda path: True
)
assert {i.title for i in lib.items(q)} == set(expected_titles)
@pytest.mark.parametrize(
"case_sensitive, expected_titles",
[
_p(True, [], id="non-caps-dont-match-caps"),
_p(False, ["caps path"], id="non-caps-match-caps"),
],
)
def test_case_sensitivity(
self, lib, monkeypatch, case_sensitive, expected_titles
):
"""Test path matching with different case sensitivity settings."""
q = f"path:{self.abs_query_path('/a/b/c2.mp3')}"
monkeypatch.setattr(
"beets.util.case_sensitive", lambda *_: case_sensitive
)
assert {i.title for i in lib.items(q)} == set(expected_titles)
# FIXME: Also create a variant of this test for windows, which tests
# both os.sep and os.altsep
@pytest.mark.skipif(sys.platform == "win32", reason=WIN32_NO_IMPLICIT_PATHS)
@pytest.mark.parametrize(
"q, is_path_query",
[
("/foo/bar", True),
("foo/bar", True),
("foo/", True),
("foo", False),
("foo/:bar", True),
("foo:bar/", False),
("foo:/bar", False),
],
)
def test_path_sep_detection(self, monkeypatch, tmp_path, q, is_path_query):
"""Test detection of path queries based on the presence of path separators."""
monkeypatch.chdir(tmp_path)
(tmp_path / "foo").mkdir()
(tmp_path / "foo" / "bar").touch()
if Path(q).is_absolute():
q = str(tmp_path / q[1:])
assert PathQuery.is_path_query(q) == is_path_query
class TestQuery:
ALBUM = "album title"
SINGLE = "singleton"
@pytest.fixture(scope="class")
def lib(self, helper):
helper.add_album(
title=self.ALBUM,
comp=True,
flexbool=True,
bpm=120,
flexint=2,
rg_track_gain=0,
)
helper.add_item(
title=self.SINGLE, comp=False, flexbool=False, rg_track_gain=None
)
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
Item,
"_types",
{"flexbool": types.Boolean(), "flexint": types.Integer()},
)
yield helper.lib
@pytest.mark.parametrize("query_class", [MatchQuery, StringFieldQuery])
def test_equality(self, query_class):
assert query_class("foo", "bar") == query_class("foo", "bar")
def test_in_query_hashable(self):
# smartplaylist puts queries in a set; an InQuery whose pattern is a
# list used to raise `unhashable type: 'list'`. See #5354.
query = InQuery("field", [1, 2, 3])
assert query in {query}
@pytest.mark.parametrize(
"make_q, expected_msg",
[
(lambda: NumericQuery("year", "199a"), "not an int"),
(lambda: RegexpQuery("year", "199("), r"not a regular expression.*unterminated subpattern"), # noqa: E501
]
) # fmt: skip
def test_invalid_query(self, make_q, expected_msg):
with pytest.raises(ParsingError, match=expected_msg):
make_q()
@pytest.mark.parametrize(
"q, expected_titles",
[
# Boolean value
_p("comp:true", {ALBUM}, id="parse-true"),
_p("flexbool:true", {ALBUM}, id="flex-parse-true"),
_p("flexbool:false", {SINGLE}, id="flex-parse-false"),
_p("flexbool:1", {ALBUM}, id="flex-parse-1"),
_p("flexbool:0", {SINGLE}, id="flex-parse-0"),
# TODO: shouldn't this match 1 / true instead?
_p("flexbool:something", {SINGLE}, id="flex-parse-true"),
# Integer value
_p("bpm:120", {ALBUM}, id="int-exact-value"),
_p("bpm:110..125", {ALBUM}, id="int-range"),
_p("flexint:2", {ALBUM}, id="int-flex"),
_p("flexint:3", set(), id="int-no-match"),
_p("bpm:12", set(), id="int-dont-match-substring"),
# None value
_p(NoneQuery("album_id"), {SINGLE}, id="none-match-singleton"),
_p(NoneQuery("rg_track_gain"), {SINGLE}, id="none-value"),
],
)
def test_value_type(self, lib, q, expected_titles):
assert {i.title for i in lib.items(q)} == expected_titles
class TestDefaultSearchFields:
@pytest.fixture(scope="class")
def lib(self, helper):
helper.add_album(
title="title",
album="album",
albumartist="albumartist",
catalognum="catalognum",
year=2001,
)
return helper.lib
@pytest.mark.parametrize(
"entity, q, should_match",
[
_p("albums", "album", True, id="album-match-album"),
_p("albums", "albumartist", True, id="album-match-albumartist"),
_p("albums", "catalognum", False, id="album-dont-match-catalognum"),
_p("items", "title", True, id="item-match-title"),
_p("items", "2001", False, id="item-dont-match-year"),
],
)
def test_search(self, lib, entity, q, should_match):
assert bool(getattr(lib, entity)(q)) == should_match
class TestRelatedQueries:
"""Test album-level queries with track-level filters and vice-versa."""
@pytest.fixture(scope="class")
def lib(self, helper):
for album_idx in range(1, 3):
album_name = f"Album{album_idx}"
items = [
helper.create_item(
album=album_name, title=f"{album_name} Item{idx}"
)
for idx in range(1, 3)
]
album = helper.lib.add_album(items)
album.artpath = f"{album_name} Artpath"
album.catalognum = "ABC"
album.store()
return helper.lib
@pytest.mark.parametrize(
"q, expected_titles, expected_albums",
[
_p(
"title:Album1",
["Album1 Item1", "Album1 Item2"],
["Album1"],
id="match-album-with-item-field-query",
),
_p(
"title:Item2",
["Album1 Item2", "Album2 Item2"],
["Album1", "Album2"],
id="match-albums-with-item-field-query",
),
_p(
"artpath::Album1",
["Album1 Item1", "Album1 Item2"],
["Album1"],
id="match-items-with-album-field-query",
),
_p(
"catalognum:ABC Album1",
["Album1 Item1", "Album1 Item2"],
["Album1"],
id="query-field-common-to-album-and-item",
),
],
)
def test_related_query(self, lib, q, expected_titles, expected_albums):
assert {i.album for i in lib.albums(q)} == set(expected_albums)
assert {i.title for i in lib.items(q)} == set(expected_titles)
class TestHasCoverArtQuery:
"""Test has_cover_art computed field for detecting embedded cover art."""
@pytest.fixture(scope="class")
def lib(self, helper):
item_with = helper.add_item_fixture()
item_with.title = "with_art"
path_with = helper.create_mediafile_fixture(images=["jpg"])
item_with["path"] = path_with
item_with.store()
path_without = helper.create_mediafile_fixture(images=[])
item_without = helper.add_item_fixture()
item_without.title = "without_art"
item_without["path"] = path_without
item_without.store()
return helper.lib
@pytest.mark.parametrize(
"query, expected_titles",
[
("has_cover_art:true", {"with_art"}),
("has_cover_art:false", {"without_art"}),
],
)
def test_has_cover_art_query(self, lib, query, expected_titles):
assert {i.title for i in lib.items(query)} == expected_titles