bug fixes, console themes, help interface

This commit is contained in:
evilchili 2022-12-16 23:24:05 -08:00
parent 335d67c9d2
commit b7ec4259a6
6 changed files with 247 additions and 39 deletions

47
groove/console.py Normal file
View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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):

View File

@ -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()