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 <micah.jerome.ellison@gmail.com>
This commit is contained in:
Jonathan Wren 2022-03-12 12:43:26 -08:00 committed by GitHub
parent d6ff04cf17
commit 5273f8769d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 204 additions and 96 deletions

View file

@ -3,10 +3,15 @@
import logging import logging
import sys import sys
import traceback
from .jrnl import run from jrnl.jrnl import run
from .args import parse_args from jrnl.args import parse_args
from .exception import JrnlError 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): def configure_logger(debug=False):
@ -33,9 +38,23 @@ def cli(manual_args=None):
return run(args) return run(args)
except JrnlError as e: except JrnlException as e:
print(e.message, file=sys.stderr) e.print()
return 1 return 1
except KeyboardInterrupt: 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 return 1

View file

@ -13,7 +13,10 @@ avoid any possible overhead for these standalone commands.
""" """
import platform import platform
import sys 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(_): def preconfig_diagnostic(_):
@ -70,10 +73,15 @@ def postconfig_encrypt(args, config, original_config, **kwargs):
journal = open_journal(args.journal_name, config) journal = open_journal(args.journal_name, config)
if hasattr(journal, "can_be_encrypted") and not journal.can_be_encrypted: if hasattr(journal, "can_be_encrypted") and not journal.can_be_encrypted:
raise JrnlError( raise JrnlException(
"CannotEncryptJournalType", Message(
journal_name=args.journal_name, MsgText.CannotEncryptJournalType,
journal_type=journal.__class__.__name__, MsgType.ERROR,
{
"journal_name": args.journal_name,
"journal_type": journal.__class__.__name__,
},
)
) )
journal.config["encrypt"] = True journal.config["encrypt"] = True

View file

@ -7,7 +7,11 @@ import yaml
import xdg.BaseDirectory import xdg.BaseDirectory
from . import __version__ 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 ERROR_COLOR
from .color import RESET_COLOR from .color import RESET_COLOR
from .output import list_journals from .output import list_journals
@ -68,12 +72,18 @@ def get_config_path():
try: try:
config_directory_path = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) config_directory_path = xdg.BaseDirectory.save_config_path(XDG_RESOURCE)
except FileExistsError: except FileExistsError:
raise JrnlError( raise JrnlException(
"ConfigDirectoryIsFile", Message(
config_directory_path=os.path.join( MsgText.ConfigDirectoryIsFile,
MsgType.ERROR,
{
"config_directory_path": os.path.join(
xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE
)
},
), ),
) )
return os.path.join( return os.path.join(
config_directory_path or os.path.expanduser("~"), DEFAULT_CONFIG_NAME config_directory_path or os.path.expanduser("~"), DEFAULT_CONFIG_NAME
) )

View file

@ -6,10 +6,16 @@ import tempfile
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from .color import ERROR_COLOR from jrnl.color import ERROR_COLOR
from .color import RESET_COLOR from jrnl.color import RESET_COLOR
from .os_compat import on_windows from jrnl.os_compat import on_windows
from .os_compat import split_args 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=""): def get_text_from_editor(config, template=""):
@ -47,16 +53,25 @@ def get_text_from_editor(config, template=""):
def get_text_from_stdin(): def get_text_from_stdin():
_how_to_quit = "Ctrl+z and then Enter" if on_windows() else "Ctrl+d" print_msg(
print( Message(
f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n", MsgText.WritingEntryStart,
file=sys.stderr, MsgType.TITLE,
{
"how_to_quit": MsgText.HowToQuitWindows
if on_windows()
else MsgText.HowToQuitLinux
},
) )
)
try: try:
raw = sys.stdin.read() raw = sys.stdin.read()
except KeyboardInterrupt: except KeyboardInterrupt:
logging.error("Write mode: keyboard interrupt") logging.error("Write mode: keyboard interrupt")
print("[Entry NOT saved to journal]", file=sys.stderr) raise JrnlException(
sys.exit(0) Message(MsgText.KeyboardInterruptMsg, MsgType.ERROR),
Message(MsgText.JournalNotSaved, MsgType.WARNING),
)
return raw return raw

View file

@ -1,6 +1,7 @@
# Copyright (C) 2012-2021 jrnl contributors # Copyright (C) 2012-2021 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html # 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): class UserAbort(Exception):
@ -13,40 +14,12 @@ class UpgradeValidationException(Exception):
pass pass
class JrnlError(Exception): class JrnlException(Exception):
"""Common exceptions raised by jrnl.""" """Common exceptions raised by jrnl."""
def __init__(self, error_type, **kwargs): def __init__(self, *messages: Message):
self.error_type = error_type self.messages = messages
self.message = self._get_error_message(**kwargs)
def _get_error_message(self, **kwargs): def print(self) -> None:
error_messages = { for msg in self.messages:
"ConfigDirectoryIsFile": """ print_msg(msg)
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

79
jrnl/messages.py Normal file
View file

@ -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 = {}

View file

@ -2,14 +2,16 @@
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
import logging 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): 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""" warning_msg = f"""
The command {old_cmd} is deprecated and will be removed from jrnl soon. 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 journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg
) )
return result 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)

View file

@ -2,7 +2,10 @@
# Copyright (C) 2012-2021 jrnl contributors # Copyright (C) 2012-2021 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html # 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 textwrap import TextWrapper
from .text_exporter import TextExporter from .text_exporter import TextExporter
@ -40,7 +43,7 @@ class FancyExporter(TextExporter):
card = [ card = [
cls.border_a + cls.border_b * (initial_linewrap) + cls.border_c + date_str 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( w = TextWrapper(
width=initial_linewrap, width=initial_linewrap,
@ -84,9 +87,14 @@ class FancyExporter(TextExporter):
def check_provided_linewrap_viability(linewrap, card, journal): def check_provided_linewrap_viability(linewrap, card, journal):
if len(card[0]) > linewrap: if len(card[0]) > linewrap:
width_violation = len(card[0]) - linewrap width_violation = len(card[0]) - linewrap
raise JrnlError( raise JrnlException(
"LineWrapTooSmallForDateFormat", Message(
config_linewrap=linewrap, MsgText.LineWrapTooSmallForDateFormat,
columns=width_violation, MsgType.NORMAL,
journal=journal, {
"config_linewrap": linewrap,
"columns": width_violation,
"journal": journal,
},
)
) )

View file

@ -96,7 +96,7 @@ Feature: Writing new entries.
When we run "jrnl --config-override editor ''" and enter "" When we run "jrnl --config-override editor ''" and enter ""
Then the stdin prompt should have been called Then the stdin prompt should have been called
And the output should be empty 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 And the editor should not have been called
Examples: configs Examples: configs

View file

@ -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.
"""
)

View file

@ -1,6 +1,7 @@
import pytest import pytest
from jrnl.exception import JrnlError from jrnl.exception import JrnlException
from jrnl.plugins.fancy_exporter import check_provided_linewrap_viability from jrnl.plugins.fancy_exporter import check_provided_linewrap_viability
@ -23,6 +24,5 @@ class TestFancy:
total_linewrap = 12 total_linewrap = 12
with pytest.raises(JrnlError) as e: with pytest.raises(JrnlException):
check_provided_linewrap_viability(total_linewrap, [content], journal) check_provided_linewrap_viability(total_linewrap, [content], journal)
assert e.value.error_type == "LineWrapTooSmallForDateFormat"