diff --git a/beets/test/helper.py b/beets/test/helper.py index ebfedb2aa..059500bba 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -29,6 +29,7 @@ information or mock the environment. - The `TestHelper` class encapsulates various fixtures that can be set up. """ +from __future__ import annotations import os import os.path @@ -39,6 +40,7 @@ from contextlib import contextmanager from enum import Enum from io import StringIO from tempfile import mkdtemp, mkstemp +from typing import ClassVar import responses from mediafile import Image, MediaFile @@ -50,7 +52,12 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo 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): @@ -952,3 +959,13 @@ class FetchImageHelper: # 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) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index cae081dd4..9076bea30 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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 @@ -26,9 +27,11 @@ import sys 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, @@ -58,6 +61,7 @@ MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = "\\\\?\\" T = TypeVar("T") Bytes_or_String: TypeAlias = Union[str, bytes] +PathLike = Union[str, bytes, Path] class HumanReadableException(Exception): @@ -1076,3 +1080,46 @@ class cached_classproperty: # noqa: N801 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/` 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) diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 84844fac1..09cc29e0d 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -22,11 +22,10 @@ import platform 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/" @@ -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 @@ -141,7 +131,9 @@ class IMBackend(LocalBackend): 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), @@ -208,7 +200,8 @@ class IMBackend(LocalBackend): 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), @@ -366,7 +359,9 @@ class PILBackend(LocalBackend): """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( @@ -442,7 +437,9 @@ class PILBackend(LocalBackend): 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: diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index a3bac19a1..72aa3aa29 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -19,14 +19,13 @@ import os 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: @@ -412,17 +411,17 @@ class RemoteArtSource(ArtSource): 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: diff --git a/test/plugins/test_art.py b/test/plugins/test_art.py index a8cbc15ae..8a2aa5870 100644 --- a/test/plugins/test_art.py +++ b/test/plugins/test_art.py @@ -26,7 +26,7 @@ import responses from beets import config, importer, library, logging, util from beets.autotag import AlbumInfo, AlbumMatch from beets.test import _common -from beets.test.helper import FetchImageHelper, capture_log +from beets.test.helper import CleanupModulesMixin, FetchImageHelper, capture_log from beets.util import syspath from beets.util.artresizer import ArtResizer from beetsplug import fetchart @@ -44,7 +44,9 @@ class Settings: setattr(self, k, v) -class UseThePlugin(_common.TestCase): +class UseThePlugin(CleanupModulesMixin, _common.TestCase): + modules = (fetchart.__name__, ArtResizer.__module__) + def setUp(self): super().setUp() self.plugin = fetchart.FetchArtPlugin() diff --git a/test/test_art_resize.py b/test/test_art_resize.py index 5cb1e7e69..ac9463cba 100644 --- a/test/test_art_resize.py +++ b/test/test_art_resize.py @@ -20,7 +20,7 @@ import unittest from unittest.mock import patch from beets.test import _common -from beets.test.helper import TestHelper +from beets.test.helper import CleanupModulesMixin, TestHelper from beets.util import command_output, syspath from beets.util.artresizer import IMBackend, PILBackend @@ -48,9 +48,11 @@ class DummyPILBackend(PILBackend): pass -class ArtResizerFileSizeTest(_common.TestCase, TestHelper): +class ArtResizerFileSizeTest(CleanupModulesMixin, _common.TestCase, TestHelper): """Unittest test case for Art Resizer to a specific filesize.""" + modules = (IMBackend.__module__,) + IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg") IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size