Skip to content

Commit 3ad823f

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 two methods to beets.ui - beets.ui.iprogress_bar, and beets.ui.changed_unchanged_error_pbars - which can be used by Beets' core commands and all Beets plugins. Example usage is provided in the methods' documentation. Integrating progress bars into the 'import' command required a more custom implementation than the beets.ui.* commands could support. The approach taken is certainly open to discussion. Notably, 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.
1 parent 3c177b5 commit 3ad823f

26 files changed

+1944
-1188
lines changed

beets/importer.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,35 @@ def _setup_logging(self, loghandler: logging.Handler | None):
248248
logger.handlers = [loghandler]
249249
return logger
250250

251+
def tasks_created(self, _: list[ImportTask]) -> None:
252+
"""Called when a list of tasks is created.
253+
254+
Expected to be called when an individual directory or query result is
255+
transformed into a list of tasks.
256+
"""
257+
raise NotImplementedError
258+
259+
def task_candidates_found(self) -> None:
260+
"""Called when a task has found candidates.
261+
262+
Expected to be called by an ImportTask when it has found candidates.
263+
"""
264+
raise NotImplementedError
265+
266+
def task_match_chosen(self) -> None:
267+
"""Called when a task has chosen a match.
268+
269+
Expected to be called by an ImportTask when it has chosen a match.
270+
"""
271+
raise NotImplementedError
272+
273+
def task_finalized(self) -> None:
274+
"""Called when a task has been finalized.
275+
276+
Expected to be called by an ImportTask when it has been finalized.
277+
"""
278+
raise NotImplementedError
279+
251280
def set_config(self, config):
252281
"""Set `config` property from global import config and make
253282
implied changes.
@@ -610,7 +639,7 @@ def chosen_info(self):
610639
return likelies
611640
elif self.choice_flag is action.APPLY and self.match:
612641
return self.match.info.copy()
613-
assert False
642+
raise ValueError("Invalid choice flag; this should never happen.")
614643

615644
def imported_items(self):
616645
"""Return a list of Items that should be added to the library.
@@ -625,7 +654,7 @@ def imported_items(self):
625654
):
626655
return list(self.match.mapping.keys())
627656
else:
628-
assert False
657+
raise ValueError("Invalid choice flag; this should never happen.")
629658

630659
def apply_metadata(self):
631660
"""Copy metadata from match info to the items."""
@@ -694,6 +723,8 @@ def finalize(self, session: ImportSession):
694723
if not self.skip:
695724
self._emit_imported(session.lib)
696725

726+
session.task_finalized()
727+
697728
def cleanup(self, copy=False, delete=False, move=False):
698729
"""Remove and prune imported paths."""
699730
# Do not delete any files or prune directories when skipping.
@@ -731,9 +762,10 @@ def handle_created(self, session: ImportSession):
731762
else:
732763
# The plugins gave us a list of lists of tasks. Flatten it.
733764
tasks = [t for inner in tasks for t in inner]
765+
session.tasks_created(tasks)
734766
return tasks
735767

736-
def lookup_candidates(self):
768+
def lookup_candidates(self, session: ImportSession):
737769
"""Retrieve and store candidates for this album. User-specified
738770
candidate IDs are stored in self.search_ids: if present, the
739771
initial lookup is restricted to only those IDs.
@@ -745,6 +777,7 @@ def lookup_candidates(self):
745777
self.cur_album = album
746778
self.candidates = prop.candidates
747779
self.rec = prop.recommendation
780+
session.task_candidates_found()
748781

749782
def find_duplicates(self, lib: library.Library):
750783
"""Return a list of albums from `lib` with the same artist and
@@ -1017,6 +1050,7 @@ def choose_match(self, session):
10171050
choice = session.choose_match(self)
10181051
self.set_choice(choice)
10191052
session.log_choice(self)
1053+
session.task_match_chosen()
10201054

10211055
def reload(self):
10221056
"""Reload albums and items from the database."""
@@ -1072,10 +1106,11 @@ def _emit_imported(self, lib):
10721106
for item in self.imported_items():
10731107
plugins.send("item_imported", lib=lib, item=item)
10741108

1075-
def lookup_candidates(self):
1109+
def lookup_candidates(self, session: ImportSession):
10761110
prop = autotag.tag_item(self.item, search_ids=self.search_ids)
10771111
self.candidates = prop.candidates
10781112
self.rec = prop.recommendation
1113+
session.task_candidates_found()
10791114

10801115
def find_duplicates(self, lib):
10811116
"""Return a list of items from `lib` that have the same artist
@@ -1516,7 +1551,7 @@ def read_tasks(session: ImportSession):
15161551
log.info("Skipped {0} paths.", skipped)
15171552

15181553

1519-
def query_tasks(session: ImportSession):
1554+
def query_tasks(session: ImportSession) -> Iterable[ImportTask]:
15201555
"""A generator that works as a drop-in-replacement for read_tasks.
15211556
Instead of finding files from the filesystem, a query is used to
15221557
match items from the library.
@@ -1564,7 +1599,7 @@ def lookup_candidates(session: ImportSession, task: ImportTask):
15641599
# option. Currently all the IDs are passed onto the tasks directly.
15651600
task.search_ids = session.config["search_ids"].as_str_seq()
15661601

1567-
task.lookup_candidates()
1602+
task.lookup_candidates(session)
15681603

15691604

15701605
@pipeline.stage

beets/test/helper.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,13 +679,30 @@ class ImportSessionFixture(ImportSession):
679679
remaining albums, the metadata from the autotagger will be applied.
680680
"""
681681

682+
created: int = 0
683+
candidates_found: int = 0
684+
match_chosen: int = 0
685+
finalized: int = 0
686+
682687
def __init__(self, *args, **kwargs):
683688
super().__init__(*args, **kwargs)
684689
self._choices = []
685690
self._resolutions = []
686691

687692
default_choice = importer.action.APPLY
688693

694+
def tasks_created(self, tasks: list[importer.ImportTask]) -> None:
695+
self.created += len(tasks)
696+
697+
def task_candidates_found(self) -> None:
698+
self.candidates_found += 1
699+
700+
def task_match_chosen(self) -> None:
701+
self.match_chosen += 1
702+
703+
def task_finalized(self) -> None:
704+
self.finalized += 1
705+
689706
def add_choice(self, choice):
690707
self._choices.append(choice)
691708

@@ -726,13 +743,30 @@ def resolve_duplicate(self, task, found_duplicates):
726743

727744

728745
class TerminalImportSessionFixture(TerminalImportSession):
746+
created: int = 0
747+
candidates_found: int = 0
748+
match_chosen: int = 0
749+
finalized: int = 0
750+
729751
def __init__(self, *args, **kwargs):
730752
self.io = kwargs.pop("io")
731753
super().__init__(*args, **kwargs)
732754
self._choices = []
733755

734756
default_choice = importer.action.APPLY
735757

758+
def tasks_created(self, tasks: list[importer.ImportTask]) -> None:
759+
self.created += len(tasks)
760+
761+
def task_candidates_found(self) -> None:
762+
self.candidates_found += 1
763+
764+
def task_match_chosen(self) -> None:
765+
self.match_chosen += 1
766+
767+
def task_finalized(self) -> None:
768+
self.finalized += 1
769+
736770
def add_choice(self, choice):
737771
self._choices.append(choice)
738772

beets/ui/__init__.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
import sys
2727
import textwrap
2828
import traceback
29+
from contextlib import contextmanager
2930
from difflib import SequenceMatcher
30-
from typing import Any, Callable
31+
from typing import Any, Callable, Generator
3132

3233
import confuse
34+
import enlighten
3335

3436
from beets import config, library, logging, plugins, util
3537
from beets.autotag import mb
@@ -38,8 +40,10 @@
3840
from beets.util import as_string
3941
from beets.util.functemplate import template
4042

43+
is_windows = sys.platform == "win32"
44+
4145
# On Windows platforms, use colorama to support "ANSI" terminal colors.
42-
if sys.platform == "win32":
46+
if is_windows:
4347
try:
4448
import colorama
4549
except ImportError:
@@ -1436,6 +1440,97 @@ def add_all_common_options(self):
14361440
self.add_format_option()
14371441

14381442

1443+
@contextmanager
1444+
def changes_and_errors_pbars(
1445+
**kwargs,
1446+
) -> Generator[
1447+
tuple[enlighten.Counter, enlighten.Counter, enlighten.Counter], None, None
1448+
]:
1449+
"""Construct three progress bars for incremental changes and errors.
1450+
1451+
Using this method to construct the three progress bars allows Beets to
1452+
manage the formatting and coloring of the progress bars, ensuring
1453+
consistency across the codebase and among plugins.
1454+
1455+
Example usage:
1456+
1457+
```python
1458+
with ui.changes_and_errors_pbars(
1459+
total=len(items),
1460+
desc="Updating items",
1461+
unit="items",
1462+
) as (n_changed, n_unchanged, n_errors):
1463+
for album in lib.albums():
1464+
try:
1465+
if update_album(album):
1466+
n_changed.update()
1467+
else:
1468+
n_unchanged.update()
1469+
except Exception:
1470+
n_errors.update()
1471+
```
1472+
1473+
Args:
1474+
kwargs: Keyword arguments to pass to the `enlighten.Counter`
1475+
constructor.
1476+
1477+
Returns:
1478+
A tuple of three `enlighten.Counter` instances: the first for changed
1479+
items, the second for unchanged items, and the third for errors.
1480+
"""
1481+
if "color" in kwargs:
1482+
del kwargs["color"]
1483+
1484+
# Currently disabled when running in Windows. Enlighten does not seem to
1485+
# handle user inputs in the text area above the progress bar, nor cleaning
1486+
# up the progress bar from the terminal when the command completes.
1487+
#
1488+
# TODO: investigate and resolve these problems, and enable the progress
1489+
# bars in Windows environments.
1490+
with enlighten.Manager(enabled=not is_windows) as manager:
1491+
with manager.counter(**kwargs, color="white") as unchanged:
1492+
changed = unchanged.add_subcounter("blue")
1493+
errors = unchanged.add_subcounter("red")
1494+
yield changed, unchanged, errors
1495+
1496+
1497+
def iprogress_bar(sequence, **kwargs):
1498+
"""Construct and manage an `enlighten.Counter` progress bar while iterating.
1499+
1500+
Example usage:
1501+
```
1502+
for album in ui.iprogress_bar(
1503+
lib.albums(), desc="Updating albums", unit="albums"):
1504+
do_something_to(album)
1505+
```
1506+
1507+
Args:
1508+
sequence: An `Iterable` sequence to iterate over. If provided, and the
1509+
sequence can return its length, then the length will be used as the
1510+
total for the counter. The counter will be updated for each item
1511+
in the sequence.
1512+
kwargs: Additional keyword arguments to pass to the `enlighten.Counter`
1513+
constructor.
1514+
1515+
Yields:
1516+
The items from the sequence.
1517+
"""
1518+
if sequence is None:
1519+
log.error("sequence must not be None")
1520+
return
1521+
1522+
# If sequence is not None, and can return its length, then use that as the total.
1523+
if "total" not in kwargs and hasattr(sequence, "__len__"):
1524+
kwargs["total"] = len(sequence)
1525+
1526+
# Disabled in windows environments. See above for details
1527+
with enlighten.Manager(enabled=not is_windows) as manager:
1528+
with manager.counter(**kwargs) as counter:
1529+
for item in sequence:
1530+
counter.update()
1531+
yield item
1532+
1533+
14391534
# Subcommand parsing infrastructure.
14401535
#
14411536
# This is a fairly generic subcommand parser for optparse. It is

0 commit comments

Comments
 (0)