Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite the fish plugin from scratch #5359

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
9 changes: 5 additions & 4 deletions beets/test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

"""This module contains components of beets' test environment, which
may be of use for testing procedures of external libraries or programs.
For example the 'TestHelper' class may be useful for creating an
in-memory beets library filled with a few example items.
"""
This module contains components of beets' test environment, which may be of use
for testing procedures of external libraries or programs. For example the
'TestHelper' class may be useful for creating an in-memory beets library filled
with a few example items.
"""
126 changes: 126 additions & 0 deletions beets/test/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# This file is part of beets.
snejus marked this conversation as resolved.
Show resolved Hide resolved
# Copyright 2024, Arav K.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

"""
This module provides `pytest`-based fixtures for testing Beets.
"""

from __future__ import annotations

from contextlib import contextmanager
from pathlib import Path
from typing import Iterator

import pytest
from confuse import Configuration

import beets
import beets.util as util
from beets.library import Library


@contextmanager
def setup_config(lib_dir: Path) -> Iterator[Configuration]:
# Initialize configuration.
config = beets.config
config.sources = []
config.read(user=False, defaults=True)

# Set configuration defaults.
config["plugins"] = []
config["verbose"] = 1
config["ui"]["color"] = False
config["threaded"] = False
config["directory"] = str(lib_dir)

# Provide the configuration to the test.
yield config

# Reset the global configuration state.
beets.config.clear()
beets.config._materialized = False


@contextmanager
def setup_library(
lib_dir: Path,
db_loc: Path | None = None,
) -> Iterator[Library]:
# Create the Beets library object.
db_loc = util.bytestring_path(db_loc) if db_loc is not None else ":memory:"

Check failure on line 61 in beets/test/fixtures.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Incompatible types in assignment (expression has type "Union[bytes, str]", variable has type "Optional[Path]")

Check failure on line 61 in beets/test/fixtures.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Incompatible types in assignment (expression has type "Union[bytes, str]", variable has type "Optional[Path]")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mypy is failing here because db_loc type becomes bytes | str while Path | None is expected. You can deal with this by moving this logic directly to the Library call:

    lib = Library(util.bytestring_path(db_loc) if db_loc is not None else ":memory:", str(lib_dir))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that bytestring_path cast may not be required since the default value for path parameter seems to be a string:

class Library(dbcore.Database):
    """A database of music containing songs and albums."""

    _models = (Item, Album)

    def __init__(
        self,
        path="library.blb",
        directory: str | None = None,
        path_formats=((PF_KEY_DEFAULT, "$artist/$album/$track $title"),),
        replacements=None,
    ):

lib = Library(db_loc, str(lib_dir))

# Provide the library to the test.
yield lib

# Clean up the library.
lib._close()


@pytest.fixture(scope="session")
def resource_dir(pytestconfig: pytest.Config) -> Path:
"""
The resource directory for tests.

Tests requiring external data (e.g. audio files) can place them within the
resource directory (located at `test/rsrc` in the repository) and then find
them relative to the returned path.

If the tests for a particular component or plugin require several files,
they should be placed within an appropriately named subdirectory.

:return: the path to `test/rsrc` in the repository.
"""

return pytestconfig.rootpath / "test" / "rsrc"


@pytest.fixture
def config(
snejus marked this conversation as resolved.
Show resolved Hide resolved
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Configuration]:
"""
snejus marked this conversation as resolved.
Show resolved Hide resolved
Prepare a pristine configuration for Beets.

This will construct and return a fresh configuration object for Beets,
containing the default settings for the Beets library and first-party
plugins.

Currently, Beets internally stores configuration in the `beets.config`
global variable. This fixture will reset it to the same configuration
object that is returned. Modifications in the object returned by this
fixture will be reflected in `beets.config`. However, it is recommended
to avoid the global variable and work directly with the returned object
whenever possible.
"""

# 'confuse' looks at `HOME`, so we set it to a tmpdir.
monkeypatch.setenv("HOME", str(tmp_path))
with setup_config(tmp_path / "libdir") as config:
yield config


@pytest.fixture
@pytest.mark.usefixtures("config")
def library(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Library]:
# Beets needs a location to store library contents.
lib_dir = tmp_path / "libdir"
monkeypatch.setenv("BEETSDIR", str(lib_dir))

with setup_library(lib_dir, db_loc=None) as library:
yield library
67 changes: 39 additions & 28 deletions beets/test/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@
import shutil
import subprocess
import sys
import typing
import unittest
from contextlib import contextmanager
from contextlib import AbstractContextManager, contextmanager
from enum import Enum
from functools import cached_property
from io import StringIO
Expand All @@ -64,6 +65,8 @@
syspath,
)

from .fixtures import setup_config, setup_library


class LogCapture(logging.Handler):
def __init__(self):
Expand Down Expand Up @@ -169,9 +172,19 @@
"""

db_on_disk: ClassVar[bool] = False
contexts: list[AbstractContextManager[Any]]

# TODO automate teardown through hook registration

ContextValue = typing.TypeVar("ContextValue")

def setup_context(
self, manager: AbstractContextManager[ContextValue]
) -> ContextValue:
value = manager.__enter__()
self.contexts.append(manager)
return value

def setup_beets(self):
"""Setup pristine global configuration and library for testing.

Expand All @@ -194,46 +207,44 @@

Make sure you call ``teardown_beets()`` afterwards.
"""
self.create_temp_dir()
temp_dir_str = os.fsdecode(self.temp_dir)
self.env_patcher = patch.dict(
"os.environ",
{
"BEETSDIR": temp_dir_str,
"HOME": temp_dir_str, # used by Confuse to create directories.
},
)
self.env_patcher.start()

self.config = beets.config
self.config.sources = []
self.config.read(user=False, defaults=True)
self.contexts = []

self.config["plugins"] = []
self.config["verbose"] = 1
self.config["ui"]["color"] = False
self.config["threaded"] = False
# Set up the basic configuration with a HOME directory.
self.create_temp_dir()
temp_dir = Path(os.fsdecode(self.temp_dir))
self.libdir = temp_dir / "libdir"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.libdir starts as a Path and later gets cast to bytes. As a Path, it is only used within this method, so you may want to assign a local variable for it, and set self.libdir with bytes later:

    libdir = temp_dir / "libdir"
    ...
    self.libdir = util.bytestring_path(libdir)

Note the usage of bytestring_path: remember the weird magic/bytes/Windows logic? 😅

self.libdir.mkdir(exist_ok=True)

self.setup_context(
patch.dict(
"os.environ",
{
"BEETSDIR": str(temp_dir),
"HOME": str(temp_dir),
},
)
)

self.libdir = os.path.join(self.temp_dir, b"libdir")
os.mkdir(syspath(self.libdir))
self.config["directory"] = os.fsdecode(self.libdir)
self.config = self.setup_context(setup_config(self.libdir))

if self.db_on_disk:
dbpath = util.bytestring_path(self.config["library"].as_filename())
db_loc = self.config["library"].as_filename()
else:
dbpath = ":memory:"
self.lib = Library(dbpath, self.libdir)
db_loc = None

self.lib = self.setup_context(setup_library(self.libdir, db_loc))

self.libdir = bytes(self.libdir)

# Initialize, but don't install, a DummyIO.
self.io = _common.DummyIO()

def teardown_beets(self):
self.env_patcher.stop()
self.io.restore()
self.lib._close()
while len(self.contexts) != 0:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestCase has a method addCleanup which accepts the finalizer function. It can be called in the very beginning, so it should slightly simplify things here. See

    def setup_with_context_manager(self, cm):
        val = cm.__enter__()
        self.addCleanup(cm.__exit__, None, None, None)
        return val

Interestingly, using addCleanup is recommended over teardown methods, since this makes sure that the cleanup is always performed. On the other hand teardown may not get called if something fails in the setUp method, I think

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should remove the need for the contexts list.

self.contexts.pop().__exit__(None, None, None)
self.remove_temp_dir()
beets.config.clear()
beets.config._materialized = False

# Library fixtures methods

Expand Down Expand Up @@ -479,23 +490,23 @@
"""Load and initialize plugins by names.

Similar setting a list of plugins in the configuration. Make
sure you call ``unload_plugins()`` afterwards.

Check failure on line 493 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Item]" has no attribute "_original_types"
"""

Check failure on line 494 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Album]" has no attribute "_original_types"
# FIXME this should eventually be handled by a plugin manager
plugins = (self.plugin,) if hasattr(self, "plugin") else plugins
beets.config["plugins"] = plugins
beets.plugins.load_plugins(plugins)

Check failure on line 498 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Item]" has no attribute "_original_queries"
beets.plugins.find_plugins()

Check failure on line 499 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Album]" has no attribute "_original_queries"

# Take a backup of the original _types and _queries to restore
# when unloading.
Item._original_types = dict(Item._types)

Check failure on line 503 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Item]" has no attribute "_original_types"
Album._original_types = dict(Album._types)

Check failure on line 504 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Album]" has no attribute "_original_types"
Item._types.update(beets.plugins.types(Item))
Album._types.update(beets.plugins.types(Album))

Item._original_queries = dict(Item._queries)

Check failure on line 508 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Item]" has no attribute "_original_queries"
Album._original_queries = dict(Album._queries)

Check failure on line 509 in beets/test/helper.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

"Type[Album]" has no attribute "_original_queries"
Item._queries.update(beets.plugins.named_queries(Item))
Album._queries.update(beets.plugins.named_queries(Album))

Expand Down
Loading
Loading