From 09c04aa5dee6d2cd2607a3519e662286b4336ba4 Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 5 Feb 2022 16:05:43 -0800 Subject: [PATCH] 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 | 20 +++++++-- jrnl/commands.py | 7 ++-- jrnl/config.py | 7 ++-- jrnl/editor.py | 22 ++++++---- jrnl/exception.py | 75 ++++++++++++++++++++-------------- jrnl/output.py | 41 ++++++++++++++++--- jrnl/plugins/fancy_exporter.py | 9 ++-- tests/unit/test_exception.py | 8 ++-- tests/unit/test_export.py | 9 ++-- 9 files changed, 135 insertions(+), 63 deletions(-) diff --git a/jrnl/cli.py b/jrnl/cli.py index 6a1c6a0f..24dcab9e 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -3,10 +3,13 @@ import logging import sys +import traceback from .jrnl import run from .args import parse_args -from .exception import JrnlError +from .exception import JrnlException +from jrnl.output import print_msg +from jrnl.output import Message def configure_logger(debug=False): @@ -33,9 +36,20 @@ def cli(manual_args=None): return run(args) - except JrnlError as e: - print(e.message, file=sys.stderr) + except JrnlException as e: + print_msg(e.title, e.message, msg=Message.ERROR) return 1 except KeyboardInterrupt: + print_msg("KeyboardInterrupt", "Aborted by user", msg=Message.ERROR) + return 1 + + except Exception as e: + # uncaught exception + if args.debug: + print("\n") + traceback.print_tb(sys.exc_info()[2]) + return 1 + + print_msg(f"{type(e).__name__}\n", str(e), msg=Message.ERROR) return 1 diff --git a/jrnl/commands.py b/jrnl/commands.py index d765242a..8d203c7e 100644 --- a/jrnl/commands.py +++ b/jrnl/commands.py @@ -13,7 +13,8 @@ avoid any possible overhead for these standalone commands. """ import platform import sys -from .exception import JrnlError +from .exception import JrnlException +from .exception import JrnlExceptionMessage def preconfig_diagnostic(_): @@ -70,8 +71,8 @@ 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", + raise JrnlException( + JrnlExceptionMessage.CannotEncryptJournalType, journal_name=args.journal_name, journal_type=journal.__class__.__name__, ) diff --git a/jrnl/config.py b/jrnl/config.py index 035fb34a..b36e9df5 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -7,7 +7,8 @@ import yaml import xdg.BaseDirectory from . import __version__ -from .exception import JrnlError +from .exception import JrnlException +from .exception import JrnlExceptionMessage from .color import ERROR_COLOR from .color import RESET_COLOR from .output import list_journals @@ -68,8 +69,8 @@ def get_config_path(): try: config_directory_path = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) except FileExistsError: - raise JrnlError( - "ConfigDirectoryIsFile", + raise JrnlException( + JrnlExceptionMessage.ConfigDirectoryIsFile, config_directory_path=os.path.join( xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE ), diff --git a/jrnl/editor.py b/jrnl/editor.py index 7c7413e8..90ecb199 100644 --- a/jrnl/editor.py +++ b/jrnl/editor.py @@ -6,10 +6,13 @@ 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.output import Message +from jrnl.exception import JrnlException def get_text_from_editor(config, template=""): @@ -48,15 +51,16 @@ 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( + "Writing Entry", + f"To finish writing, press {_how_to_quit} on a blank line.", + msg=Message.NORMAL, ) 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) + print_msg("Entry NOT saved to journal", msg=Message.NORMAL) + raise JrnlException("KeyboardInterrupt") return raw diff --git a/jrnl/exception.py b/jrnl/exception.py index 0bafbdeb..3ec5666b 100644 --- a/jrnl/exception.py +++ b/jrnl/exception.py @@ -2,6 +2,8 @@ # License: https://www.gnu.org/licenses/gpl-3.0.html import textwrap +from enum import Enum + class UserAbort(Exception): pass @@ -13,40 +15,53 @@ class UpgradeValidationException(Exception): pass -class JrnlError(Exception): +class JrnlExceptionMessage(Enum): + 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. + """ + + KeyboardInterrupt = "Aborted by user" + + SomeTest = """ + Some error or something + + This is a thing to test with this message or whatever and maybe it just + keeps going forever because it's super long for no apparent reason + """ + + +class JrnlException(Exception): """Common exceptions raised by jrnl.""" - def __init__(self, error_type, **kwargs): - self.error_type = error_type + def __init__(self, exception_msg: JrnlExceptionMessage, **kwargs): + self.exception_msg = exception_msg + self.title = str(exception_msg.name) self.message = self._get_error_message(**kwargs) 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 = self.exception_msg.value + msg = msg.format(**kwargs) msg = textwrap.dedent(msg) return msg diff --git a/jrnl/output.py b/jrnl/output.py index 60c5d5aa..b67cb0a7 100644 --- a/jrnl/output.py +++ b/jrnl/output.py @@ -1,15 +1,18 @@ # Copyright (C) 2012-2021 jrnl contributors # License: https://www.gnu.org/licenses/gpl-3.0.html +from enum import Enum import logging +import sys +from collections import namedtuple +import textwrap + +from jrnl.color import colorize +from jrnl.color import RESET_COLOR +from jrnl.color import WARNING_COLOR 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 +37,31 @@ def list_journals(configuration): journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg ) return result + + +MessageProps = namedtuple("MessageProps", ["value", "color", "pipe"]) + + +class Message(Enum): + NORMAL = MessageProps(0, "cyan", sys.stderr) + WARNING = MessageProps(1, "yellow", sys.stderr) + ERROR = MessageProps(2, "red", sys.stderr) + + @property + def color(self): + return self.value.color + + @property + def pipe(self): + return self.value.pipe + + +def print_msg(title: str, body: str = "", msg: Message = Message.NORMAL): + + # @todo Add some polish around colorization of multi-line messages + result = colorize(title, msg.color) + + if body: + result += f"\n{body}" + + print(result, file=msg.pipe) diff --git a/jrnl/plugins/fancy_exporter.py b/jrnl/plugins/fancy_exporter.py index 2cb27eca..4dc7897e 100644 --- a/jrnl/plugins/fancy_exporter.py +++ b/jrnl/plugins/fancy_exporter.py @@ -2,7 +2,8 @@ # 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.exception import JrnlExceptionMessage from textwrap import TextWrapper from .text_exporter import TextExporter @@ -40,7 +41,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,8 +85,8 @@ 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", + raise JrnlException( + JrnlExceptionMessage.LineWrapTooSmallForDateFormat, config_linewrap=linewrap, columns=width_violation, journal=journal, diff --git a/tests/unit/test_exception.py b/tests/unit/test_exception.py index 1fee1982..c723a5be 100644 --- a/tests/unit/test_exception.py +++ b/tests/unit/test_exception.py @@ -1,11 +1,13 @@ import textwrap -from jrnl.exception import JrnlError +from jrnl.exception import JrnlException +from jrnl.exception import JrnlExceptionMessage def test_config_directory_exception_message(): - ex = JrnlError( - "ConfigDirectoryIsFile", config_directory_path="/config/directory/path" + ex = JrnlException( + JrnlExceptionMessage.ConfigDirectoryIsFile, + config_directory_path="/config/directory/path", ) assert ex.message == textwrap.dedent( diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index 8bc1b410..0f9a5b7d 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.exception import JrnlExceptionMessage from jrnl.plugins.fancy_exporter import check_provided_linewrap_viability @@ -23,6 +24,8 @@ class TestFancy: total_linewrap = 12 - with pytest.raises(JrnlError) as e: + with pytest.raises(JrnlException) as e: check_provided_linewrap_viability(total_linewrap, [content], journal) - assert e.value.error_type == "LineWrapTooSmallForDateFormat" + assert ( + e.value.exception_msg == JrnlExceptionMessage.LineWrapTooSmallForDateFormat + )