artresizer: revise typings

This is more of a "what should the types be", in particular regarding
paths, it has not yet been run through mypy. That will be done next,
which is probably going to highlight a bunch of issues that should lead
to either the code being fixed, or the types adjusted.
This commit is contained in:
wisp3rwind 2025-04-15 22:25:13 +02:00
parent c90ff27315
commit b18e6e0654

View file

@ -16,20 +16,26 @@
public resizing proxy if neither is available. public resizing proxy if neither is available.
""" """
from __future__ import annotations
import os import os
import os.path import os.path
import platform import platform
import re import re
import subprocess import subprocess
from itertools import chain from itertools import chain
from typing import AnyStr, Tuple, Optional, Mapping, Union, TYPE_CHECKING from typing import TYPE_CHECKING, ClassVar, Mapping, Self
from urllib.parse import urlencode from urllib.parse import urlencode
from beets import logging, util from beets import logging, util
from beets.util import displayable_path, get_temp_filename, syspath from beets.util import displayable_path, get_temp_filename, syspath
if TYPE_CHECKING: if TYPE_CHECKING:
from PIL import Image try:
from PIL import Image
except ImportError:
from typing import Any
Image = Any
PROXY_URL = "https://images.weserv.nl/" PROXY_URL = "https://images.weserv.nl/"
@ -58,7 +64,10 @@ class LocalBackendNotAvailableError(Exception):
_NOT_AVAILABLE = object() _NOT_AVAILABLE = object()
# FIXME: Turn this into an ABC with all methods that a backend should have
class LocalBackend: class LocalBackend:
NAME: ClassVar[str]
@classmethod @classmethod
def available(cls) -> bool: def available(cls) -> bool:
try: try:
@ -74,11 +83,11 @@ class IMBackend(LocalBackend):
# These fields are used as a cache for `version()`. `_legacy` indicates # These fields are used as a cache for `version()`. `_legacy` indicates
# whether the modern `magick` binary is available or whether to fall back # whether the modern `magick` binary is available or whether to fall back
# to the old-style `convert`, `identify`, etc. commands. # to the old-style `convert`, `identify`, etc. commands.
_version = None _version: tuple[int, int, int] | None = None
_legacy = None _legacy: bool | None = None
@classmethod @classmethod
def version(cls) -> Optional[Union[object, Tuple[int, int, int]]]: def version(cls) -> tuple[int, int, int]:
"""Obtain and cache ImageMagick version. """Obtain and cache ImageMagick version.
Raises `LocalBackendNotAvailableError` if not available. Raises `LocalBackendNotAvailableError` if not available.
@ -107,7 +116,7 @@ class IMBackend(LocalBackend):
else: else:
return cls._version return cls._version
def __init__(self): def __init__(self) -> None:
"""Initialize a wrapper around ImageMagick for local image operations. """Initialize a wrapper around ImageMagick for local image operations.
Stores the ImageMagick version and legacy flag. If ImageMagick is not Stores the ImageMagick version and legacy flag. If ImageMagick is not
@ -130,11 +139,11 @@ class IMBackend(LocalBackend):
def resize( def resize(
self, self,
maxwidth: int, maxwidth: int,
path_in: AnyStr, path_in: bytes,
path_out: Optional[AnyStr] = None, path_out: bytes | None = None,
quality: int = 0, quality: int = 0,
max_filesize: int = 0, max_filesize: int = 0,
) -> AnyStr: ) -> bytes:
"""Resize using ImageMagick. """Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return Use the ``magick`` program or ``convert`` on older versions. Return
@ -183,7 +192,7 @@ class IMBackend(LocalBackend):
return path_out return path_out
def get_size(self, path_in: str) -> Optional[Tuple[int, ...]]: def get_size(self, path_in: bytes) -> tuple[int, int] | None:
cmd = self.identify_cmd + [ cmd = self.identify_cmd + [
"-format", "-format",
"%w %h", "%w %h",
@ -209,10 +218,10 @@ class IMBackend(LocalBackend):
return None return None
def deinterlace( def deinterlace(
self, self,
path_in: AnyStr, path_in: bytes,
path_out: Optional[AnyStr] = None, path_out: bytes | None = None,
) -> AnyStr: ) -> bytes:
if not path_out: if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in) path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
@ -230,8 +239,8 @@ class IMBackend(LocalBackend):
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return path_in return path_in
def get_format(self, filepath: AnyStr) -> Optional[bytes]: def get_format(self, path_in: bytes) -> bytes | None:
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)] cmd = self.identify_cmd + ["-format", "%[magick]", syspath(path_in)]
try: try:
return util.command_output(cmd).stdout return util.command_output(cmd).stdout
@ -241,10 +250,10 @@ class IMBackend(LocalBackend):
def convert_format( def convert_format(
self, self,
source: AnyStr, source: bytes,
target: AnyStr, target: bytes,
deinterlaced: bool, deinterlaced: bool,
) -> AnyStr: ) -> bytes:
cmd = self.convert_cmd + [ cmd = self.convert_cmd + [
syspath(source), syspath(source),
*(["-interlace", "none"] if deinterlaced else []), *(["-interlace", "none"] if deinterlaced else []),
@ -265,11 +274,11 @@ class IMBackend(LocalBackend):
return self.version() > (6, 8, 7) return self.version() > (6, 8, 7)
def compare( def compare(
self, self,
im1: Image, im1: bytes,
im2: Image, im2: bytes,
compare_threshold: float, compare_threshold: float,
) -> Optional[bool]: ) -> bool | None:
is_windows = platform.system() == "Windows" is_windows = platform.system() == "Windows"
# Converting images to grayscale tends to minimize the weight # Converting images to grayscale tends to minimize the weight
@ -355,7 +364,7 @@ class IMBackend(LocalBackend):
def can_write_metadata(self) -> bool: def can_write_metadata(self) -> bool:
return True return True
def write_metadata(self, file: AnyStr, metadata: Mapping): def write_metadata(self, file: bytes, metadata: Mapping) -> None:
assignments = list( assignments = list(
chain.from_iterable(("-set", k, v) for k, v in metadata.items()) chain.from_iterable(("-set", k, v) for k, v in metadata.items())
) )
@ -368,13 +377,13 @@ class PILBackend(LocalBackend):
NAME = "PIL" NAME = "PIL"
@classmethod @classmethod
def version(cls): def version(cls) -> None:
try: try:
__import__("PIL", fromlist=["Image"]) __import__("PIL", fromlist=["Image"])
except ImportError: except ImportError:
raise LocalBackendNotAvailableError() raise LocalBackendNotAvailableError()
def __init__(self): def __init__(self) -> None:
"""Initialize a wrapper around PIL for local image operations. """Initialize a wrapper around PIL for local image operations.
If PIL is not available, raise an Exception. If PIL is not available, raise an Exception.
@ -384,11 +393,11 @@ class PILBackend(LocalBackend):
def resize( def resize(
self, self,
maxwidth: int, maxwidth: int,
path_in: AnyStr, path_in: bytes,
path_out: Optional[AnyStr] = None, path_out: bytes | None = None,
quality: int = 0, quality: int = 0,
max_filesize: int = 0, max_filesize: int = 0,
) -> AnyStr: ) -> bytes:
"""Resize using Python Imaging Library (PIL). Return the output path """Resize using Python Imaging Library (PIL). Return the output path
of resized image. of resized image.
""" """
@ -457,7 +466,7 @@ class PILBackend(LocalBackend):
) )
return path_in return path_in
def get_size(self, path_in: AnyStr) -> Optional[Tuple[int, int]]: def get_size(self, path_in: bytes) -> tuple[int, int] | None:
from PIL import Image from PIL import Image
try: try:
@ -470,10 +479,10 @@ class PILBackend(LocalBackend):
return None return None
def deinterlace( def deinterlace(
self, self,
path_in: AnyStr, path_in: bytes,
path_out: Optional[AnyStr] = None, path_out: bytes | None = None,
) -> AnyStr: ) -> bytes:
if not path_out: if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in) path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
@ -487,11 +496,11 @@ class PILBackend(LocalBackend):
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return path_in return path_in
def get_format(self, filepath: AnyStr) -> Optional[str]: def get_format(self, path_in: bytes) -> bytes | None:
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
try: try:
with Image.open(syspath(filepath)) as im: with Image.open(syspath(path_in)) as im:
return im.format return im.format
except ( except (
ValueError, ValueError,
@ -499,15 +508,15 @@ class PILBackend(LocalBackend):
UnidentifiedImageError, UnidentifiedImageError,
FileNotFoundError, FileNotFoundError,
): ):
log.exception("failed to detect image format for {}", filepath) log.exception("failed to detect image format for {}", path_in)
return None return None
def convert_format( def convert_format(
self, self,
source: AnyStr, source: bytes,
target: AnyStr, target: bytes,
deinterlaced: bool, deinterlaced: bool,
) -> str: ) -> bytes:
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
try: try:
@ -529,11 +538,11 @@ class PILBackend(LocalBackend):
return False return False
def compare( def compare(
self, self,
im1: Image, im1: bytes,
im2: Image, im2: bytes,
compare_threshold: float, compare_threshold: float,
): ) -> bool | None:
# It is an error to call this when ArtResizer.can_compare is not True. # It is an error to call this when ArtResizer.can_compare is not True.
raise NotImplementedError() raise NotImplementedError()
@ -541,7 +550,7 @@ class PILBackend(LocalBackend):
def can_write_metadata(self) -> bool: def can_write_metadata(self) -> bool:
return True return True
def write_metadata(self, file: AnyStr, metadata: Mapping): def write_metadata(self, file: bytes, metadata: Mapping) -> None:
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
# FIXME: Detect and handle other file types (currently, the only user # FIXME: Detect and handle other file types (currently, the only user
@ -560,18 +569,18 @@ class Shareable(type):
``MyClass()`` to construct a new object works as usual. ``MyClass()`` to construct a new object works as usual.
""" """
def __init__(cls, name, bases, dict): def __init__(cls, name, bases, dict) -> None:
super().__init__(name, bases, dict) super().__init__(name, bases, dict)
cls._instance = None cls._instance = None
@property @property
def shared(cls) -> 'Shareable': def shared(cls) -> Self:
if cls._instance is None: if cls._instance is None:
cls._instance = cls() cls._instance = cls()
return cls._instance return cls._instance
BACKEND_CLASSES = [ BACKEND_CLASSES: list[LocalBackend] = [
IMBackend, IMBackend,
PILBackend, PILBackend,
] ]
@ -580,7 +589,7 @@ BACKEND_CLASSES = [
class ArtResizer(metaclass=Shareable): class ArtResizer(metaclass=Shareable):
"""A singleton class that performs image resizes.""" """A singleton class that performs image resizes."""
def __init__(self): def __init__(self) -> None:
"""Create a resizer object with an inferred method.""" """Create a resizer object with an inferred method."""
# Check if a local backend is available, and store an instance of the # Check if a local backend is available, and store an instance of the
# backend class. Otherwise, fallback to the web proxy. # backend class. Otherwise, fallback to the web proxy.
@ -592,6 +601,15 @@ class ArtResizer(metaclass=Shareable):
except LocalBackendNotAvailableError: except LocalBackendNotAvailableError:
continue continue
else: else:
# FIXME: Turn WEBPROXY into a backend class as well to remove all
# the special casing. Then simply delegate all methods to the
# backends. (How does proxy_url fit in here, however?)
# Use an ABC (or maybe a typing Protocol?) for backend
# methods, such that both individual backends as well as
# ArtResizer implement it.
# It should probably be configurable which backends classes to
# consider, similar to fetchart or lyrics backends (i.e. a list
# of backends sorted by priority).
log.debug("artresizer: method is WEBPROXY") log.debug("artresizer: method is WEBPROXY")
self.local_method = None self.local_method = None
@ -605,11 +623,11 @@ class ArtResizer(metaclass=Shareable):
def resize( def resize(
self, self,
maxwidth: int, maxwidth: int,
path_in: AnyStr, path_in: bytes,
path_out: Optional[AnyStr]=None, path_out: bytes | None = None,
quality: int = 0, quality: int = 0,
max_filesize: int = 0, max_filesize: int = 0,
) -> AnyStr: ) -> bytes:
"""Manipulate an image file according to the method, returning a """Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file and encodes with the specified quality level. temporary file and encodes with the specified quality level.
@ -628,10 +646,10 @@ class ArtResizer(metaclass=Shareable):
return path_in return path_in
def deinterlace( def deinterlace(
self, self,
path_in: AnyStr, path_in: bytes,
path_out: Optional[AnyStr] = None, path_out: bytes | None = None,
) -> AnyStr: ) -> bytes:
"""Deinterlace an image. """Deinterlace an image.
Only available locally. Only available locally.
@ -660,7 +678,7 @@ class ArtResizer(metaclass=Shareable):
""" """
return self.local_method is not None return self.local_method is not None
def get_size(self, path_in: AnyStr) -> Union[Tuple[int, int], AnyStr]: def get_size(self, path_in: bytes) -> tuple[int, int] | None:
"""Return the size of an image file as an int couple (width, height) """Return the size of an image file as an int couple (width, height)
in pixels. in pixels.
@ -672,7 +690,7 @@ class ArtResizer(metaclass=Shareable):
# FIXME: Should probably issue a warning? # FIXME: Should probably issue a warning?
return path_in return path_in
def get_format(self, path_in: AnyStr) -> Optional[str]: def get_format(self, path_in: bytes) -> bytes | None:
"""Returns the format of the image as a string. """Returns the format of the image as a string.
Only available locally. Only available locally.
@ -684,11 +702,11 @@ class ArtResizer(metaclass=Shareable):
return None return None
def reformat( def reformat(
self, self,
path_in: AnyStr, path_in: bytes,
new_format: str, new_format: str,
deinterlaced: bool = True, deinterlaced: bool = True,
) -> AnyStr: ) -> bytes:
"""Converts image to desired format, updating its extension, but """Converts image to desired format, updating its extension, but
keeping the same filename. keeping the same filename.
@ -729,11 +747,11 @@ class ArtResizer(metaclass=Shareable):
return False return False
def compare( def compare(
self, self,
im1: Image, im1: bytes,
im2: Image, im2: bytes,
compare_threshold: float, compare_threshold: float,
) -> Optional[bool]: ) -> bool | None:
"""Return a boolean indicating whether two images are similar. """Return a boolean indicating whether two images are similar.
Only available locally. Only available locally.
@ -753,7 +771,7 @@ class ArtResizer(metaclass=Shareable):
else: else:
return False return False
def write_metadata(self, file: AnyStr, metadata: Mapping): def write_metadata(self, file: bytes, metadata: Mapping) -> None:
"""Write key-value metadata to the image file. """Write key-value metadata to the image file.
Only available locally. Currently, expects the image to be a PNG file. Only available locally. Currently, expects the image to be a PNG file.