Skip to content

Commit f42279b

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 ea1f5da commit f42279b

25 files changed

+1005
-243
lines changed

beets/importer/tasks.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def chosen_info(self):
234234
return likelies
235235
elif self.choice_flag is Action.APPLY and self.match:
236236
return self.match.info.copy()
237-
assert False
237+
raise ValueError("Invalid choice flag; this should never happen.")
238238

239239
def imported_items(self):
240240
"""Return a list of Items that should be added to the library.
@@ -249,7 +249,7 @@ def imported_items(self):
249249
):
250250
return list(self.match.mapping.keys())
251251
else:
252-
assert False
252+
raise ValueError("Invalid choice flag; this should never happen.")
253253

254254
def apply_metadata(self):
255255
"""Copy metadata from match info to the items."""
@@ -318,6 +318,8 @@ def finalize(self, session: ImportSession):
318318
if not self.skip:
319319
self._emit_imported(session.lib)
320320

321+
session.task_finalized()
322+
321323
def cleanup(self, copy=False, delete=False, move=False):
322324
"""Remove and prune imported paths."""
323325
# Do not delete any files or prune directories when skipping.
@@ -355,9 +357,10 @@ def handle_created(self, session: ImportSession):
355357
else:
356358
# The plugins gave us a list of lists of tasks. Flatten it.
357359
tasks = [t for inner in tasks for t in inner]
360+
session.tasks_created(tasks)
358361
return tasks
359362

360-
def lookup_candidates(self, search_ids: list[str]) -> None:
363+
def lookup_candidates(self, session: ImportSession, search_ids: list[str]) -> None:
361364
"""Retrieve and store candidates for this album.
362365
363366
If User-specified ``search_ids`` list is not empty, the lookup is
@@ -366,6 +369,7 @@ def lookup_candidates(self, search_ids: list[str]) -> None:
366369
self.cur_artist, self.cur_album, (self.candidates, self.rec) = (
367370
autotag.tag_album(self.items, search_ids=search_ids)
368371
)
372+
session.task_candidates_found()
369373

370374
def find_duplicates(self, lib: library.Library) -> list[library.Album]:
371375
"""Return a list of albums from `lib` with the same artist and
@@ -638,6 +642,7 @@ def choose_match(self, session):
638642
choice = session.choose_match(self)
639643
self.set_choice(choice)
640644
session.log_choice(self)
645+
session.task_match_chosen()
641646

642647
def reload(self):
643648
"""Reload albums and items from the database."""
@@ -693,10 +698,11 @@ def _emit_imported(self, lib):
693698
for item in self.imported_items():
694699
plugins.send("item_imported", lib=lib, item=item)
695700

696-
def lookup_candidates(self, search_ids: list[str]) -> None:
701+
def lookup_candidates(self, session: ImportSession, search_ids: list[str]) -> None:
697702
self.candidates, self.rec = autotag.tag_item(
698703
self.item, search_ids=search_ids
699704
)
705+
session.task_candidates_found()
700706

701707
def find_duplicates(self, lib: library.Library) -> list[library.Item]: # type: ignore[override] # Need splitting Singleton and Album tasks into separate classes
702708
"""Return a list of items from `lib` that have the same artist

beets/test/helper.py

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

666+
created: int = 0
667+
candidates_found: int = 0
668+
match_chosen: int = 0
669+
finalized: int = 0
670+
666671
def __init__(self, *args, **kwargs):
667672
super().__init__(*args, **kwargs)
668673
self._choices = []
669674
self._resolutions = []
670675

671676
default_choice = importer.Action.APPLY
672677

678+
def tasks_created(self, tasks: list[importer.ImportTask]) -> None:
679+
self.created += len(tasks)
680+
681+
def task_candidates_found(self) -> None:
682+
self.candidates_found += 1
683+
684+
def task_match_chosen(self) -> None:
685+
self.match_chosen += 1
686+
687+
def task_finalized(self) -> None:
688+
self.finalized += 1
689+
673690
def add_choice(self, choice):
674691
self._choices.append(choice)
675692

@@ -710,13 +727,30 @@ def resolve_duplicate(self, task, found_duplicates):
710727

711728

712729
class TerminalImportSessionFixture(TerminalImportSession):
730+
created: int = 0
731+
candidates_found: int = 0
732+
match_chosen: int = 0
733+
finalized: int = 0
734+
713735
def __init__(self, *args, **kwargs):
714736
self.io = kwargs.pop("io")
715737
super().__init__(*args, **kwargs)
716738
self._choices = []
717739

718740
default_choice = importer.Action.APPLY
719741

742+
def tasks_created(self, tasks: list[importer.ImportTask]) -> None:
743+
self.created += len(tasks)
744+
745+
def task_candidates_found(self) -> None:
746+
self.candidates_found += 1
747+
748+
def task_match_chosen(self) -> None:
749+
self.match_chosen += 1
750+
751+
def task_finalized(self) -> None:
752+
self.finalized += 1
753+
720754
def add_choice(self, choice):
721755
self._choices.append(choice)
722756

beets/ui/__init__.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,26 @@
2828
import sys
2929
import textwrap
3030
import traceback
31+
from contextlib import contextmanager
3132
from difflib import SequenceMatcher
32-
from typing import TYPE_CHECKING, Any, Callable
33+
from typing import TYPE_CHECKING, Any, Callable, Generator
3334

3435
import confuse
36+
import enlighten
3537

3638
from beets import config, library, logging, plugins, util
3739
from beets.dbcore import db
3840
from beets.dbcore import query as db_query
3941
from beets.util import as_string
4042
from beets.util.functemplate import template
4143

44+
is_windows = sys.platform == "win32"
45+
4246
if TYPE_CHECKING:
4347
from types import ModuleType
4448

4549
# On Windows platforms, use colorama to support "ANSI" terminal colors.
46-
if sys.platform == "win32":
50+
if is_windows:
4751
try:
4852
import colorama
4953
except ImportError:
@@ -1370,6 +1374,97 @@ def add_all_common_options(self):
13701374
self.add_format_option()
13711375

13721376

1377+
@contextmanager
1378+
def changes_and_errors_pbars(
1379+
**kwargs,
1380+
) -> Generator[
1381+
tuple[enlighten.Counter, enlighten.Counter, enlighten.Counter], None, None
1382+
]:
1383+
"""Construct three progress bars for incremental changes and errors.
1384+
1385+
Using this method to construct the three progress bars allows Beets to
1386+
manage the formatting and coloring of the progress bars, ensuring
1387+
consistency across the codebase and among plugins.
1388+
1389+
Example usage:
1390+
1391+
```python
1392+
with ui.changes_and_errors_pbars(
1393+
total=len(items),
1394+
desc="Updating items",
1395+
unit="items",
1396+
) as (n_changed, n_unchanged, n_errors):
1397+
for album in lib.albums():
1398+
try:
1399+
if update_album(album):
1400+
n_changed.update()
1401+
else:
1402+
n_unchanged.update()
1403+
except Exception:
1404+
n_errors.update()
1405+
```
1406+
1407+
Args:
1408+
kwargs: Keyword arguments to pass to the `enlighten.Counter`
1409+
constructor.
1410+
1411+
Returns:
1412+
A tuple of three `enlighten.Counter` instances: the first for changed
1413+
items, the second for unchanged items, and the third for errors.
1414+
"""
1415+
if "color" in kwargs:
1416+
del kwargs["color"]
1417+
1418+
# Currently disabled when running in Windows. Enlighten does not seem to
1419+
# handle user inputs in the text area above the progress bar, nor cleaning
1420+
# up the progress bar from the terminal when the command completes.
1421+
#
1422+
# TODO: investigate and resolve these problems, and enable the progress
1423+
# bars in Windows environments.
1424+
with enlighten.Manager(enabled=not is_windows) as manager:
1425+
with manager.counter(**kwargs, color="white") as unchanged:
1426+
changed = unchanged.add_subcounter("blue")
1427+
errors = unchanged.add_subcounter("red")
1428+
yield changed, unchanged, errors
1429+
1430+
1431+
def iprogress_bar(sequence, **kwargs):
1432+
"""Construct and manage an `enlighten.Counter` progress bar while iterating.
1433+
1434+
Example usage:
1435+
```
1436+
for album in ui.iprogress_bar(
1437+
lib.albums(), desc="Updating albums", unit="albums"):
1438+
do_something_to(album)
1439+
```
1440+
1441+
Args:
1442+
sequence: An `Iterable` sequence to iterate over. If provided, and the
1443+
sequence can return its length, then the length will be used as the
1444+
total for the counter. The counter will be updated for each item
1445+
in the sequence.
1446+
kwargs: Additional keyword arguments to pass to the `enlighten.Counter`
1447+
constructor.
1448+
1449+
Yields:
1450+
The items from the sequence.
1451+
"""
1452+
if sequence is None:
1453+
log.error("sequence must not be None")
1454+
return
1455+
1456+
# If sequence is not None, and can return its length, then use that as the total.
1457+
if "total" not in kwargs and hasattr(sequence, "__len__"):
1458+
kwargs["total"] = len(sequence)
1459+
1460+
# Disabled in windows environments. See above for details
1461+
with enlighten.Manager(enabled=not is_windows) as manager:
1462+
with manager.counter(**kwargs) as counter:
1463+
for item in sequence:
1464+
counter.update()
1465+
yield item
1466+
1467+
13731468
# Subcommand parsing infrastructure.
13741469
#
13751470
# This is a fairly generic subcommand parser for optparse. It is

0 commit comments

Comments
 (0)