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.
"""
from __future__ import annotations
import os
import os.path
import platform
import re
import subprocess
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 beets import logging, util
from beets.util import displayable_path, get_temp_filename, syspath
if TYPE_CHECKING:
try:
from PIL import Image
except ImportError:
from typing import Any
Image = Any
PROXY_URL = "https://images.weserv.nl/"
@ -58,7 +64,10 @@ class LocalBackendNotAvailableError(Exception):
_NOT_AVAILABLE = object()
# FIXME: Turn this into an ABC with all methods that a backend should have
class LocalBackend:
NAME: ClassVar[str]
@classmethod
def available(cls) -> bool:
try:
@ -74,11 +83,11 @@ class IMBackend(LocalBackend):
# These fields are used as a cache for `version()`. `_legacy` indicates
# whether the modern `magick` binary is available or whether to fall back
# to the old-style `convert`, `identify`, etc. commands.
_version = None
_legacy = None
_version: tuple[int, int, int] | None = None
_legacy: bool | None = None
@classmethod
def version(cls) -> Optional[Union[object, Tuple[int, int, int]]]:
def version(cls) -> tuple[int, int, int]:
"""Obtain and cache ImageMagick version.
Raises `LocalBackendNotAvailableError` if not available.
@ -107,7 +116,7 @@ class IMBackend(LocalBackend):
else:
return cls._version
def __init__(self):
def __init__(self) -> None:
"""Initialize a wrapper around ImageMagick for local image operations.
Stores the ImageMagick version and legacy flag. If ImageMagick is not
@ -130,11 +139,11 @@ class IMBackend(LocalBackend):
def resize(
self,
maxwidth: int,
path_in: AnyStr,
path_out: Optional[AnyStr] = None,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> AnyStr:
) -> bytes:
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
@ -183,7 +192,7 @@ class IMBackend(LocalBackend):
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 + [
"-format",
"%w %h",
@ -210,9 +219,9 @@ class IMBackend(LocalBackend):
def deinterlace(
self,
path_in: AnyStr,
path_out: Optional[AnyStr] = None,
) -> AnyStr:
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
@ -230,8 +239,8 @@ class IMBackend(LocalBackend):
# FIXME: Should probably issue a warning?
return path_in
def get_format(self, filepath: AnyStr) -> Optional[bytes]:
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)]
def get_format(self, path_in: bytes) -> bytes | None:
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(path_in)]
try:
return util.command_output(cmd).stdout
@ -241,10 +250,10 @@ class IMBackend(LocalBackend):
def convert_format(
self,
source: AnyStr,
target: AnyStr,
source: bytes,
target: bytes,
deinterlaced: bool,
) -> AnyStr:
) -> bytes:
cmd = self.convert_cmd + [
syspath(source),
*(["-interlace", "none"] if deinterlaced else []),
@ -266,10 +275,10 @@ class IMBackend(LocalBackend):
def compare(
self,
im1: Image,
im2: Image,
im1: bytes,
im2: bytes,
compare_threshold: float,
) -> Optional[bool]:
) -> bool | None:
is_windows = platform.system() == "Windows"
# Converting images to grayscale tends to minimize the weight
@ -355,7 +364,7 @@ class IMBackend(LocalBackend):
def can_write_metadata(self) -> bool:
return True
def write_metadata(self, file: AnyStr, metadata: Mapping):
def write_metadata(self, file: bytes, metadata: Mapping) -> None:
assignments = list(
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
)
@ -368,13 +377,13 @@ class PILBackend(LocalBackend):
NAME = "PIL"
@classmethod
def version(cls):
def version(cls) -> None:
try:
__import__("PIL", fromlist=["Image"])
except ImportError:
raise LocalBackendNotAvailableError()
def __init__(self):
def __init__(self) -> None:
"""Initialize a wrapper around PIL for local image operations.
If PIL is not available, raise an Exception.
@ -384,11 +393,11 @@ class PILBackend(LocalBackend):
def resize(
self,
maxwidth: int,
path_in: AnyStr,
path_out: Optional[AnyStr] = None,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> AnyStr:
) -> bytes:
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
@ -457,7 +466,7 @@ class PILBackend(LocalBackend):
)
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
try:
@ -471,9 +480,9 @@ class PILBackend(LocalBackend):
def deinterlace(
self,
path_in: AnyStr,
path_out: Optional[AnyStr] = None,
) -> AnyStr:
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
@ -487,11 +496,11 @@ class PILBackend(LocalBackend):
# FIXME: Should probably issue a warning?
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
try:
with Image.open(syspath(filepath)) as im:
with Image.open(syspath(path_in)) as im:
return im.format
except (
ValueError,
@ -499,15 +508,15 @@ class PILBackend(LocalBackend):
UnidentifiedImageError,
FileNotFoundError,
):
log.exception("failed to detect image format for {}", filepath)
log.exception("failed to detect image format for {}", path_in)
return None
def convert_format(
self,
source: AnyStr,
target: AnyStr,
source: bytes,
target: bytes,
deinterlaced: bool,
) -> str:
) -> bytes:
from PIL import Image, UnidentifiedImageError
try:
@ -530,10 +539,10 @@ class PILBackend(LocalBackend):
def compare(
self,
im1: Image,
im2: Image,
im1: bytes,
im2: bytes,
compare_threshold: float,
):
) -> bool | None:
# It is an error to call this when ArtResizer.can_compare is not True.
raise NotImplementedError()
@ -541,7 +550,7 @@ class PILBackend(LocalBackend):
def can_write_metadata(self) -> bool:
return True
def write_metadata(self, file: AnyStr, metadata: Mapping):
def write_metadata(self, file: bytes, metadata: Mapping) -> None:
from PIL import Image, PngImagePlugin
# 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.
"""
def __init__(cls, name, bases, dict):
def __init__(cls, name, bases, dict) -> None:
super().__init__(name, bases, dict)
cls._instance = None
@property
def shared(cls) -> 'Shareable':
def shared(cls) -> Self:
if cls._instance is None:
cls._instance = cls()
return cls._instance
BACKEND_CLASSES = [
BACKEND_CLASSES: list[LocalBackend] = [
IMBackend,
PILBackend,
]
@ -580,7 +589,7 @@ BACKEND_CLASSES = [
class ArtResizer(metaclass=Shareable):
"""A singleton class that performs image resizes."""
def __init__(self):
def __init__(self) -> None:
"""Create a resizer object with an inferred method."""
# Check if a local backend is available, and store an instance of the
# backend class. Otherwise, fallback to the web proxy.
@ -592,6 +601,15 @@ class ArtResizer(metaclass=Shareable):
except LocalBackendNotAvailableError:
continue
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")
self.local_method = None
@ -605,11 +623,11 @@ class ArtResizer(metaclass=Shareable):
def resize(
self,
maxwidth: int,
path_in: AnyStr,
path_out: Optional[AnyStr]=None,
path_in: bytes,
path_out: bytes | None = None,
quality: int = 0,
max_filesize: int = 0,
) -> AnyStr:
) -> bytes:
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file and encodes with the specified quality level.
@ -629,9 +647,9 @@ class ArtResizer(metaclass=Shareable):
def deinterlace(
self,
path_in: AnyStr,
path_out: Optional[AnyStr] = None,
) -> AnyStr:
path_in: bytes,
path_out: bytes | None = None,
) -> bytes:
"""Deinterlace an image.
Only available locally.
@ -660,7 +678,7 @@ class ArtResizer(metaclass=Shareable):
"""
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)
in pixels.
@ -672,7 +690,7 @@ class ArtResizer(metaclass=Shareable):
# FIXME: Should probably issue a warning?
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.
Only available locally.
@ -685,10 +703,10 @@ class ArtResizer(metaclass=Shareable):
def reformat(
self,
path_in: AnyStr,
path_in: bytes,
new_format: str,
deinterlaced: bool = True,
) -> AnyStr:
) -> bytes:
"""Converts image to desired format, updating its extension, but
keeping the same filename.
@ -730,10 +748,10 @@ class ArtResizer(metaclass=Shareable):
def compare(
self,
im1: Image,
im2: Image,
im1: bytes,
im2: bytes,
compare_threshold: float,
) -> Optional[bool]:
) -> bool | None:
"""Return a boolean indicating whether two images are similar.
Only available locally.
@ -753,7 +771,7 @@ class ArtResizer(metaclass=Shareable):
else:
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.
Only available locally. Currently, expects the image to be a PNG file.