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

Make sure tests do not leave any temp files behind #5345

Merged
merged 11 commits into from
Jul 18, 2024
Merged
63 changes: 47 additions & 16 deletions beets/test/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
- The `TestHelper` class encapsulates various fixtures that can be set up.
"""

from __future__ import annotations

import os
import os.path
Expand All @@ -39,7 +40,9 @@
from enum import Enum
from io import StringIO
from tempfile import mkdtemp, mkstemp
from typing import ClassVar

import responses
from mediafile import Image, MediaFile

import beets
Expand All @@ -49,7 +52,12 @@
from beets.library import Album, Item, Library
from beets.test import _common
from beets.ui.commands import TerminalImportSession
from beets.util import MoveOperation, bytestring_path, syspath
from beets.util import (
MoveOperation,
bytestring_path,
clean_module_tempdir,
syspath,
)


class LogCapture(logging.Handler):
Expand Down Expand Up @@ -397,18 +405,14 @@ def add_album_fixture(
return self.lib.add_album(items)

def create_mediafile_fixture(self, ext="mp3", images=[]):
"""Copies a fixture mediafile with the extension to a temporary
location and returns the path.

It keeps track of the created locations and will delete the with
`remove_mediafile_fixtures()`
"""Copy a fixture mediafile with the extension to `temp_dir`.

`images` is a subset of 'png', 'jpg', and 'tiff'. For each
specified extension a cover art image is added to the media
file.
"""
src = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
handle, path = mkstemp()
handle, path = mkstemp(dir=self.temp_dir)
path = bytestring_path(path)
os.close(handle)
shutil.copyfile(syspath(src), syspath(path))
Expand All @@ -424,17 +428,8 @@ def create_mediafile_fixture(self, ext="mp3", images=[]):
mediafile.images = imgs
mediafile.save()

if not hasattr(self, "_mediafile_fixtures"):
self._mediafile_fixtures = []
self._mediafile_fixtures.append(path)

return path

def remove_mediafile_fixtures(self):
if hasattr(self, "_mediafile_fixtures"):
for path in self._mediafile_fixtures:
os.remove(syspath(path))

def _get_item_count(self):
if not hasattr(self, "__item_count"):
count = 0
Expand Down Expand Up @@ -925,3 +920,39 @@ def _make_album_match(self, artist, album, tracks, distance=0, missing=0):
albumtype="soundtrack",
data_source="match_source",
)


class FetchImageHelper:
"""Helper mixin for mocking requests when fetching images
with remote art sources.
"""

@responses.activate
def run(self, *args, **kwargs):
super().run(*args, **kwargs)

IMAGEHEADER = {
"image/jpeg": b"\x00" * 6 + b"JFIF",
"image/png": b"\211PNG\r\n\032\n",
}

def mock_response(self, url, content_type="image/jpeg", file_type=None):
if file_type is None:
file_type = content_type
responses.add(
responses.GET,
url,
content_type=content_type,
# imghdr reads 32 bytes
body=self.IMAGEHEADER.get(file_type, b"").ljust(32, b"\x00"),
)


class CleanupModulesMixin:
modules: ClassVar[tuple[str, ...]]

@classmethod
def tearDownClass(cls) -> None:
"""Remove files created by the plugin."""
for module in cls.modules:
clean_module_tempdir(module)
47 changes: 47 additions & 0 deletions beets/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# included in all copies or substantial portions of the Software.

"""Miscellaneous utility functions."""
from __future__ import annotations

import errno
import fnmatch
Expand All @@ -26,9 +27,11 @@
import tempfile
import traceback
from collections import Counter, namedtuple
from contextlib import suppress
from enum import Enum
from logging import Logger
from multiprocessing.pool import ThreadPool
from pathlib import Path
from typing import (
Any,
AnyStr,
Expand Down Expand Up @@ -58,6 +61,7 @@
WINDOWS_MAGIC_PREFIX = "\\\\?\\"
T = TypeVar("T")
Bytes_or_String: TypeAlias = Union[str, bytes]
PathLike = Union[str, bytes, Path]


class HumanReadableException(Exception):
Expand Down Expand Up @@ -161,7 +165,7 @@
"""Provide the canonical form of the path suitable for storing in
the database.
"""
path = syspath(path, prefix=False)

Check failure on line 168 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Incompatible types in assignment (expression has type "Union[str, bytes]", variable has type "bytes")
path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
return bytestring_path(path)

Expand All @@ -175,7 +179,7 @@

The argument should *not* be the result of a call to `syspath`.
"""
out = []

Check failure on line 182 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Need type annotation for "out" (hint: "out: List[<type>] = ...")
last_path = None
while path:
path = os.path.dirname(path)
Expand All @@ -192,17 +196,17 @@

def sorted_walk(
path: AnyStr,
ignore: Sequence = (),

Check failure on line 199 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Missing type parameters for generic type "Sequence"
ignore_hidden: bool = False,
logger: Optional[Logger] = None,
) -> Generator[Tuple, None, None]:

Check failure on line 202 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Missing type parameters for generic type "Tuple"
"""Like `os.walk`, but yields things in case-insensitive sorted,
breadth-first order. Directory and file names matching any glob
pattern in `ignore` are skipped. If `logger` is provided, then
warning messages are logged there when a directory cannot be listed.
"""
# Make sure the paths aren't Unicode strings.
path = bytestring_path(path)

Check failure on line 209 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Incompatible types in assignment (expression has type "bytes", variable has type "str")
ignore = [bytestring_path(i) for i in ignore]

# Get all the directories and files at this level.
Expand All @@ -227,7 +231,7 @@
if fnmatch.fnmatch(base, pat):
if logger:
logger.debug(
"ignoring {} due to ignore rule {}".format(base, pat)

Check failure on line 234 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

If x = b'abc' then f"{x}" or "{}".format(x) produces "b'abc'", not "abc". If this is desired behavior, use f"{x!r}" or "{!r}".format(x). Otherwise, decode the bytes
)
skip = True
break
Expand All @@ -235,7 +239,7 @@
continue

# Add to output as either a file or a directory.
cur = os.path.join(path, base)

Check failure on line 242 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

No overload variant of "join" matches argument types "str", "bytes"
if (ignore_hidden and not hidden.is_hidden(cur)) or not ignore_hidden:
if os.path.isdir(syspath(cur)):
dirs.append(base)
Expand All @@ -249,7 +253,7 @@

# Recurse into directories.
for base in dirs:
cur = os.path.join(path, base)

Check failure on line 256 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

No overload variant of "join" matches argument types "str", "bytes"
# yield from sorted_walk(...)
yield from sorted_walk(cur, ignore, ignore_hidden, logger)

Expand Down Expand Up @@ -302,7 +306,7 @@
emptiness. If root is not provided, then only path may be removed
(i.e., no recursive removal).
"""
path = normpath(path)

Check failure on line 309 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Incompatible types in assignment (expression has type "bytes", variable has type "str")

Check failure on line 309 in beets/util/__init__.py

View workflow job for this annotation

GitHub Actions / Check types with mypy

Argument 1 to "normpath" has incompatible type "str"; expected "bytes"
if root is not None:
root = normpath(root)

Expand Down Expand Up @@ -1076,3 +1080,46 @@
self.cache[owner] = self.getter(owner)

return self.cache[owner]


def get_module_tempdir(module: str) -> Path:
"""Return the temporary directory for the given module.

The directory is created within the `/tmp/beets/<module>` directory on
Linux (or the equivalent temporary directory on other systems).

Dots in the module name are replaced by underscores.
"""
module = module.replace("beets.", "").replace(".", "_")
return Path(tempfile.gettempdir()) / "beets" / module


def clean_module_tempdir(module: str) -> None:
"""Clean the temporary directory for the given module."""
tempdir = get_module_tempdir(module)
shutil.rmtree(tempdir, ignore_errors=True)
with suppress(OSError):
# remove parent (/tmp/beets) directory if it is empty
tempdir.parent.rmdir()


def get_temp_filename(
module: str,
prefix: str = "",
path: PathLike | None = None,
suffix: str = "",
) -> bytes:
"""Return temporary filename for the given module and prefix.

The filename starts with the given `prefix`.
If 'suffix' is given, it is used a the file extension.
If 'path' is given, we use the same suffix.
"""
if not suffix and path:
suffix = Path(os.fsdecode(path)).suffix

tempdir = get_module_tempdir(module)
tempdir.mkdir(parents=True, exist_ok=True)

_, filename = tempfile.mkstemp(dir=tempdir, prefix=prefix, suffix=suffix)
return bytestring_path(filename)
27 changes: 12 additions & 15 deletions beets/util/artresizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@
import re
import subprocess
from itertools import chain
from tempfile import NamedTemporaryFile
from urllib.parse import urlencode

from beets import logging, util
from beets.util import bytestring_path, displayable_path, syspath
from beets.util import displayable_path, get_temp_filename, syspath

PROXY_URL = "https://images.weserv.nl/"

Expand All @@ -48,15 +47,6 @@ def resize_url(url, maxwidth, quality=0):
return "{}?{}".format(PROXY_URL, urlencode(params))


def temp_file_for(path):
"""Return an unused filename with the same extension as the
specified path.
"""
ext = os.path.splitext(path)[1]
with NamedTemporaryFile(suffix=os.fsdecode(ext), delete=False) as f:
return bytestring_path(f.name)


class LocalBackendNotAvailableError(Exception):
pass

Expand Down Expand Up @@ -141,7 +131,9 @@ def resize(
Use the ``magick`` program or ``convert`` on older versions. Return
the output path of resized image.
"""
path_out = path_out or temp_file_for(path_in)
if not path_out:
path_out = get_temp_filename(__name__, "resize_IM_", path_in)

log.debug(
"artresizer: ImageMagick resizing {0} to {1}",
displayable_path(path_in),
Expand Down Expand Up @@ -208,7 +200,8 @@ def get_size(self, path_in):
return None

def deinterlace(self, path_in, path_out=None):
path_out = path_out or temp_file_for(path_in)
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)

cmd = self.convert_cmd + [
syspath(path_in, prefix=False),
Expand Down Expand Up @@ -366,7 +359,9 @@ def resize(
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
path_out = path_out or temp_file_for(path_in)
if not path_out:
path_out = get_temp_filename(__name__, "resize_PIL_", path_in)

from PIL import Image

log.debug(
Expand Down Expand Up @@ -442,7 +437,9 @@ def get_size(self, path_in):
return None

def deinterlace(self, path_in, path_out=None):
path_out = path_out or temp_file_for(path_in)
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)

from PIL import Image

try:
Expand Down
11 changes: 5 additions & 6 deletions beetsplug/fetchart.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,13 @@
import re
from collections import OrderedDict
from contextlib import closing
from tempfile import NamedTemporaryFile

import confuse
import requests
from mediafile import image_mime_type

from beets import config, importer, plugins, ui, util
from beets.util import bytestring_path, sorted_walk, syspath
from beets.util import bytestring_path, get_temp_filename, sorted_walk, syspath
from beets.util.artresizer import ArtResizer

try:
Expand Down Expand Up @@ -412,17 +411,17 @@ def fetch_image(self, candidate, plugin):
ext,
)

suffix = os.fsdecode(ext)
with NamedTemporaryFile(suffix=suffix, delete=False) as fh:
filename = get_temp_filename(__name__, suffix=ext.decode())
with open(filename, "wb") as fh:
# write the first already loaded part of the image
fh.write(header)
# download the remaining part of the image
for chunk in data:
fh.write(chunk)
self._log.debug(
"downloaded art to: {0}", util.displayable_path(fh.name)
"downloaded art to: {0}", util.displayable_path(filename)
)
candidate.path = util.bytestring_path(fh.name)
candidate.path = util.bytestring_path(filename)
return

except (OSError, requests.RequestException, TypeError) as exc:
Expand Down
16 changes: 8 additions & 8 deletions beetsplug/play.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@
import shlex
import subprocess
from os.path import relpath
from tempfile import NamedTemporaryFile

from beets import config, ui, util
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.ui.commands import PromptChoice
from beets.util import get_temp_filename

# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.
Expand Down Expand Up @@ -194,15 +194,15 @@ def _exceeds_threshold(
def _create_tmp_playlist(self, paths_list):
"""Create a temporary .m3u file. Return the filename."""
utf8_bom = config["play"]["bom"].get(bool)
m3u = NamedTemporaryFile("wb", suffix=".m3u", delete=False)
filename = get_temp_filename(__name__, suffix=".m3u")
with open(filename, "wb") as m3u:
if utf8_bom:
m3u.write(b"\xEF\xBB\xBF")

if utf8_bom:
m3u.write(b"\xEF\xBB\xBF")
for item in paths_list:
m3u.write(item + b"\n")

for item in paths_list:
m3u.write(item + b"\n")
m3u.close()
return m3u.name
return filename

def before_choose_candidate_listener(self, session, task):
"""Append a "Play" choice to the interactive importer prompt."""
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,21 @@ env.OPTS = """
--cov-context=test
"""

[tool.poe.tasks.check-temp-files]
help = "Run each test module one by one and check for leftover temp files"
shell = """
setopt nullglob
for file in test/**/*.py; do
print Temp files created by $file && poe test $file &>/dev/null
tempfiles=(/tmp/**/tmp* /tmp/beets/**/*)
if (( $#tempfiles )); then
print -l $'\t'$^tempfiles
rm -r --interactive=never $tempfiles &>/dev/null
fi
done
"""
interpreter = "zsh"

Comment on lines +233 to +247
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we add this to our testing CI? It seems like there's no downside.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think the downside here is how long it takes to run - I have done it now and it took longer than 4 minutes.

In any case, there's probably little value for it once these issues are fixed - but it's good to have it around in case this issue comes up again.

Copy link
Contributor

Choose a reason for hiding this comment

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

That's interesting, why does it take longer to run than the normal test suite?

I suppose it doesn't actually matter, once we switch to pytest, it'll handle all of the temp files and directories and fix issues.

Additionally this issue is more of a nice to have and to stop any possible dependence between test runs because the OS should clear out temp files, at the very least every reboot. That's what the temp files are for.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's interesting, why does it take longer to run than the normal test suite?

Because it runs pytest for each of the test files :)

Copy link
Contributor

Choose a reason for hiding this comment

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

Do you mean that it runs the entire test suite for each of the files?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nope, each invocation runs tests found in each of these files

$ centralize-test-setup print -l test/**/*.py                                                                                                  beets 2.0.0 │ 🐍 3.8 🐶
test/__init__.py
test/plugins/__init__.py
test/plugins/lyrics_download_samples.py
test/plugins/test_acousticbrainz.py
test/plugins/test_advancedrewrite.py
test/plugins/test_albumtypes.py
test/plugins/test_art.py
test/plugins/test_aura.py
test/plugins/test_bareasc.py
test/plugins/test_beatport.py

[tool.black]
line-length = 80
target-version = ["py38", "py39", "py310", "py311"]
Expand Down
Loading
Loading