From bbb5658781133a2e406f24a6ba99fd2fef77ba74 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 11:39:22 +0100 Subject: [PATCH] Overhaul output to use click --- tests/integration/test_timelines.py | 2 +- tests/test_output.py | 26 -- toot/output.py | 407 ++++++++++++---------------- toot/utils/__init__.py | 4 +- 4 files changed, 176 insertions(+), 263 deletions(-) delete mode 100644 tests/test_output.py diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py index 5f4da1e..70aa676 100644 --- a/tests/integration/test_timelines.py +++ b/tests/integration/test_timelines.py @@ -117,7 +117,7 @@ def test_empty_timeline(app, run_as): user = register_account(app) result = run_as(user, cli.timeline) assert result.exit_code == 0 - assert result.stdout.strip() == "─" * 100 + assert result.stdout.strip() == "─" * 80 def test_timeline_cant_combine_timelines(run): diff --git a/tests/test_output.py b/tests/test_output.py deleted file mode 100644 index cc31e5c..0000000 --- a/tests/test_output.py +++ /dev/null @@ -1,26 +0,0 @@ -from toot.output import colorize, strip_tags, STYLES - -reset = STYLES["reset"] -red = STYLES["red"] -green = STYLES["green"] -bold = STYLES["bold"] - - -def test_colorize(): - assert colorize("foo") == "foo" - assert colorize("foo") == f"{red}foo{reset}{reset}" - assert colorize("foo bar baz") == f"foo {red}bar{reset} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset}{bold} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset} baz{reset}" - assert colorize("foobarbaz") == f"{red}foo{bold}bar{reset}{red}baz{reset}{reset}" - - -def test_strip_tags(): - assert strip_tags("foo") == "foo" - assert strip_tags("foo") == "foo" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foobarbaz") == "foobarbaz" diff --git a/toot/output.py b/toot/output.py index d526539..fc4b7dd 100644 --- a/toot/output.py +++ b/toot/output.py @@ -1,227 +1,103 @@ -import os +import click import re -import sys import textwrap -from functools import lru_cache -from toot import settings -from toot.utils import get_text, html_to_paragraphs from toot.entities import Account, Instance, Notification, Poll, Status +from toot.utils import get_text, html_to_paragraphs from toot.wcstring import wc_wrap -from typing import Iterable, List +from typing import Any, Generator, Iterable, List from wcwidth import wcswidth -STYLES = { - 'reset': '\033[0m', - 'bold': '\033[1m', - 'dim': '\033[2m', - 'italic': '\033[3m', - 'underline': '\033[4m', - 'red': '\033[91m', - 'green': '\033[92m', - 'yellow': '\033[93m', - 'blue': '\033[94m', - 'magenta': '\033[95m', - 'cyan': '\033[96m', -} - -STYLE_TAG_PATTERN = re.compile(r""" - (? # literal -""", re.X) +def print_instance(instance: Instance, width: int = 80): + click.echo(instance_to_text(instance, width)) -def colorize(message): - """ - Replaces style tags in `message` with ANSI escape codes. - - Markup is inspired by HTML, but you can use multiple words pre tag, e.g.: - - alert! a thing happened - - Empty closing tag will reset all styes: - - alert! a thing happened - - Styles can be nested: - - red red and underline red - """ - - def _codes(styles): - for style in styles: - yield STYLES.get(style, "") - - def _generator(message): - # A list is used instead of a set because we want to keep style order - # This allows nesting colors, e.g. "foobarbaz" - position = 0 - active_styles = [] - - for match in re.finditer(STYLE_TAG_PATTERN, message): - is_closing = bool(match.group(1)) - styles = match.group(2).strip().split() - - start, end = match.span() - # Replace backslash for escaped < - yield message[position:start].replace("\\<", "<") - - if is_closing: - yield STYLES["reset"] - - # Empty closing tag resets all styles - if styles == []: - active_styles = [] - else: - active_styles = [s for s in active_styles if s not in styles] - yield from _codes(active_styles) - else: - active_styles = active_styles + styles - yield from _codes(styles) - - position = end - - if position == 0: - # Nothing matched, yield the original string - yield message - else: - # Yield the remaining fragment - yield message[position:] - # Reset styles at the end to prevent leaking - yield STYLES["reset"] - - return "".join(_generator(message)) +def instance_to_text(instance: Instance, width: int) -> str: + return "\n".join(instance_lines(instance, width)) -def strip_tags(message): - return re.sub(STYLE_TAG_PATTERN, "", message) - - -@lru_cache(maxsize=None) -def use_ansi_color(): - """Returns True if ANSI color codes should be used.""" - - # Windows doesn't support color unless ansicon is installed - # See: http://adoxa.altervista.org/ansicon/ - if sys.platform == 'win32' and 'ANSICON' not in os.environ: - return False - - # Don't show color if stdout is not a tty, e.g. if output is piped on - if not sys.stdout.isatty(): - return False - - # Don't show color if explicitly specified in options - if "--no-color" in sys.argv: - return False - - # Check in settings - color = settings.get_setting("common.color", bool) - if color is not None: - return color - - # Use color by default - return True - - -def print_out(*args, **kwargs): - if not settings.get_quiet(): - args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] - print(*args, **kwargs) - - -def print_err(*args, **kwargs): - args = [f"{a}" for a in args] - args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] - print(*args, file=sys.stderr, **kwargs) - - -def print_instance(instance: Instance): - print_out(f"{instance.title}") - print_out(f"{instance.uri}") - print_out(f"running Mastodon {instance.version}") - print_out() +def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]: + yield f"{green(instance.title)}" + yield f"{blue(instance.uri)}" + yield f"running Mastodon {instance.version}" + yield "" if instance.description: for paragraph in re.split(r"[\r\n]+", instance.description.strip()): paragraph = get_text(paragraph) - print_out(textwrap.fill(paragraph, width=80)) - print_out() + yield textwrap.fill(paragraph, width=width) + yield "" if instance.rules: - print_out("Rules:") + yield "Rules:" for ordinal, rule in enumerate(instance.rules): ordinal = f"{ordinal + 1}." - lines = textwrap.wrap(rule.text, 80 - len(ordinal)) + lines = textwrap.wrap(rule.text, width - len(ordinal)) first = True for line in lines: if first: - print_out(f"{ordinal} {line}") + yield f"{ordinal} {line}" first = False else: - print_out(f"{' ' * len(ordinal)} {line}") - print_out() + yield f"{' ' * len(ordinal)} {line}" + yield "" contact = instance.contact_account if contact: - print_out(f"Contact: {contact.display_name} @{contact.acct}") + yield f"Contact: {contact.display_name} @{contact.acct}" -def print_account(account: Account): - print_out(f"@{account.acct} {account.display_name}") +def print_account(account: Account, width: int = 80) -> None: + click.echo(account_to_text(account, width)) + + +def account_to_text(account: Account, width: int) -> str: + return "\n".join(account_lines(account, width)) + + +def account_lines(account: Account, width: int) -> Generator[str, None, None]: + acct = f"@{account.acct}" + since = account.created_at.strftime("%Y-%m-%d") + + yield f"{green(acct)} {account.display_name}" if account.note: - print_out("") - print_html(account.note) + yield "" + yield from html_lines(account.note, width) - since = account.created_at.strftime('%Y-%m-%d') - - print_out("") - print_out(f"ID: {account.id}") - print_out(f"Since: {since}") - print_out("") - print_out(f"Followers: {account.followers_count}") - print_out(f"Following: {account.following_count}") - print_out(f"Statuses: {account.statuses_count}") + yield "" + yield f"ID: {green(account.id)}" + yield f"Since: {green(since)}" + yield "" + yield f"Followers: {yellow(account.followers_count)}" + yield f"Following: {yellow(account.following_count)}" + yield f"Statuses: {yellow(account.statuses_count)}" if account.fields: for field in account.fields: name = field.name.title() - print_out(f'\n{name}:') - print_html(field.value) + yield f'\n{yellow(name)}:' + yield from html_lines(field.value, width) if field.verified_at: - print_out("✓ Verified") + yield green("✓ Verified") - print_out("") - print_out(account.url) - - -HASHTAG_PATTERN = re.compile(r'(?\\1', line) + yield "" + yield account.url def print_acct_list(accounts): for account in accounts: - print_out(f"* @{account['acct']} {account['display_name']}") - - -def print_user_list(users): - for user in users: - print_out(f"* {user}") + acct = green(f"@{account['acct']}") + click.echo(f"* {acct} {account['display_name']}") def print_tag_list(tags): if tags: for tag in tags: - print_out(f"* #{tag['name']}\t{tag['url']}") + click.echo(f"* {format_tag_name(tag)}\t{tag['url']}") else: - print_out("You're not following any hashtags.") + click.echo("You're not following any hashtags.") def print_lists(lists): @@ -234,20 +110,17 @@ def print_table(headers: List[str], data: List[List[str]]): widths = [[len(cell) for cell in row] for row in data + [headers]] widths = [max(width) for width in zip(*widths)] - def style(string, tag): - return f"<{tag}>{string}" if tag else string - - def print_row(row, tag=None): + def print_row(row): for idx, cell in enumerate(row): width = widths[idx] - print_out(style(cell.ljust(width), tag), end="") - print_out(" ", end="") - print_out() + click.echo(cell.ljust(width), nl=False) + click.echo(" ", nl=False) + click.echo() underlines = ["-" * width for width in widths] - print_row(headers, "bold") - print_row(underlines, "dim") + print_row(headers) + print_row(underlines) for row in data: print_row(row) @@ -255,33 +128,40 @@ def print_table(headers: List[str], data: List[List[str]]): def print_list_accounts(accounts): if accounts: - print_out("Accounts in list:\n") + click.echo("Accounts in list:\n") print_acct_list(accounts) else: - print_out("This list has no accounts.") + click.echo("This list has no accounts.") def print_search_results(results): - accounts = results['accounts'] - hashtags = results['hashtags'] + accounts = results["accounts"] + hashtags = results["hashtags"] if accounts: - print_out("\nAccounts:") + click.echo("\nAccounts:") print_acct_list(accounts) if hashtags: - print_out("\nHashtags:") - print_out(", ".join([f"#{t['name']}" for t in hashtags])) + click.echo("\nHashtags:") + click.echo(", ".join([format_tag_name(tag) for tag in hashtags])) if not accounts and not hashtags: - print_out("Nothing found") + click.echo("Nothing found") -def print_status(status: Status, width: int = 80): +def print_status(status: Status, width: int = 80) -> None: + click.echo(status_to_text(status, width)) + + +def status_to_text(status: Status, width: int) -> str: + return "\n".join(status_lines(status, width)) + + +def status_lines(status: Status, width: int = 80) -> Generator[str, None, None]: status_id = status.id in_reply_to_id = status.in_reply_to_id reblogged_by = status.account if status.reblog else None - status = status.original time = status.created_at.strftime('%Y-%m-%d %H:%M %Z') @@ -289,61 +169,60 @@ def print_status(status: Status, width: int = 80): spacing = width - wcswidth(username) - wcswidth(time) - 2 display_name = status.account.display_name + if display_name: + author = f"{green(display_name)} {blue(username)}" spacing -= wcswidth(display_name) + 1 + else: + author = blue(username) - print_out( - f"{display_name}" if display_name else "", - f"{username}", - " " * spacing, - f"{time}", - ) + spaces = " " * spacing + yield f"{author} {spaces} {yellow(time)}" - print_out("") - print_html(status.content, width) + yield "" + yield from html_lines(status.content, width) if status.media_attachments: - print_out("\nMedia:") + yield "" + yield "Media:" for attachment in status.media_attachments: url = attachment.url for line in wc_wrap(url, width): - print_out(line) + yield line if status.poll: - print_poll(status.poll) + yield from poll_lines(status.poll) - print_out() + reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None + yield "" - print_out( - f"ID {status_id} ", - f"↲ In reply to {in_reply_to_id} " if in_reply_to_id else "", - f"↻ @{reblogged_by.acct} boosted " if reblogged_by else "", - ) + reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else "" + boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else "" + yield f"ID {yellow(status_id)} {reply} {boost}" -def print_html(text, width=80): +def html_lines(html: str, width: int) -> Generator[str, None, None]: first = True - for paragraph in html_to_paragraphs(text): + for paragraph in html_to_paragraphs(html): if not first: - print_out("") + yield "" for line in paragraph: for subline in wc_wrap(line, width): - print_out(highlight_hashtags(subline)) + yield subline first = False -def print_poll(poll: Poll): - print_out() +def poll_lines(poll: Poll) -> Generator[str, None, None]: for idx, option in enumerate(poll.options): perc = (round(100 * option.votes_count / poll.votes_count) if poll.votes_count and option.votes_count is not None else 0) if poll.voted and poll.own_votes and idx in poll.own_votes: - voted_for = " " + voted_for = yellow(" ✓") else: voted_for = "" - print_out(f'{option.title} - {perc}% {voted_for}') + yield f"{option.title} - {perc}% {voted_for}" poll_footer = f'Poll · {poll.votes_count} votes' @@ -354,15 +233,15 @@ def print_poll(poll: Poll): expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M") poll_footer += f" · Closes on {expires_at}" - print_out() - print_out(poll_footer) + yield "" + yield poll_footer -def print_timeline(items: Iterable[Status], width=100): - print_out("─" * width) +def print_timeline(items: Iterable[Status], width=80): + click.echo("─" * width) for item in items: print_status(item, width) - print_out("─" * width) + click.echo("─" * width) notification_msgs = { @@ -373,19 +252,79 @@ notification_msgs = { } -def print_notification(notification: Notification, width=100): - account = f"{notification.account.display_name} @{notification.account.acct}" - msg = notification_msgs.get(notification.type) - if msg is None: - return - - print_out("─" * width) - print_out(msg.format(account=account)) +def print_notification(notification: Notification, width=80): + print_notification_header(notification) if notification.status: + click.echo("-" * width) print_status(notification.status, width) -def print_notifications(notifications: List[Notification], width=100): +def print_notifications(notifications: List[Notification], width=80): for notification in notifications: + click.echo("─" * width) print_notification(notification) - print_out("─" * width) + click.echo("─" * width) + + +def print_notification_header(notification: Notification): + account_name = format_account_name(notification.account) + + if (notification.type == "follow"): + click.echo(f"{account_name} now follows you") + elif (notification.type == "mention"): + click.echo(f"{account_name} mentioned you") + elif (notification.type == "reblog"): + click.echo(f"{account_name} reblogged your status") + elif (notification.type == "favourite"): + click.echo(f"{account_name} favourited your status") + elif (notification.type == "update"): + click.echo(f"{account_name} edited a post") + else: + click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow") + click.secho("Please report an issue to toot.", err=True, fg="yellow") + + +notification_msgs = { + "follow": "{account} now follows you", + "mention": "{account} mentioned you in", + "reblog": "{account} reblogged your status", + "favourite": "{account} favourited your status", +} + + +def format_tag_name(tag): + return green(f"#{tag['name']}") + + +def format_account_name(account: Account) -> str: + acct = blue(f"@{account.acct}") + if account.display_name: + return f"{green(account.display_name)} {acct}" + else: + return acct + + +# Shorthand functions for coloring output + +def blue(text: Any) -> str: + return click.style(text, fg="blue") + + +def bold(text: Any) -> str: + return click.style(text, bold=True) + + +def cyan(text: Any) -> str: + return click.style(text, fg="cyan") + + +def dim(text: Any) -> str: + return click.style(text, dim=True) + + +def green(text: Any) -> str: + return click.style(text, fg="green") + + +def yellow(text: Any) -> str: + return click.style(text, fg="yellow") diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index d9ffbb9..7bc5c77 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -7,7 +7,7 @@ import unicodedata import warnings from bs4 import BeautifulSoup -from typing import Any, Dict +from typing import Any, Dict, List import click @@ -40,7 +40,7 @@ def get_text(html): return unicodedata.normalize("NFKC", text) -def html_to_paragraphs(html): +def html_to_paragraphs(html: str) -> List[List[str]]: """Attempt to convert html to plain text while keeping line breaks. Returns a list of paragraphs, each being a list of lines. """