diff --git a/groove/console.py b/groove/console.py new file mode 100644 index 0000000..5a28ab4 --- /dev/null +++ b/groove/console.py @@ -0,0 +1,47 @@ +import os + +from pathlib import Path +from textwrap import dedent + +from rich.console import Console as _Console +from rich.markdown import Markdown +from rich.theme import Theme + +from prompt_toolkit import prompt as _toolkit_prompt +from prompt_toolkit.formatted_text import ANSI + +from groove.path import theme + +BASE_STYLE = { + 'help': 'cyan', + 'bright': 'white', + 'repr.str': 'dim', + 'repr.brace': 'dim', + 'repr.url': 'blue', +} + + +class Console(_Console): + + def __init__(self, *args, **kwargs): + if 'theme' not in kwargs: + theme_path = theme(os.environ['DEFAULT_THEME']) + kwargs['theme'] = Theme(BASE_STYLE).read(theme_path / Path('console.cfg'), inherit=False) + super().__init__(*args, **kwargs) + + def prompt(self, lines, **kwargs): + for line in lines[:-1]: + super().print(line) + with self.capture() as capture: + super().print(f"[prompt bold]{lines[-1]}>[/] ", end='') + rendered = ANSI(capture.get()) + return _toolkit_prompt(rendered, **kwargs) + + def mdprint(self, txt, **kwargs): + self.print(Markdown(dedent(txt), justify='left'), **kwargs) + + def print(self, txt, **kwargs): + super().print(txt, **kwargs) + + def error(self, txt, **kwargs): + super().print(dedent(txt), style='error') diff --git a/groove/editor.py b/groove/editor.py index 5610269..783af9b 100644 --- a/groove/editor.py +++ b/groove/editor.py @@ -3,12 +3,17 @@ import os import subprocess import yaml +from yaml.scanner import ScannerError +from groove.exceptions import PlaylistValidationError + + from tempfile import NamedTemporaryFile EDITOR_TEMPLATE = """ {name}: - description: {description} + description: | +{description} entries: {entries} @@ -60,10 +65,21 @@ class PlaylistEditor: return self._path def edit(self, playlist): - with self.path as fh: - fh.write(playlist.as_yaml.encode()) - subprocess.check_call([os.environ['EDITOR'], self.path.name]) - edits = self.read() + try: + with self.path as fh: + fh.write(playlist.as_yaml.encode()) + subprocess.check_call([os.environ['EDITOR'], self.path.name]) + except (IOError, OSError, FileNotFoundError) as e: + logging.error(e) + raise RuntimeError("Could not invoke the editor! If the error persists, try enabling DEBUG mode.") + try: + edits = self.read() + except ScannerError: + raise PlaylistValidationError( + f"An error occurred when importing the playlist definition. This is " + f"typically the result of a YAML syntax error; you can inspect the " + f"source for errors at {self._path.name}." + ) self.cleanup() return edits diff --git a/groove/playlist.py b/groove/playlist.py index 13b0ac4..ca153b2 100644 --- a/groove/playlist.py +++ b/groove/playlist.py @@ -1,6 +1,7 @@ import logging import os +from textwrap import indent from typing import Union, List from groove import db @@ -13,6 +14,11 @@ from sqlalchemy.orm.session import Session from sqlalchemy.engine.row import Row from sqlalchemy.exc import NoResultFound, MultipleResultsFound +from rich.table import Table, Column +from rich import box + +from yaml.scanner import ScannerError + class Playlist: """ @@ -135,12 +141,54 @@ class Playlist: def as_string(self) -> str: text = self.info for (tracknum, entry) in enumerate(self.entries): - text += f" - {tracknum+1} {entry.artist} - {entry.title}\n" + text += f" {tracknum+1:-3d}. {entry.artist} - {entry.title}\n" return text + @property + def as_richtext(self) -> str: + title = f"\n [b]:headphones: {self.name}[/b]" + if self.description: + title += f"\n [italic]{self.description}[/italic]\n" + params = dict( + box=box.HORIZONTALS, + title=title, + title_justify='left', + caption=f"[link]{self.url}[/link]", + caption_justify='right', + ) + if os.environ['CONSOLE_THEMES']: + params.update( + header_style='on #001321', + title_style='on #001321', + border_style='on #001321', + row_styles=['on #001321'], + caption_style='on #001321', + style='on #001321', + ) + width = os.environ.get('CONSOLE_WIDTH', 'auto') + if width == 'expand': + params['expand'] = True + elif width != 'auto': + params['width'] = int(width) + + table = Table( + Column('#', justify='right', width=4), + Column('Artist', justify='left'), + Column('Title', justify='left'), + **params + ) + for (num, entry) in enumerate(self.entries): + table.add_row( + f"[text]{num+1}[/text]", + f"[artist]{entry.artist}[/artist]", + f"[title]{entry.title}[/title]" + ) + return table + @property def as_yaml(self) -> str: template_vars = self.as_dict + template_vars['description'] = indent(template_vars['description'], prefix=' ') template_vars['entries'] = '' for entry in self.entries: template_vars['entries'] += f' - "{entry.artist}": "{entry.title}"\n' @@ -200,10 +248,23 @@ class Playlist: edits = self.editor.edit(self) if not edits: return - new = Playlist.from_yaml(edits, self.session) - if new == self: - logging.debug("No changes detected.") - return + try: + new = Playlist.from_yaml(edits, self.session) + if new == self: + logging.debug("No changes detected.") + return + except (TypeError, ScannerError) as e: + logging.error(e) + raise PlaylistValidationError( + "An error occurred reading the input file; this is typically " + "the result of an error in the YAML structure." + ) + except TrackNotFoundError as e: + logging.error(e) + raise PlaylistValidationError( + "One or more of the specified tracks " + "did not exactly match an entry in the database." + ) logging.debug(f"Updating {self.slug} with new edits.") self._slug = new.slug self._name = new.name.strip() @@ -345,10 +406,13 @@ class Playlist: session=session, create_ok=create_ok ) - pl._entries = list(pl._get_tracks_by_artist_and_title(entries=[ - list(entry.items())[0] for entry in source[name]['entries'] - ])) - except (IndexError, KeyError): + if not source[name]['entries']: + pl._entries = [] + else: + pl._entries = list(pl._get_tracks_by_artist_and_title(entries=[ + list(entry.items())[0] for entry in source[name]['entries'] + ])) + except (IndexError, KeyError, AttributeError): raise PlaylistValidationError("The specified source was not a valid playlist.") return pl diff --git a/groove/shell/base.py b/groove/shell/base.py index f6eb614..002dad3 100644 --- a/groove/shell/base.py +++ b/groove/shell/base.py @@ -1,10 +1,13 @@ from prompt_toolkit import prompt from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit import print_formatted_text, HTML +from groove.console import Console + class BasePrompt(Completer): - def __init__(self, manager=None, parent=None): + def __init__(self, manager=None, console=None, parent=None): super(BasePrompt, self).__init__() if (not manager and not parent): # pragma: no cover @@ -14,6 +17,14 @@ class BasePrompt(Completer): self._values = [] self._parent = parent self._manager = manager + self._console = None + self._theme = None + + @property + def console(self): + if not self._console: + self._console = Console(color_system='truecolor') + return self._console @property def usage(self): @@ -56,10 +67,16 @@ class BasePrompt(Completer): except NotImplementedError: pass + def help(self, parts): + self.console.print( + getattr(self, parts[0]).__doc__ if parts else self.help_text + ) + return True + def start(self, cmd=''): while True: if not cmd: - cmd = prompt(f'{self.prompt} ', completer=self) + cmd = self.console.prompt(self.prompt, completer=self) if not cmd: return cmd, *parts = cmd.split() diff --git a/groove/shell/interactive_shell.py b/groove/shell/interactive_shell.py index 82cc896..4fd0193 100644 --- a/groove/shell/interactive_shell.py +++ b/groove/shell/interactive_shell.py @@ -11,9 +11,12 @@ class CommandPrompt(BasePrompt): def __init__(self, manager): super().__init__(manager=manager) self._playlist = None - self._prompt = "Groove on Demand interactive shell. Try 'help' for help.\ngroove>" self._completer = None self._commands = None + self._prompt = [ + "[help]Groove on Demand interactive shell. Try 'help' for help.[/help]", + "groove" + ] @property def playlist(self): diff --git a/groove/shell/playlist.py b/groove/shell/playlist.py index 337e110..5275ab6 100644 --- a/groove/shell/playlist.py +++ b/groove/shell/playlist.py @@ -1,23 +1,67 @@ from .base import BasePrompt -from prompt_toolkit import prompt -from rich import print +import os + from sqlalchemy.exc import NoResultFound +from textwrap import dedent, wrap from groove import db +from groove.exceptions import PlaylistValidationError class _playlist(BasePrompt): + """ + """ def __init__(self, parent, manager=None): super().__init__(manager=manager, parent=parent) self._parent = parent - self._prompt = '' self._commands = None + @property + def help_text(self): + synopsis = ( + f"You are currently editing the [b]{self.parent.playlist.name}[/b]" + f" playlist. From this prompt you can quickly append new tracks " + f"to the playlist. You can invoke your editor " + f"([link]{os.environ['EDITOR']}[/link]) to change the playlist " + f"name and description, or reorder or remove tracks. You can also " + f"delete the playlist." + ) + + try: + width = int(os.environ.get('CONSOLE_WIDTH', '80')) + except ValueError: + width = 80 + synopsis = '\n '.join(wrap(synopsis, width=width)) + + return dedent(f""" + [title]WORKING WITH PLAYLISTS[/title] + + {synopsis} + + [title]USAGE[/title] + + [link]playlist> COMMAND [ARG ..][/link] + + [title]COMMANDS[/title] + [help] + [b]add[/b] Add one or more tracks to the playlist + [b]edit[/b] Open the playlist in the system editor + [b]show[/b] Display the complete playlist + [b]delete[/b] Delete the playlist + [b]help[/b] This message + + Try 'help command' for command-specific help.[/help] + """) + @property def prompt(self): - return f"{self.parent.playlist}\n{self.parent.playlist.slug}> " + return [ + "", + "[help]Available commands: add, edit, show, delete, help. Hit Enter to return.[/help]", + f"[prompt]{self.parent.playlist.slug}[/prompt]", + ] @property def values(self): @@ -27,32 +71,41 @@ class _playlist(BasePrompt): def commands(self): if not self._commands: self._commands = { - 'show': self.show, 'delete': self.delete, 'add': self.add, + 'show': self.show, 'edit': self.edit, + 'help': self.help } return self._commands def process(self, cmd, *parts): if cmd in self.commands: return True if self.commands[cmd](parts) else False - print(f"Command not understood: {cmd}") + self.parent.console.error(f"Command not understood: {cmd}") return True - def show(self, parts): - print(self.parent.playlist) + def edit(self, *parts): + try: + self.parent.playlist.edit() + except PlaylistValidationError as e: + self.parent.console.error(f"Changes were not saved: {e}") + else: + self.show() return True - def edit(self, parts): - self.parent.playlist.edit() + def show(self, *parts): + self.parent.console.print(self.parent.playlist.as_richtext) return True - def add(self, parts): - print("Add tracks one at a time by title. ENTER to finish.") + def add(self, *parts): + self.parent.console.print( + "Add tracks one at a time by title. Hit Enter to finish." + ) + added = False while True: - text = prompt( - ' ?', + text = self.parent.console.prompt( + [' ?'], completer=self.manager.fuzzy_table_completer( db.track, db.track.c.relpath, @@ -61,8 +114,11 @@ class _playlist(BasePrompt): complete_in_thread=True, complete_while_typing=True ) if not text: + if added: + self.show() return True self._add_track(text) + added = True def _add_track(self, text): sess = self.parent.manager.session @@ -70,20 +126,25 @@ class _playlist(BasePrompt): track = sess.query(db.track).filter(db.track.c.relpath == text).one() self.parent.playlist.create_entries([track]) except NoResultFound: - print("No match for '{text}'") + self.parent.console.error("No match for '{text}'") return return text - def delete(self, parts): - res = prompt( - 'Type DELETE to permanently delete the playlist ' - f'"{self.parent.playlist.record.name}".\nDELETE {self.prompt}' - ) + def delete(self, *parts): + res = self.parent.console.prompt([ + f"[error]Type [b]DELETE[/b] to permanently delete the playlist " + f'"{self.parent.playlist.record.name}".[/error]', + f"[prompt]DELETE {self.parent.playlist.slug}[/prompt]", + ]) if res != 'DELETE': - print("Delete aborted. No changes have been made.") + self.parent.console.error("Delete aborted. No changes have been made.") return True self.parent.playlist.delete() - print("Deleted the playlist.") + self.parent.console.print("Deleted the playlist.") self.parent._playlist = None return False + + def start(self): + self.show() + super().start()