mirror of
https://github.com/beetbox/beets.git
synced 2025-12-07 17:16:07 +01:00
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:
parent
c90ff27315
commit
b18e6e0654
1 changed files with 89 additions and 71 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue