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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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,
},
)
)

View file

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

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