mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-19 23:44:01 +01:00
update Beets
This commit is contained in:
+365
-351
File diff suppressed because it is too large
Load Diff
+522
-315
@@ -16,23 +16,20 @@
|
||||
public resizing proxy if neither is available.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
import os.path
|
||||
import platform
|
||||
import re
|
||||
from tempfile import NamedTemporaryFile
|
||||
import subprocess
|
||||
from itertools import chain
|
||||
from urllib.parse import urlencode
|
||||
from beets import logging
|
||||
from beets import util
|
||||
|
||||
# Resizing methods
|
||||
PIL = 1
|
||||
IMAGEMAGICK = 2
|
||||
WEBPROXY = 3
|
||||
from beets import logging, util
|
||||
from beets.util import displayable_path, get_temp_filename, syspath
|
||||
|
||||
PROXY_URL = 'https://images.weserv.nl/'
|
||||
PROXY_URL = "https://images.weserv.nl/"
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
def resize_url(url, maxwidth, quality=0):
|
||||
@@ -40,265 +37,473 @@ def resize_url(url, maxwidth, quality=0):
|
||||
maxwidth (preserving aspect ratio).
|
||||
"""
|
||||
params = {
|
||||
'url': url.replace('http://', ''),
|
||||
'w': maxwidth,
|
||||
"url": url.replace("http://", ""),
|
||||
"w": maxwidth,
|
||||
}
|
||||
|
||||
if quality > 0:
|
||||
params['q'] = quality
|
||||
params["q"] = quality
|
||||
|
||||
return '{}?{}'.format(PROXY_URL, urlencode(params))
|
||||
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=util.py3_path(ext), delete=False) as f:
|
||||
return util.bytestring_path(f.name)
|
||||
class LocalBackendNotAvailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
||||
"""Resize using Python Imaging Library (PIL). Return the output path
|
||||
of resized image.
|
||||
"""
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
from PIL import Image
|
||||
_NOT_AVAILABLE = object()
|
||||
|
||||
log.debug('artresizer: PIL resizing {0} to {1}',
|
||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
||||
|
||||
try:
|
||||
im = Image.open(util.syspath(path_in))
|
||||
size = maxwidth, maxwidth
|
||||
im.thumbnail(size, Image.ANTIALIAS)
|
||||
class LocalBackend:
|
||||
@classmethod
|
||||
def available(cls):
|
||||
try:
|
||||
cls.version()
|
||||
return True
|
||||
except LocalBackendNotAvailableError:
|
||||
return False
|
||||
|
||||
if quality == 0:
|
||||
# Use PIL's default quality.
|
||||
quality = -1
|
||||
|
||||
# progressive=False only affects JPEGs and is the default,
|
||||
# but we include it here for explicitness.
|
||||
im.save(util.py3_path(path_out), quality=quality, progressive=False)
|
||||
class IMBackend(LocalBackend):
|
||||
NAME = "ImageMagick"
|
||||
|
||||
if max_filesize > 0:
|
||||
# If maximum filesize is set, we attempt to lower the quality of
|
||||
# jpeg conversion by a proportional amount, up to 3 attempts
|
||||
# First, set the maximum quality to either provided, or 95
|
||||
if quality > 0:
|
||||
lower_qual = quality
|
||||
else:
|
||||
lower_qual = 95
|
||||
for i in range(5):
|
||||
# 5 attempts is an abitrary choice
|
||||
filesize = os.stat(util.syspath(path_out)).st_size
|
||||
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
|
||||
if filesize <= max_filesize:
|
||||
return path_out
|
||||
# The relationship between filesize & quality will be
|
||||
# image dependent.
|
||||
lower_qual -= 10
|
||||
# Restrict quality dropping below 10
|
||||
if lower_qual < 10:
|
||||
lower_qual = 10
|
||||
# Use optimize flag to improve filesize decrease
|
||||
im.save(util.py3_path(path_out), quality=lower_qual,
|
||||
optimize=True, progressive=False)
|
||||
log.warning("PIL Failed to resize file to below {0}B",
|
||||
max_filesize)
|
||||
return path_out
|
||||
# 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
|
||||
|
||||
@classmethod
|
||||
def version(cls):
|
||||
"""Obtain and cache ImageMagick version.
|
||||
|
||||
Raises `LocalBackendNotAvailableError` if not available.
|
||||
"""
|
||||
if cls._version is None:
|
||||
for cmd_name, legacy in (("magick", False), ("convert", True)):
|
||||
try:
|
||||
out = util.command_output([cmd_name, "--version"]).stdout
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
log.debug("ImageMagick version check failed: {}", exc)
|
||||
cls._version = _NOT_AVAILABLE
|
||||
else:
|
||||
if b"imagemagick" in out.lower():
|
||||
pattern = rb".+ (\d+)\.(\d+)\.(\d+).*"
|
||||
match = re.search(pattern, out)
|
||||
if match:
|
||||
cls._version = (
|
||||
int(match.group(1)),
|
||||
int(match.group(2)),
|
||||
int(match.group(3)),
|
||||
)
|
||||
cls._legacy = legacy
|
||||
|
||||
if cls._version is _NOT_AVAILABLE:
|
||||
raise LocalBackendNotAvailableError()
|
||||
else:
|
||||
return path_out
|
||||
except OSError:
|
||||
log.error("PIL cannot create thumbnail for '{0}'",
|
||||
util.displayable_path(path_in))
|
||||
return path_in
|
||||
return cls._version
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a wrapper around ImageMagick for local image operations.
|
||||
|
||||
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
||||
"""Resize using ImageMagick.
|
||||
Stores the ImageMagick version and legacy flag. If ImageMagick is not
|
||||
available, raise an Exception.
|
||||
"""
|
||||
self.version()
|
||||
|
||||
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)
|
||||
log.debug('artresizer: ImageMagick resizing {0} to {1}',
|
||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
||||
# Use ImageMagick's magick binary when it's available.
|
||||
# If it's not, fall back to the older, separate convert
|
||||
# and identify commands.
|
||||
if self._legacy:
|
||||
self.convert_cmd = ["convert"]
|
||||
self.identify_cmd = ["identify"]
|
||||
self.compare_cmd = ["compare"]
|
||||
else:
|
||||
self.convert_cmd = ["magick"]
|
||||
self.identify_cmd = ["magick", "identify"]
|
||||
self.compare_cmd = ["magick", "compare"]
|
||||
|
||||
# "-resize WIDTHx>" shrinks images with the width larger
|
||||
# than the given width while maintaining the aspect ratio
|
||||
# with regards to the height.
|
||||
# ImageMagick already seems to default to no interlace, but we include it
|
||||
# here for the sake of explicitness.
|
||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
||||
util.syspath(path_in, prefix=False),
|
||||
'-resize', f'{maxwidth}x>',
|
||||
'-interlace', 'none',
|
||||
]
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
):
|
||||
"""Resize using ImageMagick.
|
||||
|
||||
if quality > 0:
|
||||
cmd += ['-quality', f'{quality}']
|
||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||
the output path of resized image.
|
||||
"""
|
||||
if not path_out:
|
||||
path_out = get_temp_filename(__name__, "resize_IM_", path_in)
|
||||
|
||||
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to
|
||||
# SIZE in bytes.
|
||||
if max_filesize > 0:
|
||||
cmd += ['-define', f'jpeg:extent={max_filesize}b']
|
||||
|
||||
cmd.append(util.syspath(path_out, prefix=False))
|
||||
|
||||
try:
|
||||
util.command_output(cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
log.warning('artresizer: IM convert failed for {0}',
|
||||
util.displayable_path(path_in))
|
||||
return path_in
|
||||
|
||||
return path_out
|
||||
|
||||
|
||||
BACKEND_FUNCS = {
|
||||
PIL: pil_resize,
|
||||
IMAGEMAGICK: im_resize,
|
||||
}
|
||||
|
||||
|
||||
def pil_getsize(path_in):
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
im = Image.open(util.syspath(path_in))
|
||||
return im.size
|
||||
except OSError as exc:
|
||||
log.error("PIL could not read file {}: {}",
|
||||
util.displayable_path(path_in), exc)
|
||||
|
||||
|
||||
def im_getsize(path_in):
|
||||
cmd = ArtResizer.shared.im_identify_cmd + \
|
||||
['-format', '%w %h', util.syspath(path_in, prefix=False)]
|
||||
|
||||
try:
|
||||
out = util.command_output(cmd).stdout
|
||||
except subprocess.CalledProcessError as exc:
|
||||
log.warning('ImageMagick size query failed')
|
||||
log.debug(
|
||||
'`convert` exited with (status {}) when '
|
||||
'getting size with command {}:\n{}',
|
||||
exc.returncode, cmd, exc.output.strip()
|
||||
"artresizer: ImageMagick resizing {0} to {1}",
|
||||
displayable_path(path_in),
|
||||
displayable_path(path_out),
|
||||
)
|
||||
return
|
||||
try:
|
||||
return tuple(map(int, out.split(b' ')))
|
||||
except IndexError:
|
||||
log.warning('Could not understand IM output: {0!r}', out)
|
||||
|
||||
# "-resize WIDTHx>" shrinks images with the width larger
|
||||
# than the given width while maintaining the aspect ratio
|
||||
# with regards to the height.
|
||||
# ImageMagick already seems to default to no interlace, but we include
|
||||
# it here for the sake of explicitness.
|
||||
cmd = self.convert_cmd + [
|
||||
syspath(path_in, prefix=False),
|
||||
"-resize",
|
||||
f"{maxwidth}x>",
|
||||
"-interlace",
|
||||
"none",
|
||||
]
|
||||
|
||||
BACKEND_GET_SIZE = {
|
||||
PIL: pil_getsize,
|
||||
IMAGEMAGICK: im_getsize,
|
||||
}
|
||||
if quality > 0:
|
||||
cmd += ["-quality", f"{quality}"]
|
||||
|
||||
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick
|
||||
# to SIZE in bytes.
|
||||
if max_filesize > 0:
|
||||
cmd += ["-define", f"jpeg:extent={max_filesize}b"]
|
||||
|
||||
def pil_deinterlace(path_in, path_out=None):
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
from PIL import Image
|
||||
cmd.append(syspath(path_out, prefix=False))
|
||||
|
||||
try:
|
||||
util.command_output(cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
log.warning(
|
||||
"artresizer: IM convert failed for {0}",
|
||||
displayable_path(path_in),
|
||||
)
|
||||
return path_in
|
||||
|
||||
try:
|
||||
im = Image.open(util.syspath(path_in))
|
||||
im.save(util.py3_path(path_out), progressive=False)
|
||||
return path_out
|
||||
except IOError:
|
||||
return path_in
|
||||
|
||||
def get_size(self, path_in):
|
||||
cmd = self.identify_cmd + [
|
||||
"-format",
|
||||
"%w %h",
|
||||
syspath(path_in, prefix=False),
|
||||
]
|
||||
|
||||
def im_deinterlace(path_in, path_out=None):
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
try:
|
||||
out = util.command_output(cmd).stdout
|
||||
except subprocess.CalledProcessError as exc:
|
||||
log.warning("ImageMagick size query failed")
|
||||
log.debug(
|
||||
"`convert` exited with (status {}) when "
|
||||
"getting size with command {}:\n{}",
|
||||
exc.returncode,
|
||||
cmd,
|
||||
exc.output.strip(),
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return tuple(map(int, out.split(b" ")))
|
||||
except IndexError:
|
||||
log.warning("Could not understand IM output: {0!r}", out)
|
||||
return None
|
||||
|
||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
||||
util.syspath(path_in, prefix=False),
|
||||
'-interlace', 'none',
|
||||
util.syspath(path_out, prefix=False),
|
||||
]
|
||||
def deinterlace(self, path_in, path_out=None):
|
||||
if not path_out:
|
||||
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
|
||||
|
||||
try:
|
||||
util.command_output(cmd)
|
||||
return path_out
|
||||
except subprocess.CalledProcessError:
|
||||
return path_in
|
||||
cmd = self.convert_cmd + [
|
||||
syspath(path_in, prefix=False),
|
||||
"-interlace",
|
||||
"none",
|
||||
syspath(path_out, prefix=False),
|
||||
]
|
||||
|
||||
try:
|
||||
util.command_output(cmd)
|
||||
return path_out
|
||||
except subprocess.CalledProcessError:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
DEINTERLACE_FUNCS = {
|
||||
PIL: pil_deinterlace,
|
||||
IMAGEMAGICK: im_deinterlace,
|
||||
}
|
||||
def get_format(self, filepath):
|
||||
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)]
|
||||
|
||||
try:
|
||||
return util.command_output(cmd).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return None
|
||||
|
||||
def im_get_format(filepath):
|
||||
cmd = ArtResizer.shared.im_identify_cmd + [
|
||||
'-format', '%[magick]',
|
||||
util.syspath(filepath)
|
||||
]
|
||||
def convert_format(self, source, target, deinterlaced):
|
||||
cmd = self.convert_cmd + [
|
||||
syspath(source),
|
||||
*(["-interlace", "none"] if deinterlaced else []),
|
||||
syspath(target),
|
||||
]
|
||||
|
||||
try:
|
||||
return util.command_output(cmd).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def pil_get_format(filepath):
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
with Image.open(util.syspath(filepath)) as im:
|
||||
return im.format
|
||||
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
|
||||
log.exception("failed to detect image format for {}", filepath)
|
||||
return None
|
||||
|
||||
|
||||
BACKEND_GET_FORMAT = {
|
||||
PIL: pil_get_format,
|
||||
IMAGEMAGICK: im_get_format,
|
||||
}
|
||||
|
||||
|
||||
def im_convert_format(source, target, deinterlaced):
|
||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
||||
util.syspath(source),
|
||||
*(["-interlace", "none"] if deinterlaced else []),
|
||||
util.syspath(target),
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.check_call(
|
||||
cmd,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL
|
||||
)
|
||||
return target
|
||||
except subprocess.CalledProcessError:
|
||||
return source
|
||||
|
||||
|
||||
def pil_convert_format(source, target, deinterlaced):
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
with Image.open(util.syspath(source)) as im:
|
||||
im.save(util.py3_path(target), progressive=not deinterlaced)
|
||||
try:
|
||||
subprocess.check_call(
|
||||
cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL
|
||||
)
|
||||
return target
|
||||
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
|
||||
OSError):
|
||||
log.exception("failed to convert image {} -> {}", source, target)
|
||||
return source
|
||||
except subprocess.CalledProcessError:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return source
|
||||
|
||||
@property
|
||||
def can_compare(self):
|
||||
return self.version() > (6, 8, 7)
|
||||
|
||||
def compare(self, im1, im2, compare_threshold):
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
# Converting images to grayscale tends to minimize the weight
|
||||
# of colors in the diff score. So we first convert both images
|
||||
# to grayscale and then pipe them into the `compare` command.
|
||||
# On Windows, ImageMagick doesn't support the magic \\?\ prefix
|
||||
# on paths, so we pass `prefix=False` to `syspath`.
|
||||
convert_cmd = self.convert_cmd + [
|
||||
syspath(im2, prefix=False),
|
||||
syspath(im1, prefix=False),
|
||||
"-colorspace",
|
||||
"gray",
|
||||
"MIFF:-",
|
||||
]
|
||||
compare_cmd = self.compare_cmd + [
|
||||
"-define",
|
||||
"phash:colorspaces=sRGB,HCLp",
|
||||
"-metric",
|
||||
"PHASH",
|
||||
"-",
|
||||
"null:",
|
||||
]
|
||||
log.debug(
|
||||
"comparing images with pipeline {} | {}", convert_cmd, compare_cmd
|
||||
)
|
||||
convert_proc = subprocess.Popen(
|
||||
convert_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
close_fds=not is_windows,
|
||||
)
|
||||
compare_proc = subprocess.Popen(
|
||||
compare_cmd,
|
||||
stdin=convert_proc.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
close_fds=not is_windows,
|
||||
)
|
||||
|
||||
# Check the convert output. We're not interested in the
|
||||
# standard output; that gets piped to the next stage.
|
||||
convert_proc.stdout.close()
|
||||
convert_stderr = convert_proc.stderr.read()
|
||||
convert_proc.stderr.close()
|
||||
convert_proc.wait()
|
||||
if convert_proc.returncode:
|
||||
log.debug(
|
||||
"ImageMagick convert failed with status {}: {!r}",
|
||||
convert_proc.returncode,
|
||||
convert_stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# Check the compare output.
|
||||
stdout, stderr = compare_proc.communicate()
|
||||
if compare_proc.returncode:
|
||||
if compare_proc.returncode != 1:
|
||||
log.debug(
|
||||
"ImageMagick compare failed: {0}, {1}",
|
||||
displayable_path(im2),
|
||||
displayable_path(im1),
|
||||
)
|
||||
return None
|
||||
out_str = stderr
|
||||
else:
|
||||
out_str = stdout
|
||||
|
||||
try:
|
||||
phash_diff = float(out_str)
|
||||
except ValueError:
|
||||
log.debug("IM output is not a number: {0!r}", out_str)
|
||||
return None
|
||||
|
||||
log.debug("ImageMagick compare score: {0}", phash_diff)
|
||||
return phash_diff <= compare_threshold
|
||||
|
||||
@property
|
||||
def can_write_metadata(self):
|
||||
return True
|
||||
|
||||
def write_metadata(self, file, metadata):
|
||||
assignments = list(
|
||||
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
|
||||
)
|
||||
command = self.convert_cmd + [file, *assignments, file]
|
||||
|
||||
util.command_output(command)
|
||||
|
||||
|
||||
BACKEND_CONVERT_IMAGE_FORMAT = {
|
||||
PIL: pil_convert_format,
|
||||
IMAGEMAGICK: im_convert_format,
|
||||
}
|
||||
class PILBackend(LocalBackend):
|
||||
NAME = "PIL"
|
||||
|
||||
@classmethod
|
||||
def version(cls):
|
||||
try:
|
||||
__import__("PIL", fromlist=["Image"])
|
||||
except ImportError:
|
||||
raise LocalBackendNotAvailableError()
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a wrapper around PIL for local image operations.
|
||||
|
||||
If PIL is not available, raise an Exception.
|
||||
"""
|
||||
self.version()
|
||||
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
):
|
||||
"""Resize using Python Imaging Library (PIL). Return the output path
|
||||
of resized image.
|
||||
"""
|
||||
if not path_out:
|
||||
path_out = get_temp_filename(__name__, "resize_PIL_", path_in)
|
||||
|
||||
from PIL import Image
|
||||
|
||||
log.debug(
|
||||
"artresizer: PIL resizing {0} to {1}",
|
||||
displayable_path(path_in),
|
||||
displayable_path(path_out),
|
||||
)
|
||||
|
||||
try:
|
||||
im = Image.open(syspath(path_in))
|
||||
size = maxwidth, maxwidth
|
||||
im.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
|
||||
if quality == 0:
|
||||
# Use PIL's default quality.
|
||||
quality = -1
|
||||
|
||||
# progressive=False only affects JPEGs and is the default,
|
||||
# but we include it here for explicitness.
|
||||
im.save(os.fsdecode(path_out), quality=quality, progressive=False)
|
||||
|
||||
if max_filesize > 0:
|
||||
# If maximum filesize is set, we attempt to lower the quality
|
||||
# of jpeg conversion by a proportional amount, up to 3 attempts
|
||||
# First, set the maximum quality to either provided, or 95
|
||||
if quality > 0:
|
||||
lower_qual = quality
|
||||
else:
|
||||
lower_qual = 95
|
||||
for i in range(5):
|
||||
# 5 attempts is an arbitrary choice
|
||||
filesize = os.stat(syspath(path_out)).st_size
|
||||
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
|
||||
if filesize <= max_filesize:
|
||||
return path_out
|
||||
# The relationship between filesize & quality will be
|
||||
# image dependent.
|
||||
lower_qual -= 10
|
||||
# Restrict quality dropping below 10
|
||||
if lower_qual < 10:
|
||||
lower_qual = 10
|
||||
# Use optimize flag to improve filesize decrease
|
||||
im.save(
|
||||
os.fsdecode(path_out),
|
||||
quality=lower_qual,
|
||||
optimize=True,
|
||||
progressive=False,
|
||||
)
|
||||
log.warning(
|
||||
"PIL Failed to resize file to below {0}B", max_filesize
|
||||
)
|
||||
return path_out
|
||||
|
||||
else:
|
||||
return path_out
|
||||
except OSError:
|
||||
log.error(
|
||||
"PIL cannot create thumbnail for '{0}'",
|
||||
displayable_path(path_in),
|
||||
)
|
||||
return path_in
|
||||
|
||||
def get_size(self, path_in):
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
im = Image.open(syspath(path_in))
|
||||
return im.size
|
||||
except OSError as exc:
|
||||
log.error(
|
||||
"PIL could not read file {}: {}", displayable_path(path_in), exc
|
||||
)
|
||||
return None
|
||||
|
||||
def deinterlace(self, path_in, path_out=None):
|
||||
if not path_out:
|
||||
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
im = Image.open(syspath(path_in))
|
||||
im.save(os.fsdecode(path_out), progressive=False)
|
||||
return path_out
|
||||
except OSError:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
def get_format(self, filepath):
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
with Image.open(syspath(filepath)) as im:
|
||||
return im.format
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
):
|
||||
log.exception("failed to detect image format for {}", filepath)
|
||||
return None
|
||||
|
||||
def convert_format(self, source, target, deinterlaced):
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
with Image.open(syspath(source)) as im:
|
||||
im.save(os.fsdecode(target), progressive=not deinterlaced)
|
||||
return target
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
OSError,
|
||||
):
|
||||
log.exception("failed to convert image {} -> {}", source, target)
|
||||
return source
|
||||
|
||||
@property
|
||||
def can_compare(self):
|
||||
return False
|
||||
|
||||
def compare(self, im1, im2, compare_threshold):
|
||||
# It is an error to call this when ArtResizer.can_compare is not True.
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def can_write_metadata(self):
|
||||
return True
|
||||
|
||||
def write_metadata(self, file, metadata):
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
# FIXME: Detect and handle other file types (currently, the only user
|
||||
# is the thumbnails plugin, which generates PNG images).
|
||||
im = Image.open(syspath(file))
|
||||
meta = PngImagePlugin.PngInfo()
|
||||
for k, v in metadata.items():
|
||||
meta.add_text(k, v, 0)
|
||||
im.save(os.fsdecode(file), "PNG", pnginfo=meta)
|
||||
|
||||
|
||||
class Shareable(type):
|
||||
@@ -319,28 +524,36 @@ class Shareable(type):
|
||||
return cls._instance
|
||||
|
||||
|
||||
BACKEND_CLASSES = [
|
||||
IMBackend,
|
||||
PILBackend,
|
||||
]
|
||||
|
||||
|
||||
class ArtResizer(metaclass=Shareable):
|
||||
"""A singleton class that performs image resizes.
|
||||
"""
|
||||
"""A singleton class that performs image resizes."""
|
||||
|
||||
def __init__(self):
|
||||
"""Create a resizer object with an inferred method.
|
||||
"""
|
||||
self.method = self._check_method()
|
||||
log.debug("artresizer: method is {0}", self.method)
|
||||
self.can_compare = self._can_compare()
|
||||
"""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.
|
||||
for backend_cls in BACKEND_CLASSES:
|
||||
try:
|
||||
self.local_method = backend_cls()
|
||||
log.debug(f"artresizer: method is {self.local_method.NAME}")
|
||||
break
|
||||
except LocalBackendNotAvailableError:
|
||||
continue
|
||||
else:
|
||||
log.debug("artresizer: method is WEBPROXY")
|
||||
self.local_method = None
|
||||
|
||||
# Use ImageMagick's magick binary when it's available. If it's
|
||||
# not, fall back to the older, separate convert and identify
|
||||
# commands.
|
||||
if self.method[0] == IMAGEMAGICK:
|
||||
self.im_legacy = self.method[2]
|
||||
if self.im_legacy:
|
||||
self.im_convert_cmd = ['convert']
|
||||
self.im_identify_cmd = ['identify']
|
||||
else:
|
||||
self.im_convert_cmd = ['magick']
|
||||
self.im_identify_cmd = ['magick', 'identify']
|
||||
@property
|
||||
def method(self):
|
||||
if self.local:
|
||||
return self.local_method.NAME
|
||||
else:
|
||||
return "WEBPROXY"
|
||||
|
||||
def resize(
|
||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||
@@ -351,17 +564,26 @@ class ArtResizer(metaclass=Shareable):
|
||||
For WEBPROXY, returns `path_in` unmodified.
|
||||
"""
|
||||
if self.local:
|
||||
func = BACKEND_FUNCS[self.method[0]]
|
||||
return func(maxwidth, path_in, path_out,
|
||||
quality=quality, max_filesize=max_filesize)
|
||||
return self.local_method.resize(
|
||||
maxwidth,
|
||||
path_in,
|
||||
path_out,
|
||||
quality=quality,
|
||||
max_filesize=max_filesize,
|
||||
)
|
||||
else:
|
||||
# Handled by `proxy_url` already.
|
||||
return path_in
|
||||
|
||||
def deinterlace(self, path_in, path_out=None):
|
||||
"""Deinterlace an image.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
func = DEINTERLACE_FUNCS[self.method[0]]
|
||||
return func(path_in, path_out)
|
||||
return self.local_method.deinterlace(path_in, path_out)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
def proxy_url(self, maxwidth, url, quality=0):
|
||||
@@ -370,6 +592,7 @@ class ArtResizer(metaclass=Shareable):
|
||||
Otherwise, the URL is returned unmodified.
|
||||
"""
|
||||
if self.local:
|
||||
# Going to be handled by `resize()`.
|
||||
return url
|
||||
else:
|
||||
return resize_url(url, maxwidth, quality)
|
||||
@@ -379,7 +602,7 @@ class ArtResizer(metaclass=Shareable):
|
||||
"""A boolean indicating whether the resizing method is performed
|
||||
locally (i.e., PIL or ImageMagick).
|
||||
"""
|
||||
return self.method[0] in BACKEND_FUNCS
|
||||
return self.local_method is not None
|
||||
|
||||
def get_size(self, path_in):
|
||||
"""Return the size of an image file as an int couple (width, height)
|
||||
@@ -388,8 +611,10 @@ class ArtResizer(metaclass=Shareable):
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
func = BACKEND_GET_SIZE[self.method[0]]
|
||||
return func(path_in)
|
||||
return self.local_method.get_size(path_in)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
def get_format(self, path_in):
|
||||
"""Returns the format of the image as a string.
|
||||
@@ -397,8 +622,10 @@ class ArtResizer(metaclass=Shareable):
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
func = BACKEND_GET_FORMAT[self.method[0]]
|
||||
return func(path_in)
|
||||
return self.local_method.get_format(path_in)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return None
|
||||
|
||||
def reformat(self, path_in, new_format, deinterlaced=True):
|
||||
"""Converts image to desired format, updating its extension, but
|
||||
@@ -407,86 +634,66 @@ class ArtResizer(metaclass=Shareable):
|
||||
Only available locally.
|
||||
"""
|
||||
if not self.local:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return path_in
|
||||
|
||||
new_format = new_format.lower()
|
||||
# A nonexhaustive map of image "types" to extensions overrides
|
||||
new_format = {
|
||||
'jpeg': 'jpg',
|
||||
"jpeg": "jpg",
|
||||
}.get(new_format, new_format)
|
||||
|
||||
fname, ext = os.path.splitext(path_in)
|
||||
path_new = fname + b'.' + new_format.encode('utf8')
|
||||
func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]]
|
||||
path_new = fname + b"." + new_format.encode("utf8")
|
||||
|
||||
# allows the exception to propagate, while still making sure a changed
|
||||
# file path was removed
|
||||
result_path = path_in
|
||||
try:
|
||||
result_path = func(path_in, path_new, deinterlaced)
|
||||
result_path = self.local_method.convert_format(
|
||||
path_in, path_new, deinterlaced
|
||||
)
|
||||
finally:
|
||||
if result_path != path_in:
|
||||
os.unlink(path_in)
|
||||
return result_path
|
||||
|
||||
def _can_compare(self):
|
||||
@property
|
||||
def can_compare(self):
|
||||
"""A boolean indicating whether image comparison is available"""
|
||||
|
||||
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
|
||||
|
||||
@staticmethod
|
||||
def _check_method():
|
||||
"""Return a tuple indicating an available method and its version.
|
||||
|
||||
The result has at least two elements:
|
||||
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK.
|
||||
- The version.
|
||||
|
||||
If the method is IMAGEMAGICK, there is also a third element: a
|
||||
bool flag indicating whether to use the `magick` binary or
|
||||
legacy single-purpose executables (`convert`, `identify`, etc.)
|
||||
"""
|
||||
version = get_im_version()
|
||||
if version:
|
||||
version, legacy = version
|
||||
return IMAGEMAGICK, version, legacy
|
||||
|
||||
version = get_pil_version()
|
||||
if version:
|
||||
return PIL, version
|
||||
|
||||
return WEBPROXY, (0)
|
||||
|
||||
|
||||
def get_im_version():
|
||||
"""Get the ImageMagick version and legacy flag as a pair. Or return
|
||||
None if ImageMagick is not available.
|
||||
"""
|
||||
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
|
||||
cmd = cmd_name + ['--version']
|
||||
|
||||
try:
|
||||
out = util.command_output(cmd).stdout
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
log.debug('ImageMagick version check failed: {}', exc)
|
||||
if self.local:
|
||||
return self.local_method.can_compare
|
||||
else:
|
||||
if b'imagemagick' in out.lower():
|
||||
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
|
||||
match = re.search(pattern, out)
|
||||
if match:
|
||||
version = (int(match.group(1)),
|
||||
int(match.group(2)),
|
||||
int(match.group(3)))
|
||||
return version, legacy
|
||||
return False
|
||||
|
||||
return None
|
||||
def compare(self, im1, im2, compare_threshold):
|
||||
"""Return a boolean indicating whether two images are similar.
|
||||
|
||||
Only available locally.
|
||||
"""
|
||||
if self.local:
|
||||
return self.local_method.compare(im1, im2, compare_threshold)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
return None
|
||||
|
||||
def get_pil_version():
|
||||
"""Get the PIL/Pillow version, or None if it is unavailable.
|
||||
"""
|
||||
try:
|
||||
__import__('PIL', fromlist=['Image'])
|
||||
return (0,)
|
||||
except ImportError:
|
||||
return None
|
||||
@property
|
||||
def can_write_metadata(self):
|
||||
"""A boolean indicating whether writing image metadata is supported."""
|
||||
|
||||
if self.local:
|
||||
return self.local_method.can_write_metadata
|
||||
else:
|
||||
return False
|
||||
|
||||
def write_metadata(self, file, metadata):
|
||||
"""Write key-value metadata to the image file.
|
||||
|
||||
Only available locally. Currently, expects the image to be a PNG file.
|
||||
"""
|
||||
if self.local:
|
||||
self.local_method.write_metadata(file, metadata)
|
||||
else:
|
||||
# FIXME: Should probably issue a warning?
|
||||
pass
|
||||
|
||||
+60
-41
@@ -6,23 +6,24 @@ asyncore.
|
||||
Bluelet: easy concurrency without all the messy parallelism.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import select
|
||||
import sys
|
||||
import types
|
||||
import errno
|
||||
import traceback
|
||||
import time
|
||||
import collections
|
||||
|
||||
import errno
|
||||
import select
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
|
||||
# Basic events used for thread scheduling.
|
||||
|
||||
|
||||
class Event:
|
||||
"""Just a base class identifying Bluelet events. An event is an
|
||||
object yielded from a Bluelet thread coroutine to suspend operation
|
||||
and communicate with the scheduler.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -31,6 +32,7 @@ class WaitableEvent(Event):
|
||||
waited for using a select() call. That is, it's an event with an
|
||||
associated file descriptor.
|
||||
"""
|
||||
|
||||
def waitables(self):
|
||||
"""Return "waitable" objects to pass to select(). Should return
|
||||
three iterables for input readiness, output readiness, and
|
||||
@@ -48,18 +50,21 @@ class WaitableEvent(Event):
|
||||
|
||||
class ValueEvent(Event):
|
||||
"""An event that does nothing but return a fixed value."""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
class ExceptionEvent(Event):
|
||||
"""Raise an exception at the yield point. Used internally."""
|
||||
|
||||
def __init__(self, exc_info):
|
||||
self.exc_info = exc_info
|
||||
|
||||
|
||||
class SpawnEvent(Event):
|
||||
"""Add a new coroutine thread to the scheduler."""
|
||||
|
||||
def __init__(self, coro):
|
||||
self.spawned = coro
|
||||
|
||||
@@ -68,12 +73,14 @@ class JoinEvent(Event):
|
||||
"""Suspend the thread until the specified child thread has
|
||||
completed.
|
||||
"""
|
||||
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
|
||||
|
||||
class KillEvent(Event):
|
||||
"""Unschedule a child thread."""
|
||||
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
|
||||
@@ -83,6 +90,7 @@ class DelegationEvent(Event):
|
||||
once the child thread finished, return control to the parent
|
||||
thread.
|
||||
"""
|
||||
|
||||
def __init__(self, coro):
|
||||
self.spawned = coro
|
||||
|
||||
@@ -91,13 +99,14 @@ class ReturnEvent(Event):
|
||||
"""Return a value the current thread's delegator at the point of
|
||||
delegation. Ends the current (delegate) thread.
|
||||
"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
class SleepEvent(WaitableEvent):
|
||||
"""Suspend the thread for a given duration.
|
||||
"""
|
||||
"""Suspend the thread for a given duration."""
|
||||
|
||||
def __init__(self, duration):
|
||||
self.wakeup_time = time.time() + duration
|
||||
|
||||
@@ -107,6 +116,7 @@ class SleepEvent(WaitableEvent):
|
||||
|
||||
class ReadEvent(WaitableEvent):
|
||||
"""Reads from a file-like object."""
|
||||
|
||||
def __init__(self, fd, bufsize):
|
||||
self.fd = fd
|
||||
self.bufsize = bufsize
|
||||
@@ -120,6 +130,7 @@ class ReadEvent(WaitableEvent):
|
||||
|
||||
class WriteEvent(WaitableEvent):
|
||||
"""Writes to a file-like object."""
|
||||
|
||||
def __init__(self, fd, data):
|
||||
self.fd = fd
|
||||
self.data = data
|
||||
@@ -133,6 +144,7 @@ class WriteEvent(WaitableEvent):
|
||||
|
||||
# Core logic for executing and scheduling threads.
|
||||
|
||||
|
||||
def _event_select(events):
|
||||
"""Perform a select() over all the Events provided, returning the
|
||||
ones ready to be fired. Only WaitableEvents (including SleepEvents)
|
||||
@@ -154,11 +166,11 @@ def _event_select(events):
|
||||
wlist += w
|
||||
xlist += x
|
||||
for waitable in r:
|
||||
waitable_to_event[('r', waitable)] = event
|
||||
waitable_to_event[("r", waitable)] = event
|
||||
for waitable in w:
|
||||
waitable_to_event[('w', waitable)] = event
|
||||
waitable_to_event[("w", waitable)] = event
|
||||
for waitable in x:
|
||||
waitable_to_event[('x', waitable)] = event
|
||||
waitable_to_event[("x", waitable)] = event
|
||||
|
||||
# If we have a any sleeping threads, determine how long to sleep.
|
||||
if earliest_wakeup:
|
||||
@@ -177,11 +189,11 @@ def _event_select(events):
|
||||
# Gather ready events corresponding to the ready waitables.
|
||||
ready_events = set()
|
||||
for ready in rready:
|
||||
ready_events.add(waitable_to_event[('r', ready)])
|
||||
ready_events.add(waitable_to_event[("r", ready)])
|
||||
for ready in wready:
|
||||
ready_events.add(waitable_to_event[('w', ready)])
|
||||
ready_events.add(waitable_to_event[("w", ready)])
|
||||
for ready in xready:
|
||||
ready_events.add(waitable_to_event[('x', ready)])
|
||||
ready_events.add(waitable_to_event[("x", ready)])
|
||||
|
||||
# Gather any finished sleeps.
|
||||
for event in events:
|
||||
@@ -207,6 +219,7 @@ class Delegated(Event):
|
||||
"""Placeholder indicating that a thread has delegated execution to a
|
||||
different thread.
|
||||
"""
|
||||
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
|
||||
@@ -277,8 +290,7 @@ def run(root_coro):
|
||||
threads[coro] = next_event
|
||||
|
||||
def kill_thread(coro):
|
||||
"""Unschedule this thread and its (recursive) delegates.
|
||||
"""
|
||||
"""Unschedule this thread and its (recursive) delegates."""
|
||||
# Collect all coroutines in the delegation stack.
|
||||
coros = [coro]
|
||||
while isinstance(threads[coro], Delegated):
|
||||
@@ -338,12 +350,16 @@ def run(root_coro):
|
||||
try:
|
||||
value = event.fire()
|
||||
except OSError as exc:
|
||||
if isinstance(exc.args, tuple) and \
|
||||
exc.args[0] == errno.EPIPE:
|
||||
if (
|
||||
isinstance(exc.args, tuple)
|
||||
and exc.args[0] == errno.EPIPE
|
||||
):
|
||||
# Broken pipe. Remote host disconnected.
|
||||
pass
|
||||
elif isinstance(exc.args, tuple) and \
|
||||
exc.args[0] == errno.ECONNRESET:
|
||||
elif (
|
||||
isinstance(exc.args, tuple)
|
||||
and exc.args[0] == errno.ECONNRESET
|
||||
):
|
||||
# Connection was reset by peer.
|
||||
pass
|
||||
else:
|
||||
@@ -382,16 +398,16 @@ def run(root_coro):
|
||||
|
||||
# Sockets and their associated events.
|
||||
|
||||
|
||||
class SocketClosedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Listener:
|
||||
"""A socket wrapper object for listening sockets.
|
||||
"""
|
||||
"""A socket wrapper object for listening sockets."""
|
||||
|
||||
def __init__(self, host, port):
|
||||
"""Create a listening socket on the given hostname and port.
|
||||
"""
|
||||
"""Create a listening socket on the given hostname and port."""
|
||||
self._closed = False
|
||||
self.host = host
|
||||
self.port = port
|
||||
@@ -410,19 +426,18 @@ class Listener:
|
||||
return AcceptEvent(self)
|
||||
|
||||
def close(self):
|
||||
"""Immediately close the listening socket. (Not an event.)
|
||||
"""
|
||||
"""Immediately close the listening socket. (Not an event.)"""
|
||||
self._closed = True
|
||||
self.sock.close()
|
||||
|
||||
|
||||
class Connection:
|
||||
"""A socket wrapper object for connected sockets.
|
||||
"""
|
||||
"""A socket wrapper object for connected sockets."""
|
||||
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self._buf = b''
|
||||
self._buf = b""
|
||||
self._closed = False
|
||||
|
||||
def close(self):
|
||||
@@ -473,7 +488,7 @@ class Connection:
|
||||
self._buf += data
|
||||
else:
|
||||
line = self._buf
|
||||
self._buf = b''
|
||||
self._buf = b""
|
||||
yield ReturnEvent(line)
|
||||
break
|
||||
|
||||
@@ -482,6 +497,7 @@ class AcceptEvent(WaitableEvent):
|
||||
"""An event for Listener objects (listening sockets) that suspends
|
||||
execution until the socket gets a connection.
|
||||
"""
|
||||
|
||||
def __init__(self, listener):
|
||||
self.listener = listener
|
||||
|
||||
@@ -497,6 +513,7 @@ class ReceiveEvent(WaitableEvent):
|
||||
"""An event for Connection objects (connected sockets) for
|
||||
asynchronously reading data.
|
||||
"""
|
||||
|
||||
def __init__(self, conn, bufsize):
|
||||
self.conn = conn
|
||||
self.bufsize = bufsize
|
||||
@@ -512,6 +529,7 @@ class SendEvent(WaitableEvent):
|
||||
"""An event for Connection objects (connected sockets) for
|
||||
asynchronously writing data.
|
||||
"""
|
||||
|
||||
def __init__(self, conn, data, sendall=False):
|
||||
self.conn = conn
|
||||
self.data = data
|
||||
@@ -530,9 +548,9 @@ class SendEvent(WaitableEvent):
|
||||
# Public interface for threads; each returns an event object that
|
||||
# can immediately be "yield"ed.
|
||||
|
||||
|
||||
def null():
|
||||
"""Event: yield to the scheduler without doing anything special.
|
||||
"""
|
||||
"""Event: yield to the scheduler without doing anything special."""
|
||||
return ValueEvent(None)
|
||||
|
||||
|
||||
@@ -541,7 +559,7 @@ def spawn(coro):
|
||||
and child coroutines run concurrently.
|
||||
"""
|
||||
if not isinstance(coro, types.GeneratorType):
|
||||
raise ValueError('%s is not a coroutine' % coro)
|
||||
raise ValueError("%s is not a coroutine" % coro)
|
||||
return SpawnEvent(coro)
|
||||
|
||||
|
||||
@@ -551,7 +569,7 @@ def call(coro):
|
||||
returns a value using end(), then this event returns that value.
|
||||
"""
|
||||
if not isinstance(coro, types.GeneratorType):
|
||||
raise ValueError('%s is not a coroutine' % coro)
|
||||
raise ValueError("%s is not a coroutine" % coro)
|
||||
return DelegationEvent(coro)
|
||||
|
||||
|
||||
@@ -573,7 +591,8 @@ def read(fd, bufsize=None):
|
||||
if not data:
|
||||
break
|
||||
buf.append(data)
|
||||
yield ReturnEvent(''.join(buf))
|
||||
yield ReturnEvent("".join(buf))
|
||||
|
||||
return DelegationEvent(reader())
|
||||
|
||||
else:
|
||||
@@ -595,8 +614,7 @@ def connect(host, port):
|
||||
|
||||
|
||||
def sleep(duration):
|
||||
"""Event: suspend the thread for ``duration`` seconds.
|
||||
"""
|
||||
"""Event: suspend the thread for ``duration`` seconds."""
|
||||
return SleepEvent(duration)
|
||||
|
||||
|
||||
@@ -608,19 +626,20 @@ def join(coro):
|
||||
|
||||
|
||||
def kill(coro):
|
||||
"""Halt the execution of a different `spawn`ed thread.
|
||||
"""
|
||||
"""Halt the execution of a different `spawn`ed thread."""
|
||||
return KillEvent(coro)
|
||||
|
||||
|
||||
# Convenience function for running socket servers.
|
||||
|
||||
|
||||
def server(host, port, func):
|
||||
"""A coroutine that runs a network server. Host and port specify the
|
||||
listening address. func should be a coroutine that takes a single
|
||||
parameter, a Connection object. The coroutine is invoked for every
|
||||
incoming connection on the listening socket.
|
||||
"""
|
||||
|
||||
def handler(conn):
|
||||
try:
|
||||
yield func(conn)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2016-2019, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
import confuse
|
||||
|
||||
import warnings
|
||||
warnings.warn("beets.util.confit is deprecated; use confuse instead")
|
||||
|
||||
# Import everything from the confuse module into this module.
|
||||
for key, value in confuse.__dict__.items():
|
||||
if key not in ['__name__']:
|
||||
globals()[key] = value
|
||||
|
||||
|
||||
# Cleanup namespace.
|
||||
del key, value, warnings, confuse
|
||||
@@ -20,6 +20,7 @@ class OrderedEnum(Enum):
|
||||
"""
|
||||
An Enum subclass that allows comparison of members.
|
||||
"""
|
||||
|
||||
def __ge__(self, other):
|
||||
if self.__class__ is other.__class__:
|
||||
return self.value >= other.value
|
||||
|
||||
+105
-116
@@ -27,22 +27,21 @@ engine like Jinja2 or Mustache.
|
||||
"""
|
||||
|
||||
|
||||
import re
|
||||
import ast
|
||||
import dis
|
||||
import types
|
||||
import sys
|
||||
import functools
|
||||
import re
|
||||
import types
|
||||
|
||||
SYMBOL_DELIM = '$'
|
||||
FUNC_DELIM = '%'
|
||||
GROUP_OPEN = '{'
|
||||
GROUP_CLOSE = '}'
|
||||
ARG_SEP = ','
|
||||
ESCAPE_CHAR = '$'
|
||||
SYMBOL_DELIM = "$"
|
||||
FUNC_DELIM = "%"
|
||||
GROUP_OPEN = "{"
|
||||
GROUP_CLOSE = "}"
|
||||
ARG_SEP = ","
|
||||
ESCAPE_CHAR = "$"
|
||||
|
||||
VARIABLE_PREFIX = '__var_'
|
||||
FUNCTION_PREFIX = '__func_'
|
||||
VARIABLE_PREFIX = "__var_"
|
||||
FUNCTION_PREFIX = "__func_"
|
||||
|
||||
|
||||
class Environment:
|
||||
@@ -57,10 +56,6 @@ class Environment:
|
||||
|
||||
# Code generation helpers.
|
||||
|
||||
def ex_lvalue(name):
|
||||
"""A variable load expression."""
|
||||
return ast.Name(name, ast.Store())
|
||||
|
||||
|
||||
def ex_rvalue(name):
|
||||
"""A variable store expression."""
|
||||
@@ -74,15 +69,6 @@ def ex_literal(val):
|
||||
return ast.Constant(val)
|
||||
|
||||
|
||||
def ex_varassign(name, expr):
|
||||
"""Assign an expression into a single variable. The expression may
|
||||
either be an `ast.expr` object or a value to be used as a literal.
|
||||
"""
|
||||
if not isinstance(expr, ast.expr):
|
||||
expr = ex_literal(expr)
|
||||
return ast.Assign([ex_lvalue(name)], expr)
|
||||
|
||||
|
||||
def ex_call(func, args):
|
||||
"""A function-call expression with only positional parameters. The
|
||||
function may be an expression or the name of a function. Each
|
||||
@@ -99,19 +85,18 @@ def ex_call(func, args):
|
||||
return ast.Call(func, args, [])
|
||||
|
||||
|
||||
def compile_func(arg_names, statements, name='_the_func', debug=False):
|
||||
def compile_func(arg_names, statements, name="_the_func", debug=False):
|
||||
"""Compile a list of statements as the body of a function and return
|
||||
the resulting Python function. If `debug`, then print out the
|
||||
bytecode of the compiled function.
|
||||
"""
|
||||
args_fields = {
|
||||
'args': [ast.arg(arg=n, annotation=None) for n in arg_names],
|
||||
'kwonlyargs': [],
|
||||
'kw_defaults': [],
|
||||
'defaults': [ex_literal(None) for _ in arg_names],
|
||||
"args": [ast.arg(arg=n, annotation=None) for n in arg_names],
|
||||
"kwonlyargs": [],
|
||||
"kw_defaults": [],
|
||||
"defaults": [ex_literal(None) for _ in arg_names],
|
||||
}
|
||||
if 'posonlyargs' in ast.arguments._fields: # Added in Python 3.8.
|
||||
args_fields['posonlyargs'] = []
|
||||
args_fields["posonlyargs"] = []
|
||||
args = ast.arguments(**args_fields)
|
||||
|
||||
func_def = ast.FunctionDef(
|
||||
@@ -123,14 +108,11 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
|
||||
|
||||
# The ast.Module signature changed in 3.8 to accept a list of types to
|
||||
# ignore.
|
||||
if sys.version_info >= (3, 8):
|
||||
mod = ast.Module([func_def], [])
|
||||
else:
|
||||
mod = ast.Module([func_def])
|
||||
mod = ast.Module([func_def], [])
|
||||
|
||||
ast.fix_missing_locations(mod)
|
||||
|
||||
prog = compile(mod, '<generated>', 'exec')
|
||||
prog = compile(mod, "<generated>", "exec")
|
||||
|
||||
# Debug: show bytecode.
|
||||
if debug:
|
||||
@@ -146,6 +128,7 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
|
||||
|
||||
# AST nodes for the template language.
|
||||
|
||||
|
||||
class Symbol:
|
||||
"""A variable-substitution symbol in a template."""
|
||||
|
||||
@@ -154,7 +137,7 @@ class Symbol:
|
||||
self.original = original
|
||||
|
||||
def __repr__(self):
|
||||
return 'Symbol(%s)' % repr(self.ident)
|
||||
return "Symbol(%s)" % repr(self.ident)
|
||||
|
||||
def evaluate(self, env):
|
||||
"""Evaluate the symbol in the environment, returning a Unicode
|
||||
@@ -183,8 +166,9 @@ class Call:
|
||||
self.original = original
|
||||
|
||||
def __repr__(self):
|
||||
return 'Call({}, {}, {})'.format(repr(self.ident), repr(self.args),
|
||||
repr(self.original))
|
||||
return "Call({}, {}, {})".format(
|
||||
repr(self.ident), repr(self.args), repr(self.original)
|
||||
)
|
||||
|
||||
def evaluate(self, env):
|
||||
"""Evaluate the function call in the environment, returning a
|
||||
@@ -197,7 +181,7 @@ class Call:
|
||||
except Exception as exc:
|
||||
# Function raised exception! Maybe inlining the name of
|
||||
# the exception will help debug.
|
||||
return '<%s>' % str(exc)
|
||||
return "<%s>" % str(exc)
|
||||
return str(out)
|
||||
else:
|
||||
return self.original
|
||||
@@ -215,21 +199,22 @@ class Call:
|
||||
|
||||
# Create a subexpression that joins the result components of
|
||||
# the arguments.
|
||||
arg_exprs.append(ex_call(
|
||||
ast.Attribute(ex_literal(''), 'join', ast.Load()),
|
||||
[ex_call(
|
||||
'map',
|
||||
arg_exprs.append(
|
||||
ex_call(
|
||||
ast.Attribute(ex_literal(""), "join", ast.Load()),
|
||||
[
|
||||
ex_rvalue(str.__name__),
|
||||
ast.List(subexprs, ast.Load()),
|
||||
]
|
||||
)],
|
||||
))
|
||||
ex_call(
|
||||
"map",
|
||||
[
|
||||
ex_rvalue(str.__name__),
|
||||
ast.List(subexprs, ast.Load()),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
subexpr_call = ex_call(
|
||||
FUNCTION_PREFIX + self.ident,
|
||||
arg_exprs
|
||||
)
|
||||
subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs)
|
||||
return [subexpr_call], varnames, funcnames
|
||||
|
||||
|
||||
@@ -242,7 +227,7 @@ class Expression:
|
||||
self.parts = parts
|
||||
|
||||
def __repr__(self):
|
||||
return 'Expression(%s)' % (repr(self.parts))
|
||||
return "Expression(%s)" % (repr(self.parts))
|
||||
|
||||
def evaluate(self, env):
|
||||
"""Evaluate the entire expression in the environment, returning
|
||||
@@ -254,7 +239,7 @@ class Expression:
|
||||
out.append(part)
|
||||
else:
|
||||
out.append(part.evaluate(env))
|
||||
return ''.join(map(str, out))
|
||||
return "".join(map(str, out))
|
||||
|
||||
def translate(self):
|
||||
"""Compile the expression to a list of Python AST expressions, a
|
||||
@@ -276,6 +261,7 @@ class Expression:
|
||||
|
||||
# Parser.
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
pass
|
||||
|
||||
@@ -295,7 +281,7 @@ class Parser:
|
||||
"""
|
||||
|
||||
def __init__(self, string, in_argument=False):
|
||||
""" Create a new parser.
|
||||
"""Create a new parser.
|
||||
:param in_arguments: boolean that indicates the parser is to be
|
||||
used for parsing function arguments, ie. considering commas
|
||||
(`ARG_SEP`) a special character
|
||||
@@ -306,10 +292,16 @@ class Parser:
|
||||
self.parts = []
|
||||
|
||||
# Common parsing resources.
|
||||
special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE,
|
||||
ESCAPE_CHAR)
|
||||
special_char_re = re.compile(r'[%s]|\Z' %
|
||||
''.join(re.escape(c) for c in special_chars))
|
||||
special_chars = (
|
||||
SYMBOL_DELIM,
|
||||
FUNC_DELIM,
|
||||
GROUP_OPEN,
|
||||
GROUP_CLOSE,
|
||||
ESCAPE_CHAR,
|
||||
)
|
||||
special_char_re = re.compile(
|
||||
r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars)
|
||||
)
|
||||
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
|
||||
terminator_chars = (GROUP_CLOSE,)
|
||||
|
||||
@@ -326,9 +318,10 @@ class Parser:
|
||||
if self.in_argument:
|
||||
extra_special_chars = (ARG_SEP,)
|
||||
special_char_re = re.compile(
|
||||
r'[%s]|\Z' % ''.join(
|
||||
re.escape(c) for c in
|
||||
self.special_chars + extra_special_chars
|
||||
r"[%s]|\Z"
|
||||
% "".join(
|
||||
re.escape(c)
|
||||
for c in self.special_chars + extra_special_chars
|
||||
)
|
||||
)
|
||||
|
||||
@@ -341,10 +334,10 @@ class Parser:
|
||||
# A non-special character. Skip to the next special
|
||||
# character, treating the interstice as literal text.
|
||||
next_pos = (
|
||||
special_char_re.search(
|
||||
self.string[self.pos:]).start() + self.pos
|
||||
special_char_re.search(self.string[self.pos :]).start()
|
||||
+ self.pos
|
||||
)
|
||||
text_parts.append(self.string[self.pos:next_pos])
|
||||
text_parts.append(self.string[self.pos : next_pos])
|
||||
self.pos = next_pos
|
||||
continue
|
||||
|
||||
@@ -358,8 +351,9 @@ class Parser:
|
||||
break
|
||||
|
||||
next_char = self.string[self.pos + 1]
|
||||
if char == ESCAPE_CHAR and next_char in (self.escapable_chars +
|
||||
extra_special_chars):
|
||||
if char == ESCAPE_CHAR and next_char in (
|
||||
self.escapable_chars + extra_special_chars
|
||||
):
|
||||
# An escaped special character ($$, $}, etc.). Note that
|
||||
# ${ is not an escape sequence: this is ambiguous with
|
||||
# the start of a symbol and it's not necessary (just
|
||||
@@ -370,7 +364,7 @@ class Parser:
|
||||
|
||||
# Shift all characters collected so far into a single string.
|
||||
if text_parts:
|
||||
self.parts.append(''.join(text_parts))
|
||||
self.parts.append("".join(text_parts))
|
||||
text_parts = []
|
||||
|
||||
if char == SYMBOL_DELIM:
|
||||
@@ -392,7 +386,7 @@ class Parser:
|
||||
|
||||
# If any parsed characters remain, shift them into a string.
|
||||
if text_parts:
|
||||
self.parts.append(''.join(text_parts))
|
||||
self.parts.append("".join(text_parts))
|
||||
|
||||
def parse_symbol(self):
|
||||
"""Parse a variable reference (like ``$foo`` or ``${foo}``)
|
||||
@@ -419,21 +413,23 @@ class Parser:
|
||||
closer = self.string.find(GROUP_CLOSE, self.pos)
|
||||
if closer == -1 or closer == self.pos:
|
||||
# No closing brace found or identifier is empty.
|
||||
self.parts.append(self.string[start_pos:self.pos])
|
||||
self.parts.append(self.string[start_pos : self.pos])
|
||||
else:
|
||||
# Closer found.
|
||||
ident = self.string[self.pos:closer]
|
||||
ident = self.string[self.pos : closer]
|
||||
self.pos = closer + 1
|
||||
self.parts.append(Symbol(ident,
|
||||
self.string[start_pos:self.pos]))
|
||||
self.parts.append(
|
||||
Symbol(ident, self.string[start_pos : self.pos])
|
||||
)
|
||||
|
||||
else:
|
||||
# A bare-word symbol.
|
||||
ident = self._parse_ident()
|
||||
if ident:
|
||||
# Found a real symbol.
|
||||
self.parts.append(Symbol(ident,
|
||||
self.string[start_pos:self.pos]))
|
||||
self.parts.append(
|
||||
Symbol(ident, self.string[start_pos : self.pos])
|
||||
)
|
||||
else:
|
||||
# A standalone $.
|
||||
self.parts.append(SYMBOL_DELIM)
|
||||
@@ -457,25 +453,24 @@ class Parser:
|
||||
|
||||
if self.pos >= len(self.string):
|
||||
# Identifier terminates string.
|
||||
self.parts.append(self.string[start_pos:self.pos])
|
||||
self.parts.append(self.string[start_pos : self.pos])
|
||||
return
|
||||
|
||||
if self.string[self.pos] != GROUP_OPEN:
|
||||
# Argument list not opened.
|
||||
self.parts.append(self.string[start_pos:self.pos])
|
||||
self.parts.append(self.string[start_pos : self.pos])
|
||||
return
|
||||
|
||||
# Skip past opening brace and try to parse an argument list.
|
||||
self.pos += 1
|
||||
args = self.parse_argument_list()
|
||||
if self.pos >= len(self.string) or \
|
||||
self.string[self.pos] != GROUP_CLOSE:
|
||||
if self.pos >= len(self.string) or self.string[self.pos] != GROUP_CLOSE:
|
||||
# Arguments unclosed.
|
||||
self.parts.append(self.string[start_pos:self.pos])
|
||||
self.parts.append(self.string[start_pos : self.pos])
|
||||
return
|
||||
|
||||
self.pos += 1 # Move past closing brace.
|
||||
self.parts.append(Call(ident, args, self.string[start_pos:self.pos]))
|
||||
self.parts.append(Call(ident, args, self.string[start_pos : self.pos]))
|
||||
|
||||
def parse_argument_list(self):
|
||||
"""Parse a list of arguments starting at ``pos``, returning a
|
||||
@@ -487,15 +482,17 @@ class Parser:
|
||||
expressions = []
|
||||
|
||||
while self.pos < len(self.string):
|
||||
subparser = Parser(self.string[self.pos:], in_argument=True)
|
||||
subparser = Parser(self.string[self.pos :], in_argument=True)
|
||||
subparser.parse_expression()
|
||||
|
||||
# Extract and advance past the parsed expression.
|
||||
expressions.append(Expression(subparser.parts))
|
||||
self.pos += subparser.pos
|
||||
|
||||
if self.pos >= len(self.string) or \
|
||||
self.string[self.pos] == GROUP_CLOSE:
|
||||
if (
|
||||
self.pos >= len(self.string)
|
||||
or self.string[self.pos] == GROUP_CLOSE
|
||||
):
|
||||
# Argument list terminated by EOF or closing brace.
|
||||
break
|
||||
|
||||
@@ -510,8 +507,8 @@ class Parser:
|
||||
"""Parse an identifier and return it (possibly an empty string).
|
||||
Updates ``pos``.
|
||||
"""
|
||||
remainder = self.string[self.pos:]
|
||||
ident = re.match(r'\w*', remainder).group(0)
|
||||
remainder = self.string[self.pos :]
|
||||
ident = re.match(r"\w*", remainder).group(0)
|
||||
self.pos += len(ident)
|
||||
return ident
|
||||
|
||||
@@ -524,32 +521,20 @@ def _parse(template):
|
||||
parser.parse_expression()
|
||||
|
||||
parts = parser.parts
|
||||
remainder = parser.string[parser.pos:]
|
||||
remainder = parser.string[parser.pos :]
|
||||
if remainder:
|
||||
parts.append(remainder)
|
||||
return Expression(parts)
|
||||
|
||||
|
||||
def cached(func):
|
||||
"""Like the `functools.lru_cache` decorator, but works (as a no-op)
|
||||
on Python < 3.2.
|
||||
"""
|
||||
if hasattr(functools, 'lru_cache'):
|
||||
return functools.lru_cache(maxsize=128)(func)
|
||||
else:
|
||||
# Do nothing when lru_cache is not available.
|
||||
return func
|
||||
|
||||
|
||||
@cached
|
||||
@functools.lru_cache(maxsize=128)
|
||||
def template(fmt):
|
||||
return Template(fmt)
|
||||
|
||||
|
||||
# External interface.
|
||||
class Template:
|
||||
"""A string template, including text, Symbols, and Calls.
|
||||
"""
|
||||
"""A string template, including text, Symbols, and Calls."""
|
||||
|
||||
def __init__(self, template):
|
||||
self.expr = _parse(template)
|
||||
@@ -568,8 +553,7 @@ class Template:
|
||||
return self.expr.evaluate(Environment(values, functions))
|
||||
|
||||
def substitute(self, values={}, functions={}):
|
||||
"""Evaluate the template given the values and functions.
|
||||
"""
|
||||
"""Evaluate the template given the values and functions."""
|
||||
try:
|
||||
res = self.compiled(values, functions)
|
||||
except Exception: # Handle any exceptions thrown by compiled version.
|
||||
@@ -599,24 +583,29 @@ class Template:
|
||||
for funcname in funcnames:
|
||||
args[FUNCTION_PREFIX + funcname] = functions[funcname]
|
||||
parts = func(**args)
|
||||
return ''.join(parts)
|
||||
return "".join(parts)
|
||||
|
||||
return wrapper_func
|
||||
|
||||
|
||||
# Performance tests.
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
import timeit
|
||||
_tmpl = Template('foo $bar %baz{foozle $bar barzle} $bar')
|
||||
_vars = {'bar': 'qux'}
|
||||
_funcs = {'baz': str.upper}
|
||||
interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)',
|
||||
'from __main__ import _tmpl, _vars, _funcs',
|
||||
number=10000)
|
||||
|
||||
_tmpl = Template("foo $bar %baz{foozle $bar barzle} $bar")
|
||||
_vars = {"bar": "qux"}
|
||||
_funcs = {"baz": str.upper}
|
||||
interp_time = timeit.timeit(
|
||||
"_tmpl.interpret(_vars, _funcs)",
|
||||
"from __main__ import _tmpl, _vars, _funcs",
|
||||
number=10000,
|
||||
)
|
||||
print(interp_time)
|
||||
comp_time = timeit.timeit('_tmpl.substitute(_vars, _funcs)',
|
||||
'from __main__ import _tmpl, _vars, _funcs',
|
||||
number=10000)
|
||||
comp_time = timeit.timeit(
|
||||
"_tmpl.substitute(_vars, _funcs)",
|
||||
"from __main__ import _tmpl, _vars, _funcs",
|
||||
number=10000,
|
||||
)
|
||||
print(comp_time)
|
||||
print('Speedup:', interp_time / comp_time)
|
||||
print("Speedup:", interp_time / comp_time)
|
||||
|
||||
+30
-51
@@ -1,5 +1,6 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
# Copyright 2024, Arav K.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
@@ -14,71 +15,49 @@
|
||||
|
||||
"""Simple library to work out if a file is hidden on different platforms."""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import stat
|
||||
import ctypes
|
||||
import sys
|
||||
import beets.util
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
|
||||
def _is_hidden_osx(path):
|
||||
"""Return whether or not a file is hidden on OS X.
|
||||
|
||||
This uses os.lstat to work out if a file has the "hidden" flag.
|
||||
def is_hidden(path: Union[bytes, Path]) -> bool:
|
||||
"""
|
||||
file_stat = os.lstat(beets.util.syspath(path))
|
||||
|
||||
if hasattr(file_stat, 'st_flags') and hasattr(stat, 'UF_HIDDEN'):
|
||||
return bool(file_stat.st_flags & stat.UF_HIDDEN)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def _is_hidden_win(path):
|
||||
"""Return whether or not a file is hidden on Windows.
|
||||
|
||||
This uses GetFileAttributes to work out if a file has the "hidden" flag
|
||||
(FILE_ATTRIBUTE_HIDDEN).
|
||||
Determine whether the given path is treated as a 'hidden file' by the OS.
|
||||
"""
|
||||
# FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation.
|
||||
hidden_mask = 2
|
||||
|
||||
# Retrieve the attributes for the file.
|
||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(beets.util.syspath(path))
|
||||
if isinstance(path, bytes):
|
||||
path = Path(os.fsdecode(path))
|
||||
|
||||
# Ensure we have valid attribues and compare them against the mask.
|
||||
return attrs >= 0 and attrs & hidden_mask
|
||||
# TODO: Avoid doing a platform check on every invocation of the function.
|
||||
# TODO: Stop supporting 'bytes' inputs once 'pathlib' is fully integrated.
|
||||
|
||||
if sys.platform == "win32":
|
||||
# On Windows, we check for an FS-provided attribute.
|
||||
|
||||
def _is_hidden_dot(path):
|
||||
"""Return whether or not a file starts with a dot.
|
||||
# FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation.
|
||||
hidden_mask = 2
|
||||
|
||||
Files starting with a dot are seen as "hidden" files on Unix-based OSes.
|
||||
"""
|
||||
return os.path.basename(path).startswith(b'.')
|
||||
# Retrieve the attributes for the file.
|
||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
||||
|
||||
# Ensure the attribute mask is valid.
|
||||
if attrs < 0:
|
||||
return False
|
||||
|
||||
def is_hidden(path):
|
||||
"""Return whether or not a file is hidden. `path` should be a
|
||||
bytestring filename.
|
||||
# Check for the hidden attribute.
|
||||
return attrs & hidden_mask
|
||||
|
||||
This method works differently depending on the platform it is called on.
|
||||
# On OS X, we check for an FS-provided attribute.
|
||||
if sys.platform == "darwin":
|
||||
if hasattr(os.stat_result, "st_flags") and hasattr(stat, "UF_HIDDEN"):
|
||||
if path.lstat().st_flags & stat.UF_HIDDEN:
|
||||
return True
|
||||
|
||||
On OS X, it uses both the result of `is_hidden_osx` and `is_hidden_dot` to
|
||||
work out if a file is hidden.
|
||||
# On all non-Windows platforms, we check for a '.'-prefixed file name.
|
||||
if path.name.startswith("."):
|
||||
return True
|
||||
|
||||
On Windows, it uses the result of `is_hidden_win` to work out if a file is
|
||||
hidden.
|
||||
|
||||
On any other operating systems (i.e. Linux), it uses `is_hidden_dot` to
|
||||
work out if a file is hidden.
|
||||
"""
|
||||
# Run platform specific functions depending on the platform
|
||||
if sys.platform == 'darwin':
|
||||
return _is_hidden_osx(path) or _is_hidden_dot(path)
|
||||
elif sys.platform == 'win32':
|
||||
return _is_hidden_win(path)
|
||||
else:
|
||||
return _is_hidden_dot(path)
|
||||
|
||||
__all__ = ['is_hidden']
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2016, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Helpers around the extraction of album/track ID's from metadata sources."""
|
||||
|
||||
import re
|
||||
|
||||
# Spotify IDs consist of 22 alphanumeric characters
|
||||
# (zero-left-padded base62 representation of randomly generated UUID4)
|
||||
spotify_id_regex = {
|
||||
"pattern": r"(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})",
|
||||
"match_group": 2,
|
||||
}
|
||||
|
||||
deezer_id_regex = {
|
||||
"pattern": r"(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)",
|
||||
"match_group": 4,
|
||||
}
|
||||
|
||||
beatport_id_regex = {
|
||||
"pattern": r"(^|beatport\.com/release/.+/)(\d+)$",
|
||||
"match_group": 2,
|
||||
}
|
||||
|
||||
# A note on Bandcamp: There is no such thing as a Bandcamp album or artist ID,
|
||||
# the URL can be used as the identifier. The Bandcamp metadata source plugin
|
||||
# works that way - https://github.com/snejus/beetcamp. Bandcamp album
|
||||
# URLs usually look like: https://nameofartist.bandcamp.com/album/nameofalbum
|
||||
|
||||
|
||||
def extract_discogs_id_regex(album_id):
|
||||
"""Returns the Discogs_id or None."""
|
||||
# Discogs-IDs are simple integers. In order to avoid confusion with
|
||||
# other metadata plugins, we only look for very specific formats of the
|
||||
# input string:
|
||||
# - plain integer, optionally wrapped in brackets and prefixed by an
|
||||
# 'r', as this is how discogs displays the release ID on its webpage.
|
||||
# - legacy url format: discogs.com/<name of release>/release/<id>
|
||||
# - legacy url short format: discogs.com/release/<id>
|
||||
# - current url format: discogs.com/release/<id>-<name of release>
|
||||
# See #291, #4080 and #4085 for the discussions leading up to these
|
||||
# patterns.
|
||||
# Regex has been tested here https://regex101.com/r/TOu7kw/1
|
||||
|
||||
for pattern in [
|
||||
r"^\[?r?(?P<id>\d+)\]?$",
|
||||
r"discogs\.com/release/(?P<id>\d+)-?",
|
||||
r"discogs\.com/[^/]+/release/(?P<id>\d+)",
|
||||
]:
|
||||
match = re.search(pattern, album_id)
|
||||
if match:
|
||||
return int(match.group("id"))
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,97 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2022, J0J0 Todos.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Provides utilities to read, write and manipulate m3u playlist files."""
|
||||
|
||||
import traceback
|
||||
|
||||
from beets.util import FilesystemError, mkdirall, normpath, syspath
|
||||
|
||||
|
||||
class EmptyPlaylistError(Exception):
|
||||
"""Raised when a playlist file without media files is saved or loaded."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class M3UFile:
|
||||
"""Reads and writes m3u or m3u8 playlist files."""
|
||||
|
||||
def __init__(self, path):
|
||||
"""``path`` is the absolute path to the playlist file.
|
||||
|
||||
The playlist file type, m3u or m3u8 is determined by 1) the ending
|
||||
being m3u8 and 2) the file paths contained in the list being utf-8
|
||||
encoded. Since the list is passed from the outside, this is currently
|
||||
out of control of this class.
|
||||
"""
|
||||
self.path = path
|
||||
self.extm3u = False
|
||||
self.media_list = []
|
||||
|
||||
def load(self):
|
||||
"""Reads the m3u file from disk and sets the object's attributes."""
|
||||
pl_normpath = normpath(self.path)
|
||||
try:
|
||||
with open(syspath(pl_normpath), "rb") as pl_file:
|
||||
raw_contents = pl_file.readlines()
|
||||
except OSError as exc:
|
||||
raise FilesystemError(
|
||||
exc, "read", (pl_normpath,), traceback.format_exc()
|
||||
)
|
||||
|
||||
self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False
|
||||
for line in raw_contents[1:]:
|
||||
if line.startswith(b"#"):
|
||||
# Support for specific EXTM3U comments could be added here.
|
||||
continue
|
||||
self.media_list.append(normpath(line.rstrip()))
|
||||
if not self.media_list:
|
||||
raise EmptyPlaylistError
|
||||
|
||||
def set_contents(self, media_list, extm3u=True):
|
||||
"""Sets self.media_list to a list of media file paths.
|
||||
|
||||
Also sets additional flags, changing the final m3u-file's format.
|
||||
|
||||
``media_list`` is a list of paths to media files that should be added
|
||||
to the playlist (relative or absolute paths, that's the responsibility
|
||||
of the caller). By default the ``extm3u`` flag is set, to ensure a
|
||||
save-operation writes an m3u-extended playlist (comment "#EXTM3U" at
|
||||
the top of the file).
|
||||
"""
|
||||
self.media_list = media_list
|
||||
self.extm3u = extm3u
|
||||
|
||||
def write(self):
|
||||
"""Writes the m3u file to disk.
|
||||
|
||||
Handles the creation of potential parent directories.
|
||||
"""
|
||||
header = [b"#EXTM3U"] if self.extm3u else []
|
||||
if not self.media_list:
|
||||
raise EmptyPlaylistError
|
||||
contents = header + self.media_list
|
||||
pl_normpath = normpath(self.path)
|
||||
mkdirall(pl_normpath)
|
||||
|
||||
try:
|
||||
with open(syspath(pl_normpath), "wb") as pl_file:
|
||||
for line in contents:
|
||||
pl_file.write(line + b"\n")
|
||||
pl_file.write(b"\n") # Final linefeed to prevent noeol file.
|
||||
except OSError as exc:
|
||||
raise FilesystemError(
|
||||
exc, "create", (pl_normpath,), traceback.format_exc()
|
||||
)
|
||||
+29
-28
@@ -33,11 +33,11 @@ in place of any single coroutine.
|
||||
|
||||
|
||||
import queue
|
||||
from threading import Thread, Lock
|
||||
import sys
|
||||
from threading import Lock, Thread
|
||||
|
||||
BUBBLE = '__PIPELINE_BUBBLE__'
|
||||
POISON = '__PIPELINE_POISON__'
|
||||
BUBBLE = "__PIPELINE_BUBBLE__"
|
||||
POISON = "__PIPELINE_POISON__"
|
||||
|
||||
DEFAULT_QUEUE_SIZE = 16
|
||||
|
||||
@@ -48,6 +48,7 @@ def _invalidate_queue(q, val=None, sync=True):
|
||||
which defaults to None. `sync` controls whether a lock is
|
||||
required (because it's not reentrant!).
|
||||
"""
|
||||
|
||||
def _qsize(len=len):
|
||||
return 1
|
||||
|
||||
@@ -75,8 +76,8 @@ def _invalidate_queue(q, val=None, sync=True):
|
||||
q._qsize = _qsize
|
||||
q._put = _put
|
||||
q._get = _get
|
||||
q.not_empty.notifyAll()
|
||||
q.not_full.notifyAll()
|
||||
q.not_empty.notify_all()
|
||||
q.not_full.notify_all()
|
||||
|
||||
finally:
|
||||
if sync:
|
||||
@@ -168,6 +169,7 @@ def stage(func):
|
||||
while True:
|
||||
task = yield task
|
||||
task = func(*(args + (task,)))
|
||||
|
||||
return coro
|
||||
|
||||
|
||||
@@ -191,6 +193,7 @@ def mutator_stage(func):
|
||||
while True:
|
||||
task = yield task
|
||||
func(*(args + (task,)))
|
||||
|
||||
return coro
|
||||
|
||||
|
||||
@@ -218,20 +221,18 @@ class PipelineThread(Thread):
|
||||
self.exc_info = None
|
||||
|
||||
def abort(self):
|
||||
"""Shut down the thread at the next chance possible.
|
||||
"""
|
||||
"""Shut down the thread at the next chance possible."""
|
||||
with self.abort_lock:
|
||||
self.abort_flag = True
|
||||
|
||||
# Ensure that we are not blocking on a queue read or write.
|
||||
if hasattr(self, 'in_queue'):
|
||||
if hasattr(self, "in_queue"):
|
||||
_invalidate_queue(self.in_queue, POISON)
|
||||
if hasattr(self, 'out_queue'):
|
||||
if hasattr(self, "out_queue"):
|
||||
_invalidate_queue(self.out_queue, POISON)
|
||||
|
||||
def abort_all(self, exc_info):
|
||||
"""Abort all other threads in the system for an exception.
|
||||
"""
|
||||
"""Abort all other threads in the system for an exception."""
|
||||
self.exc_info = exc_info
|
||||
for thread in self.all_threads:
|
||||
thread.abort()
|
||||
@@ -373,7 +374,7 @@ class Pipeline:
|
||||
be at least two stages.
|
||||
"""
|
||||
if len(stages) < 2:
|
||||
raise ValueError('pipeline must have at least two stages')
|
||||
raise ValueError("pipeline must have at least two stages")
|
||||
self.stages = []
|
||||
for stage in stages:
|
||||
if isinstance(stage, (list, tuple)):
|
||||
@@ -405,15 +406,15 @@ class Pipeline:
|
||||
# Middle stages.
|
||||
for i in range(1, queue_count):
|
||||
for coro in self.stages[i]:
|
||||
threads.append(MiddlePipelineThread(
|
||||
coro, queues[i - 1], queues[i], threads
|
||||
))
|
||||
threads.append(
|
||||
MiddlePipelineThread(
|
||||
coro, queues[i - 1], queues[i], threads
|
||||
)
|
||||
)
|
||||
|
||||
# Last stage.
|
||||
for coro in self.stages[-1]:
|
||||
threads.append(
|
||||
LastPipelineThread(coro, queues[-1], threads)
|
||||
)
|
||||
threads.append(LastPipelineThread(coro, queues[-1], threads))
|
||||
|
||||
# Start threads.
|
||||
for thread in threads:
|
||||
@@ -472,21 +473,21 @@ class Pipeline:
|
||||
|
||||
|
||||
# Smoke test.
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
# Test a normally-terminating pipeline both in sequence and
|
||||
# in parallel.
|
||||
def produce():
|
||||
for i in range(5):
|
||||
print('generating %i' % i)
|
||||
print("generating %i" % i)
|
||||
time.sleep(1)
|
||||
yield i
|
||||
|
||||
def work():
|
||||
num = yield
|
||||
while True:
|
||||
print('processing %i' % num)
|
||||
print("processing %i" % num)
|
||||
time.sleep(2)
|
||||
num = yield num * 2
|
||||
|
||||
@@ -494,7 +495,7 @@ if __name__ == '__main__':
|
||||
while True:
|
||||
num = yield
|
||||
time.sleep(1)
|
||||
print('received %i' % num)
|
||||
print("received %i" % num)
|
||||
|
||||
ts_start = time.time()
|
||||
Pipeline([produce(), work(), consume()]).run_sequential()
|
||||
@@ -503,22 +504,22 @@ if __name__ == '__main__':
|
||||
ts_par = time.time()
|
||||
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
|
||||
ts_end = time.time()
|
||||
print('Sequential time:', ts_seq - ts_start)
|
||||
print('Parallel time:', ts_par - ts_seq)
|
||||
print('Multiply-parallel time:', ts_end - ts_par)
|
||||
print("Sequential time:", ts_seq - ts_start)
|
||||
print("Parallel time:", ts_par - ts_seq)
|
||||
print("Multiply-parallel time:", ts_end - ts_par)
|
||||
print()
|
||||
|
||||
# Test a pipeline that raises an exception.
|
||||
def exc_produce():
|
||||
for i in range(10):
|
||||
print('generating %i' % i)
|
||||
print("generating %i" % i)
|
||||
time.sleep(1)
|
||||
yield i
|
||||
|
||||
def exc_work():
|
||||
num = yield
|
||||
while True:
|
||||
print('processing %i' % num)
|
||||
print("processing %i" % num)
|
||||
time.sleep(3)
|
||||
if num == 3:
|
||||
raise Exception()
|
||||
@@ -527,6 +528,6 @@ if __name__ == '__main__':
|
||||
def exc_consume():
|
||||
while True:
|
||||
num = yield
|
||||
print('received %i' % num)
|
||||
print("received %i" % num)
|
||||
|
||||
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
|
||||
|
||||
Reference in New Issue
Block a user