Skip to content

Commit 4360907

Browse files
committed
Add progress bars to several commands and plugins.
Many long-running commands produce little or no feedback in the terminal to indicate that they're progressing, and none of them provide estimates of how long the operation will run. This change introduces the `enlighten` python package, which displays progress bars akin to TQDM below the existing terminal output. To support consistent use and presentation of the progress bars, and to allow for future modification, we introduce a method to beets.ui - beets.ui.iprogress_bar - which can be used by Beets' core commands and all Beets plugins. Example usage is provided in the methods' documentation and in a new 'further reading' doc for developers. The Enlighten library does not work as well in Windows PowerShell as it does in a linux terminal (manually tested in Zsh), so the progress bars are disabled in Windows environments. Resolving these issues and enabling them in Windows is left as future work. Progress Bars are disabled also when not in a TTY, to prevent interference with logging when running as a web server, for example.
1 parent 64c94f6 commit 4360907

23 files changed

+682
-91
lines changed

beets/ui/__init__.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,41 @@
2828
import sys
2929
import textwrap
3030
import traceback
31+
import typing
3132
import warnings
3233
from difflib import SequenceMatcher
3334
from functools import cache
3435
from itertools import chain
35-
from typing import Any, Callable, Literal
36+
from typing import (
37+
TYPE_CHECKING,
38+
Any,
39+
Callable,
40+
Generator,
41+
Iterable,
42+
Literal,
43+
Sized,
44+
TypeVar,
45+
Union,
46+
)
3647

3748
import confuse
49+
import enlighten
50+
from typing_extensions import Protocol
3851

3952
from beets import config, library, logging, plugins, util
4053
from beets.dbcore import db
4154
from beets.dbcore import query as db_query
4255
from beets.util import as_string
4356
from beets.util.functemplate import template
4457

58+
if TYPE_CHECKING:
59+
from beets.db import Album as Album
60+
from beets.db import Item as Item
61+
62+
is_windows = sys.platform == "win32"
63+
4564
# On Windows platforms, use colorama to support "ANSI" terminal colors.
46-
if sys.platform == "win32":
65+
if is_windows:
4766
try:
4867
import colorama
4968
except ImportError:
@@ -1325,6 +1344,80 @@ def add_all_common_options(self):
13251344
self.add_format_option()
13261345

13271346

1347+
T = TypeVar("T", covariant=True)
1348+
U = TypeVar("U")
1349+
1350+
1351+
class SizedIterable(Protocol[T], Sized, Iterable[T]):
1352+
pass
1353+
1354+
1355+
def iprogress_bar(
1356+
sequence: Union[Iterable[U], SizedIterable[U]], **kwargs
1357+
) -> Generator[U, None, None]:
1358+
"""Construct and manage an `enlighten.Counter` progress bar while iterating.
1359+
1360+
Example usage:
1361+
```
1362+
for album in ui.iprogress_bar(
1363+
lib.albums(), desc="Updating albums", unit="albums"):
1364+
do_something_to(album)
1365+
```
1366+
1367+
If the progress bar is iterating over an Album or an Item, then it will detect
1368+
whether or not the item has been modified, and will color-code the progress bar
1369+
with white and blue to indicate total progress and the portion of items that have
1370+
been modified.
1371+
1372+
Args:
1373+
sequence: An `Iterable` sequence to iterate over. If provided, and the
1374+
sequence can return its length, then the length will be used as the
1375+
total for the counter. The counter will be updated for each item
1376+
in the sequence.
1377+
kwargs: Additional keyword arguments to pass to the `enlighten.Counter`
1378+
constructor.
1379+
1380+
Yields:
1381+
The items from the sequence.
1382+
"""
1383+
if sequence is None:
1384+
log.error("sequence must not be None")
1385+
return
1386+
1387+
# If the total was not directly set, and the iterable is sized, then use its size as
1388+
# the progress bar's total.
1389+
if "total" not in kwargs:
1390+
if hasattr(sequence, "__len__"):
1391+
sized_seq = typing.cast(SizedIterable[U], sequence)
1392+
kwargs["total"] = len(sized_seq)
1393+
1394+
# Disabled in windows environments and when not attached to a TTY. See method docs
1395+
# for details.
1396+
with enlighten.Manager(
1397+
enabled=not is_windows
1398+
and (hasattr(sys.stdout, "isatty") and sys.stdout.isatty())
1399+
) as manager:
1400+
with manager.counter(**kwargs) as counter:
1401+
change_counter = counter.add_subcounter("blue")
1402+
1403+
for item in sequence:
1404+
revision = None
1405+
if hasattr(item, "_revision"):
1406+
revision = item._revision
1407+
1408+
# Yield the item, allowing it to be modified, or not.
1409+
yield item
1410+
1411+
if (
1412+
revision
1413+
and hasattr(item, "_revision")
1414+
and item._revision != revision
1415+
):
1416+
change_counter.update()
1417+
else:
1418+
counter.update()
1419+
1420+
13281421
# Subcommand parsing infrastructure.
13291422
#
13301423
# This is a fairly generic subcommand parser for optparse. It is

beetsplug/autobpm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import numpy as np
2222

2323
from beets.plugins import BeetsPlugin
24-
from beets.ui import Subcommand, should_write
24+
from beets.ui import Subcommand, iprogress_bar, should_write
2525

2626
if TYPE_CHECKING:
2727
from beets.importer import ImportTask
@@ -56,7 +56,7 @@ def imported(self, _, task: ImportTask) -> None:
5656
self.calculate_bpm(task.imported_items())
5757

5858
def calculate_bpm(self, items: list[Item], write: bool = False) -> None:
59-
for item in items:
59+
for item in iprogress_bar(items, desc="Calculating BPM", unit="items"):
6060
path = item.filepath
6161
if bpm := item.bpm:
6262
self._log.info("BPM for {} already exists: {}", path, bpm)

beetsplug/badfiles.py

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,20 @@
1414

1515
"""Use command-line tools to check for audio file corruption."""
1616

17+
import concurrent.futures
1718
import errno
1819
import os
1920
import shlex
2021
import sys
2122
from subprocess import STDOUT, CalledProcessError, check_output, list2cmdline
23+
from typing import Callable, Optional, Union
2224

2325
import confuse
2426

25-
from beets import importer, ui
27+
from beets import importer, library, ui
2628
from beets.plugins import BeetsPlugin
2729
from beets.ui import Subcommand
28-
from beets.util import displayable_path, par_map
30+
from beets.util import displayable_path
2931

3032

3133
class CheckerCommandError(Exception):
@@ -45,6 +47,13 @@ def __init__(self, cmd, oserror):
4547
self.msg = str(oserror)
4648

4749

50+
# CheckResult is a tuple of 1. status code, 2. how many errors there were, and 3.
51+
# a list of error output messages.
52+
CheckResult = tuple[int, int, list[str]]
53+
54+
CheckMethod = Callable[[str], CheckResult]
55+
56+
4857
class BadFiles(BeetsPlugin):
4958
def __init__(self):
5059
super().__init__()
@@ -55,12 +64,12 @@ def __init__(self):
5564
"import_task_before_choice", self.on_import_task_before_choice
5665
)
5766

58-
def run_command(self, cmd):
67+
def run_command(self, cmd: list[str]) -> CheckResult:
5968
self._log.debug(
6069
"running command: {}", displayable_path(list2cmdline(cmd))
6170
)
6271
try:
63-
output = check_output(cmd, stderr=STDOUT)
72+
output: bytes = check_output(cmd, stderr=STDOUT)
6473
errors = 0
6574
status = 0
6675
except CalledProcessError as e:
@@ -69,28 +78,28 @@ def run_command(self, cmd):
6978
status = e.returncode
7079
except OSError as e:
7180
raise CheckerCommandError(cmd, e)
72-
output = output.decode(sys.getdefaultencoding(), "replace")
73-
return status, errors, [line for line in output.split("\n") if line]
81+
output_dec = output.decode(sys.getdefaultencoding(), "replace")
82+
return status, errors, [line for line in output_dec.split("\n") if line]
7483

75-
def check_mp3val(self, path):
84+
def check_mp3val(self, path: str) -> CheckResult:
7685
status, errors, output = self.run_command(["mp3val", path])
7786
if status == 0:
7887
output = [line for line in output if line.startswith("WARNING:")]
7988
errors = len(output)
8089
return status, errors, output
8190

82-
def check_flac(self, path):
91+
def check_flac(self, path: str) -> CheckResult:
8392
return self.run_command(["flac", "-wst", path])
8493

85-
def check_custom(self, command):
94+
def check_custom(self, command: str) -> Callable[[str], CheckResult]:
8695
def checker(path):
8796
cmd = shlex.split(command)
8897
cmd.append(path)
8998
return self.run_command(cmd)
9099

91100
return checker
92101

93-
def get_checker(self, ext):
102+
def get_checker(self, ext: str) -> Optional[CheckMethod]:
94103
ext = ext.lower()
95104
try:
96105
command = self.config["commands"].get(dict).get(ext)
@@ -102,8 +111,9 @@ def get_checker(self, ext):
102111
return self.check_mp3val
103112
if ext == "flac":
104113
return self.check_flac
114+
return None
105115

106-
def check_item(self, item):
116+
def check_item(self, item: library.Item) -> tuple[bool, list[str]]:
107117
# First, check whether the path exists. If not, the user
108118
# should probably run `beet update` to cleanup your library.
109119
dpath = displayable_path(item.path)
@@ -118,8 +128,8 @@ def check_item(self, item):
118128
checker = self.get_checker(ext)
119129
if not checker:
120130
self._log.error("no checker specified in the config for {}", ext)
121-
return []
122-
path = item.path
131+
return False, []
132+
path: Union[bytes, str] = item.path
123133
if not isinstance(path, str):
124134
path = item.path.decode(sys.getfilesystemencoding())
125135
try:
@@ -132,11 +142,13 @@ def check_item(self, item):
132142
)
133143
else:
134144
self._log.error("error invoking {0.checker}: {0.msg}", e)
135-
return []
145+
return False, []
136146

147+
success = True
137148
error_lines = []
138149

139150
if status > 0:
151+
success = False
140152
error_lines.append(
141153
f"{ui.colorize('text_error', dpath)}: checker exited with"
142154
f" status {status}"
@@ -145,6 +157,7 @@ def check_item(self, item):
145157
error_lines.append(f" {line}")
146158

147159
elif errors > 0:
160+
success = False
148161
error_lines.append(
149162
f"{ui.colorize('text_warning', dpath)}: checker found"
150163
f" {status} errors or warnings"
@@ -154,23 +167,24 @@ def check_item(self, item):
154167
elif self.verbose:
155168
error_lines.append(f"{ui.colorize('text_success', dpath)}: ok")
156169

157-
return error_lines
170+
return success, error_lines
158171

159-
def on_import_task_start(self, task, session):
172+
def on_import_task_start(self, task, session) -> None:
160173
if not self.config["check_on_import"].get(False):
161174
return
162175

163176
checks_failed = []
164177

165178
for item in task.items:
166-
error_lines = self.check_item(item)
167-
if error_lines:
168-
checks_failed.append(error_lines)
179+
_, error_lines = self.check_item(item)
180+
checks_failed.append(error_lines)
169181

170182
if checks_failed:
171183
task._badfiles_checks_failed = checks_failed
172184

173-
def on_import_task_before_choice(self, task, session):
185+
def on_import_task_before_choice(
186+
self, task, session
187+
) -> Optional[importer.Action]:
174188
if hasattr(task, "_badfiles_checks_failed"):
175189
ui.print_(
176190
f"{ui.colorize('text_warning', 'BAD')} one or more files failed"
@@ -194,16 +208,27 @@ def on_import_task_before_choice(self, task, session):
194208
else:
195209
raise Exception(f"Unexpected selection: {sel}")
196210

197-
def command(self, lib, opts, args):
211+
return None
212+
213+
def command(self, lib, opts, args) -> None:
198214
# Get items from arguments
199215
items = lib.items(args)
200216
self.verbose = opts.verbose
201217

202218
def check_and_print(item):
203-
for error_line in self.check_item(item):
204-
ui.print_(error_line)
205-
206-
par_map(check_and_print, items)
219+
success, error_lines = self.check_item(item)
220+
if not success:
221+
for line in error_lines:
222+
ui.print_(line)
223+
224+
with concurrent.futures.ThreadPoolExecutor() as executor:
225+
for _ in ui.iprogress_bar(
226+
executor.map(check_and_print, items),
227+
desc="Checking",
228+
unit="item",
229+
total=len(items),
230+
):
231+
pass
207232

208233
def commands(self):
209234
bad_command = Subcommand(

beetsplug/chroma.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,11 @@ def submit_cmd_func(lib, opts, args):
254254
)
255255

256256
def fingerprint_cmd_func(lib, opts, args):
257-
for item in lib.items(args):
257+
for item in ui.iprogress_bar(
258+
lib.items(args),
259+
desc="Fingerprinting items",
260+
unit="items",
261+
):
258262
fingerprint_item(self._log, item, write=ui.should_write())
259263

260264
fingerprint_cmd.func = fingerprint_cmd_func

beetsplug/duplicates.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import os
1818
import shlex
1919

20+
from beets import ui
2021
from beets.library import Album, Item
2122
from beets.plugins import BeetsPlugin
2223
from beets.ui import Subcommand, UserError, print_
@@ -284,9 +285,13 @@ def _group_by(self, objs, keys, strict):
284285
import collections
285286

286287
counts = collections.defaultdict(list)
287-
for obj in objs:
288+
for obj in ui.iprogress_bar(
289+
objs,
290+
desc="Finding duplicates",
291+
unit="items",
292+
):
288293
values = [getattr(obj, k, None) for k in keys]
289-
values = [v for v in values if v not in (None, "")]
294+
values = list(filter(lambda v: v not in (None, ""), values))
290295
if strict and len(values) < len(keys):
291296
self._log.debug(
292297
"some keys {} on item {.filepath} are null or empty: skipping",

0 commit comments

Comments
 (0)