From 5273f8769df863a73692c9d6557076d62c7aaef6 Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 12 Mar 2022 12:43:26 -0800 Subject: [PATCH] Reformat messages and add new centralized exception handling (#1417) * Update and modularize exception handling cc #1024 #1141 - Stack traces are no longer shown to users unless the --debug flag is being used - Errors, warnings, and other messages contain color as needed - Converted error messages to Enum - Adds print_msg function to centralize output (this should replace all other output in other modules) Co-authored-by: Micah Jerome Ellison --- jrnl/cli.py | 29 ++++++++++-- jrnl/commands.py | 18 ++++++-- jrnl/config.py | 20 ++++++-- jrnl/editor.py | 35 ++++++++++---- jrnl/exception.py | 43 ++++------------- jrnl/messages.py | 79 ++++++++++++++++++++++++++++++++ jrnl/output.py | 25 ++++++++-- jrnl/plugins/fancy_exporter.py | 22 ++++++--- tests/bdd/features/write.feature | 4 +- tests/unit/test_exception.py | 19 -------- tests/unit/test_export.py | 6 +-- 11 files changed, 204 insertions(+), 96 deletions(-) create mode 100644 jrnl/messages.py delete mode 100644 tests/unit/test_exception.py diff --git a/jrnl/cli.py b/jrnl/cli.py index 6a1c6a0f..03b4f2f0 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -3,10 +3,15 @@ import logging import sys +import traceback -from .jrnl import run -from .args import parse_args -from .exception import JrnlError +from jrnl.jrnl import run +from jrnl.args import parse_args +from jrnl.output import print_msg +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType def configure_logger(debug=False): @@ -33,9 +38,23 @@ def cli(manual_args=None): return run(args) - except JrnlError as e: - print(e.message, file=sys.stderr) + except JrnlException as e: + e.print() return 1 except KeyboardInterrupt: + print_msg(Message(MsgText.KeyboardInterruptMsg, MsgType.WARNING)) + return 1 + + except Exception as e: + try: + is_debug = args.debug # type: ignore + except NameError: + # error happened before args were parsed + is_debug = "--debug" in sys.argv[1:] + + if is_debug: + traceback.print_tb(sys.exc_info()[2]) + + print_msg(Message(MsgText.UncaughtException, MsgType.ERROR, {"exception": e})) return 1 diff --git a/jrnl/commands.py b/jrnl/commands.py index d765242a..c795402a 100644 --- a/jrnl/commands.py +++ b/jrnl/commands.py @@ -13,7 +13,10 @@ avoid any possible overhead for these standalone commands. """ import platform import sys -from .exception import JrnlError +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType def preconfig_diagnostic(_): @@ -70,10 +73,15 @@ def postconfig_encrypt(args, config, original_config, **kwargs): journal = open_journal(args.journal_name, config) if hasattr(journal, "can_be_encrypted") and not journal.can_be_encrypted: - raise JrnlError( - "CannotEncryptJournalType", - journal_name=args.journal_name, - journal_type=journal.__class__.__name__, + raise JrnlException( + Message( + MsgText.CannotEncryptJournalType, + MsgType.ERROR, + { + "journal_name": args.journal_name, + "journal_type": journal.__class__.__name__, + }, + ) ) journal.config["encrypt"] = True diff --git a/jrnl/config.py b/jrnl/config.py index b38b6cef..e1929a1d 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -7,7 +7,11 @@ import yaml import xdg.BaseDirectory from . import __version__ -from .exception import JrnlError +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType + from .color import ERROR_COLOR from .color import RESET_COLOR from .output import list_journals @@ -68,12 +72,18 @@ def get_config_path(): try: config_directory_path = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) except FileExistsError: - raise JrnlError( - "ConfigDirectoryIsFile", - config_directory_path=os.path.join( - xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE + raise JrnlException( + Message( + MsgText.ConfigDirectoryIsFile, + MsgType.ERROR, + { + "config_directory_path": os.path.join( + xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE + ) + }, ), ) + return os.path.join( config_directory_path or os.path.expanduser("~"), DEFAULT_CONFIG_NAME ) diff --git a/jrnl/editor.py b/jrnl/editor.py index 7c7413e8..81ca659a 100644 --- a/jrnl/editor.py +++ b/jrnl/editor.py @@ -6,10 +6,16 @@ import tempfile import textwrap from pathlib import Path -from .color import ERROR_COLOR -from .color import RESET_COLOR -from .os_compat import on_windows -from .os_compat import split_args +from jrnl.color import ERROR_COLOR +from jrnl.color import RESET_COLOR +from jrnl.os_compat import on_windows +from jrnl.os_compat import split_args +from jrnl.output import print_msg + +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType def get_text_from_editor(config, template=""): @@ -47,16 +53,25 @@ def get_text_from_editor(config, template=""): def get_text_from_stdin(): - _how_to_quit = "Ctrl+z and then Enter" if on_windows() else "Ctrl+d" - print( - f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n", - file=sys.stderr, + print_msg( + Message( + MsgText.WritingEntryStart, + MsgType.TITLE, + { + "how_to_quit": MsgText.HowToQuitWindows + if on_windows() + else MsgText.HowToQuitLinux + }, + ) ) + try: raw = sys.stdin.read() except KeyboardInterrupt: logging.error("Write mode: keyboard interrupt") - print("[Entry NOT saved to journal]", file=sys.stderr) - sys.exit(0) + raise JrnlException( + Message(MsgText.KeyboardInterruptMsg, MsgType.ERROR), + Message(MsgText.JournalNotSaved, MsgType.WARNING), + ) return raw diff --git a/jrnl/exception.py b/jrnl/exception.py index 0bafbdeb..76da211d 100644 --- a/jrnl/exception.py +++ b/jrnl/exception.py @@ -1,6 +1,7 @@ # Copyright (C) 2012-2021 jrnl contributors # License: https://www.gnu.org/licenses/gpl-3.0.html -import textwrap +from jrnl.messages import Message +from jrnl.output import print_msg class UserAbort(Exception): @@ -13,40 +14,12 @@ class UpgradeValidationException(Exception): pass -class JrnlError(Exception): +class JrnlException(Exception): """Common exceptions raised by jrnl.""" - def __init__(self, error_type, **kwargs): - self.error_type = error_type - self.message = self._get_error_message(**kwargs) + def __init__(self, *messages: Message): + self.messages = messages - def _get_error_message(self, **kwargs): - error_messages = { - "ConfigDirectoryIsFile": """ - The path to your jrnl configuration directory is a file, not a directory: - - {config_directory_path} - - Removing this file will allow jrnl to save its configuration. - """, - "LineWrapTooSmallForDateFormat": """ - The provided linewrap value of {config_linewrap} is too small by - {columns} columns to display the timestamps in the configured time - format for journal {journal}. - - You can avoid this error by specifying a linewrap value that is larger - by at least {columns} in the configuration file or by using - --config-override at the command line - """, - "CannotEncryptJournalType": """ - The journal {journal_name} can't be encrypted because it is a - {journal_type} journal. - - To encrypt it, create a new journal referencing a file, export - this journal to the new journal, then encrypt the new journal. - """, - } - - msg = error_messages[self.error_type].format(**kwargs) - msg = textwrap.dedent(msg) - return msg + def print(self) -> None: + for msg in self.messages: + print_msg(msg) diff --git a/jrnl/messages.py b/jrnl/messages.py new file mode 100644 index 00000000..eed0cbae --- /dev/null +++ b/jrnl/messages.py @@ -0,0 +1,79 @@ +from enum import Enum +from typing import NamedTuple +from typing import Mapping + + +class _MsgColor(NamedTuple): + # This is a colorama color, and colorama doesn't support enums or type hints + # see: https://github.com/tartley/colorama/issues/91 + color: str + + +class MsgType(Enum): + TITLE = _MsgColor("cyan") + NORMAL = _MsgColor("white") + WARNING = _MsgColor("yellow") + ERROR = _MsgColor("red") + + @property + def color(self) -> _MsgColor: + return self.value.color + + +class MsgText(Enum): + def __str__(self) -> str: + return self.value + + # --- Exceptions ---# + UncaughtException = """ + ERROR + {exception} + + This is probably a bug. Please file an issue at: + https://github.com/jrnl-org/jrnl/issues/new/choose + """ + + ConfigDirectoryIsFile = """ + The path to your jrnl configuration directory is a file, not a directory: + + {config_directory_path} + + Removing this file will allow jrnl to save its configuration. + """ + + LineWrapTooSmallForDateFormat = """ + The provided linewrap value of {config_linewrap} is too small by + {columns} columns to display the timestamps in the configured time + format for journal {journal}. + + You can avoid this error by specifying a linewrap value that is larger + by at least {columns} in the configuration file or by using + --config-override at the command line + """ + + CannotEncryptJournalType = """ + The journal {journal_name} can't be encrypted because it is a + {journal_type} journal. + + To encrypt it, create a new journal referencing a file, export + this journal to the new journal, then encrypt the new journal. + """ + + KeyboardInterruptMsg = "Aborted by user" + + # --- Journal status ---# + JournalNotSaved = "Entry NOT saved to journal" + + # --- Editor ---# + WritingEntryStart = """ + Writing Entry + To finish writing, press {how_to_quit} on a blank line. + """ + HowToQuitWindows = "Ctrl+z and then Enter" + HowToQuitLinux = "Ctrl+d" + + +class Message(NamedTuple): + text: MsgText + type: MsgType = MsgType.NORMAL + params: Mapping = {} diff --git a/jrnl/output.py b/jrnl/output.py index 60c5d5aa..4f28b96a 100644 --- a/jrnl/output.py +++ b/jrnl/output.py @@ -2,14 +2,16 @@ # License: https://www.gnu.org/licenses/gpl-3.0.html import logging +import sys +import textwrap + +from jrnl.color import colorize +from jrnl.color import RESET_COLOR +from jrnl.color import WARNING_COLOR +from jrnl.messages import Message def deprecated_cmd(old_cmd, new_cmd, callback=None, **kwargs): - import sys - import textwrap - - from .color import RESET_COLOR - from .color import WARNING_COLOR warning_msg = f""" The command {old_cmd} is deprecated and will be removed from jrnl soon. @@ -34,3 +36,16 @@ def list_journals(configuration): journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg ) return result + + +def print_msg(msg: Message): + msg_text = textwrap.dedent(msg.text.value.format(**msg.params)).strip().split("\n") + + longest_string = len(max(msg_text, key=len)) + msg_text = [f"[ {line:<{longest_string}} ]" for line in msg_text] + + # colorize can't be called until after the lines are padded, + # because python gets confused by the ansi color codes + msg_text[0] = f"[{colorize(msg_text[0][1:-1], msg.type.color)}]" + + print("\n".join(msg_text), file=sys.stderr) diff --git a/jrnl/plugins/fancy_exporter.py b/jrnl/plugins/fancy_exporter.py index 2cb27eca..c3dbc467 100644 --- a/jrnl/plugins/fancy_exporter.py +++ b/jrnl/plugins/fancy_exporter.py @@ -2,7 +2,10 @@ # Copyright (C) 2012-2021 jrnl contributors # License: https://www.gnu.org/licenses/gpl-3.0.html -from jrnl.exception import JrnlError +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType from textwrap import TextWrapper from .text_exporter import TextExporter @@ -40,7 +43,7 @@ class FancyExporter(TextExporter): card = [ cls.border_a + cls.border_b * (initial_linewrap) + cls.border_c + date_str ] - check_provided_linewrap_viability(linewrap, card, entry.journal) + check_provided_linewrap_viability(linewrap, card, entry.journal.name) w = TextWrapper( width=initial_linewrap, @@ -84,9 +87,14 @@ class FancyExporter(TextExporter): def check_provided_linewrap_viability(linewrap, card, journal): if len(card[0]) > linewrap: width_violation = len(card[0]) - linewrap - raise JrnlError( - "LineWrapTooSmallForDateFormat", - config_linewrap=linewrap, - columns=width_violation, - journal=journal, + raise JrnlException( + Message( + MsgText.LineWrapTooSmallForDateFormat, + MsgType.NORMAL, + { + "config_linewrap": linewrap, + "columns": width_violation, + "journal": journal, + }, + ) ) diff --git a/tests/bdd/features/write.feature b/tests/bdd/features/write.feature index 5ed1d44b..062c5fef 100644 --- a/tests/bdd/features/write.feature +++ b/tests/bdd/features/write.feature @@ -1,6 +1,6 @@ Feature: Writing new entries. - Scenario Outline: Multiline entry with punctuation should keep title punctuation + Scenario Outline: Multiline entry with punctuation should keep title punctuation Given we use the config "" And we use the password "bad doggie no biscuit" if prompted When we run "jrnl This is. the title\\n This is the second line" @@ -96,7 +96,7 @@ Feature: Writing new entries. When we run "jrnl --config-override editor ''" and enter "" Then the stdin prompt should have been called And the output should be empty - And the error output should contain "Writing Entry; on a blank line" + And the error output should contain "To finish writing, press" And the editor should not have been called Examples: configs diff --git a/tests/unit/test_exception.py b/tests/unit/test_exception.py deleted file mode 100644 index 1fee1982..00000000 --- a/tests/unit/test_exception.py +++ /dev/null @@ -1,19 +0,0 @@ -import textwrap - -from jrnl.exception import JrnlError - - -def test_config_directory_exception_message(): - ex = JrnlError( - "ConfigDirectoryIsFile", config_directory_path="/config/directory/path" - ) - - assert ex.message == textwrap.dedent( - """ - The path to your jrnl configuration directory is a file, not a directory: - - /config/directory/path - - Removing this file will allow jrnl to save its configuration. - """ - ) diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index 8bc1b410..05c29a1f 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -1,6 +1,7 @@ import pytest -from jrnl.exception import JrnlError +from jrnl.exception import JrnlException + from jrnl.plugins.fancy_exporter import check_provided_linewrap_viability @@ -23,6 +24,5 @@ class TestFancy: total_linewrap = 12 - with pytest.raises(JrnlError) as e: + with pytest.raises(JrnlException): check_provided_linewrap_viability(total_linewrap, [content], journal) - assert e.value.error_type == "LineWrapTooSmallForDateFormat"