diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index 7354e7a2a..704a091ab 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -21,6 +21,11 @@ from .Journal import LegacyJournal from .prompt import create_password +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType + def make_key(password): password = password.encode("utf-8") @@ -53,11 +58,11 @@ def decrypt_content( password = getpass.getpass() result = decrypt_func(password) attempt += 1 - if result is not None: - return result - else: - print("Extremely wrong password.", file=sys.stderr) - sys.exit(1) + + if result is None: + raise JrnlException(Message(MsgText.PasswordMaxTriesExceeded, MsgType.ERROR)) + + return result class EncryptedJournal(Journal): @@ -121,15 +126,11 @@ def _store(self, filename, text): @classmethod def from_journal(cls, other: Journal): new_journal = super().from_journal(other) - try: - new_journal.password = ( - other.password - if hasattr(other, "password") - else create_password(other.name) - ) - except KeyboardInterrupt: - print("[Interrupted while creating new journal]", file=sys.stderr) - sys.exit(1) + new_journal.password = ( + other.password + if hasattr(other, "password") + else create_password(other.name) + ) return new_journal diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 17de129f6..bf446a927 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -431,13 +431,6 @@ def open_journal(journal_name, config, legacy=False): from . import EncryptedJournal - try: - if legacy: - return EncryptedJournal.LegacyEncryptedJournal( - journal_name, **config - ).open() - return EncryptedJournal.EncryptedJournal(journal_name, **config).open() - except KeyboardInterrupt: - # Since encrypted journals prompt for a password, it's easy for a user to ctrl+c out - print("[Interrupted while opening journal]", file=sys.stderr) - sys.exit(1) + if legacy: + return EncryptedJournal.LegacyEncryptedJournal(journal_name, **config).open() + return EncryptedJournal.EncryptedJournal(journal_name, **config).open() diff --git a/jrnl/cli.py b/jrnl/cli.py index 03b4f2f0c..cd33f2ec7 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -5,9 +5,10 @@ import sys import traceback -from jrnl.jrnl import run -from jrnl.args import parse_args +from .jrnl import run +from .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 @@ -36,25 +37,40 @@ def cli(manual_args=None): configure_logger(args.debug) logging.debug("Parsed args: %s", args) - return run(args) + status_code = run(args) except JrnlException as e: + status_code = 1 e.print() - return 1 except KeyboardInterrupt: - print_msg(Message(MsgText.KeyboardInterruptMsg, MsgType.WARNING)) - return 1 + status_code = 1 + print_msg("\nKeyboardInterrupt", "\nAborted by user", msg=Message.ERROR) except Exception as e: + # uncaught exception + status_code = 1 + debug = False try: - is_debug = args.debug # type: ignore + if args.debug: # type: ignore + debug = True except NameError: - # error happened before args were parsed - is_debug = "--debug" in sys.argv[1:] + # This should only happen when the exception + # happened before the args were parsed + if "--debug" in sys.argv: + debug = True - if is_debug: + if debug: + print("\n") traceback.print_tb(sys.exc_info()[2]) - print_msg(Message(MsgText.UncaughtException, MsgType.ERROR, {"exception": e})) - return 1 + print_msg( + Message( + MsgText.UncaughtException, + MsgType.ERROR, + {"name": type(e).__name__, "exception": e}, + ) + ) + + # This should be the only exit point + return status_code diff --git a/jrnl/config.py b/jrnl/config.py index 63de05867..2b07b14bb 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -197,9 +197,13 @@ def get_journal_name(args, config): args.text = args.text[1:] if args.journal_name not in config["journals"]: - print("No default journal configured.", file=sys.stderr) - print(list_journals(config), file=sys.stderr) - sys.exit(1) + raise JrnlException( + Message( + MsgText.NoDefaultJournal, + MsgType.ERROR, + {"journals": list_journals(config)}, + ), + ) logging.debug("Using journal name: %s", args.journal_name) return args diff --git a/jrnl/editor.py b/jrnl/editor.py index 81ca659ac..24c625de5 100644 --- a/jrnl/editor.py +++ b/jrnl/editor.py @@ -3,11 +3,8 @@ import subprocess import sys import tempfile -import textwrap from pathlib import Path -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 @@ -32,22 +29,21 @@ def get_text_from_editor(config, template=""): try: subprocess.call(split_args(config["editor"]) + [tmpfile]) - except FileNotFoundError as e: - error_msg = f""" - {ERROR_COLOR}{str(e)}{RESET_COLOR} - - Please check the 'editor' key in your config file for errors: - {repr(config['editor'])} - """ - print(textwrap.dedent(error_msg).strip(), file=sys.stderr) - exit(1) + except FileNotFoundError: + raise JrnlException( + Message( + MsgText.EditorMisconfigured, + MsgType.ERROR, + {"editor_key": config["editor"]}, + ) + ) with open(tmpfile, "r", encoding="utf-8") as f: raw = f.read() os.remove(tmpfile) if not raw: - print("[Nothing saved to file]", file=sys.stderr) + raise JrnlException(Message(MsgText.NoTextReceived, MsgType.ERROR)) return raw diff --git a/jrnl/exception.py b/jrnl/exception.py index 76da211db..fdfa61a44 100644 --- a/jrnl/exception.py +++ b/jrnl/exception.py @@ -4,16 +4,6 @@ from jrnl.output import print_msg -class UserAbort(Exception): - pass - - -class UpgradeValidationException(Exception): - """Raised when the contents of an upgraded journal do not match the old journal""" - - pass - - class JrnlException(Exception): """Common exceptions raised by jrnl.""" diff --git a/jrnl/install.py b/jrnl/install.py index b2b583cfb..306b44e15 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -14,10 +14,14 @@ from .config import load_config from .config import save_config from .config import verify_config_colors -from .exception import UserAbort from .prompt import yesno from .upgrade import is_old_version +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType + def upgrade_config(config_data, alt_config_path=None): """Checks if there are keys missing in a given config dict, and if so, updates the config file accordingly. @@ -47,14 +51,14 @@ def find_default_config(): def find_alt_config(alt_config): - if os.path.exists(alt_config): - return alt_config - else: - print( - "Alternate configuration file not found at path specified.", file=sys.stderr + if not os.path.exists(alt_config): + raise JrnlException( + Message( + MsgText.AltConfigNotFound, MsgType.ERROR, {"config_file": alt_config} + ) ) - print("Exiting.", file=sys.stderr) - sys.exit(1) + + return alt_config def load_or_install_jrnl(alt_config_path): @@ -72,32 +76,16 @@ def load_or_install_jrnl(alt_config_path): config = load_config(config_path) if is_old_version(config_path): - from . import upgrade - - try: - upgrade.upgrade_jrnl(config_path) - except upgrade.UpgradeValidationException: - print("Aborting upgrade.", file=sys.stderr) - print( - "Please tell us about this problem at the following URL:", - file=sys.stderr, - ) - print( - "https://github.com/jrnl-org/jrnl/issues/new?title=UpgradeValidationException", - file=sys.stderr, - ) - print("Exiting.", file=sys.stderr) - sys.exit(1) + from jrnl import upgrade + + upgrade.upgrade_jrnl(config_path) upgrade_config(config, alt_config_path) verify_config_colors(config) else: logging.debug("Configuration file not found, installing jrnl...") - try: - config = install() - except KeyboardInterrupt: - raise UserAbort("Installation aborted") + config = install() logging.debug('Using configuration "%s"', config) return config diff --git a/jrnl/jrnl.py b/jrnl/jrnl.py index 232eb7028..8014b628e 100644 --- a/jrnl/jrnl.py +++ b/jrnl/jrnl.py @@ -7,17 +7,19 @@ from . import install from . import plugins from .Journal import open_journal -from .color import ERROR_COLOR -from .color import RESET_COLOR from .config import get_journal_name from .config import scope_config from .config import get_config_path from .editor import get_text_from_editor from .editor import get_text_from_stdin -from .exception import UserAbort from . import time from .override import apply_overrides +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType + def run(args): """ @@ -35,18 +37,14 @@ def run(args): return args.preconfig_cmd(args) # Load the config, and extract journal name - try: - config = install.load_or_install_jrnl(args.config_file_path) - original_config = config.copy() + config = install.load_or_install_jrnl(args.config_file_path) + original_config = config.copy() - # Apply config overrides - config = apply_overrides(args, config) + # Apply config overrides + config = apply_overrides(args, config) - args = get_journal_name(args, config) - config = scope_config(config, args.journal_name) - except UserAbort as err: - print(f"\n{err}", file=sys.stderr) - sys.exit(1) + args = get_journal_name(args, config) + config = scope_config(config, args.journal_name) # Run post-config command now that config is ready if callable(args.postconfig_cmd): @@ -138,7 +136,9 @@ def write_mode(args, config, journal, **kwargs): if not raw: logging.error("Write mode: couldn't get raw text") - sys.exit() + raise JrnlException( + Message(MsgText.JrnlExceptionMessage.NoTextReceived, MsgType.ERROR) + ) logging.debug( 'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw @@ -202,11 +202,13 @@ def _get_editor_template(config, **kwargs): logging.debug("Write mode: template loaded: %s", template) except OSError: logging.error("Write mode: template not loaded") - print( - f"[Could not read template at '{config['template']}']", - file=sys.stderr, + raise JrnlException( + Message( + MsgText.CantReadTemplate, + MsgType.ERROR, + {"template": config["template"]}, + ) ) - sys.exit(1) return template @@ -243,16 +245,13 @@ def _edit_search_results(config, journal, old_entries, **kwargs): 3. Write modifications to journal """ if not config["editor"]: - print( - f""" - [{ERROR_COLOR}ERROR{RESET_COLOR}: There is no editor configured.] - - Please specify an editor in config file ({get_config_path()}) - to use the --edit option. - """, - file=sys.stderr, + raise JrnlException( + Message( + MsgText.EditorNotConfigured, + MsgType.ERROR, + {"config_file": get_config_path()}, + ) ) - sys.exit(1) # separate entries we are not editing other_entries = [e for e in old_entries if e not in journal.entries] @@ -310,11 +309,7 @@ def _pluralize_entry(num): def _delete_search_results(journal, old_entries, **kwargs): if not journal.entries: - print( - "[No entries deleted, because the search returned no results.]", - file=sys.stderr, - ) - sys.exit(1) + raise JrnlException(Message(MsgText.NothingToDelete, MsgType.ERROR)) entries_to_delete = journal.prompt_delete_entries() diff --git a/jrnl/messages.py b/jrnl/messages.py index eed0cbaef..e6a1933a7 100644 --- a/jrnl/messages.py +++ b/jrnl/messages.py @@ -26,7 +26,7 @@ def __str__(self) -> str: # --- Exceptions ---# UncaughtException = """ - ERROR + {name} {exception} This is probably a bug. Please file an issue at: @@ -61,6 +61,14 @@ def __str__(self) -> str: KeyboardInterruptMsg = "Aborted by user" + CantReadTemplate = """ + Unreadable template + Could not read template file at: + {template} + """ + + NoDefaultJournal = "No default journal configured\n{journals}" + # --- Journal status ---# JournalNotSaved = "Entry NOT saved to journal" @@ -72,6 +80,56 @@ def __str__(self) -> str: HowToQuitWindows = "Ctrl+z and then Enter" HowToQuitLinux = "Ctrl+d" + EditorMisconfigured = """ + No such file or directory: '{editor_key}' + + Please check the 'editor' key in your config file for errors: + editor: '{editor_key}' + """ + + EditorNotConfigured = """ + There is no editor configured + + To use the --edit option, please specify an editor your config file: + {config_file} + + For examples of how to configure an external editor, see: + https://jrnl.sh/en/stable/external-editors/ + """ + + NoTextReceived = """ + Nothing saved to file + """ + + # --- Upgrade --- # + JournalFailedUpgrade = """ + The following journal{s} failed to upgrade: + {failed_journals} + + Please tell us about this problem at the following URL: + https://github.com/jrnl-org/jrnl/issues/new?title=JournalFailedUpgrade + """ + + UpgradeAborted = "jrnl was NOT upgraded" + + ImportAborted = "Entries were NOT imported" + + # -- Config --- # + AltConfigNotFound = """ + Alternate configuration file not found at the given path: + {config_file} + """ + + # --- Password --- # + PasswordMaxTriesExceeded = """ + Too many attempts with wrong password + """ + + # --- Search --- # + NothingToDelete = """ + No entries to delete, because the search returned no results + """ + class Message(NamedTuple): text: MsgText diff --git a/jrnl/output.py b/jrnl/output.py index 4f28b96ab..f31a02e26 100644 --- a/jrnl/output.py +++ b/jrnl/output.py @@ -29,7 +29,7 @@ def list_journals(configuration): from . import config """List the journals specified in the configuration file""" - result = f"Journals defined in {config.get_config_path()}\n" + result = f"Journals defined in config ({config.get_config_path()})\n" ml = min(max(len(k) for k in configuration["journals"]), 20) for journal, cfg in configuration["journals"].items(): result += " * {:{}} -> {}\n".format( diff --git a/jrnl/plugins/jrnl_importer.py b/jrnl/plugins/jrnl_importer.py index 214fc70b4..54dd2ab83 100644 --- a/jrnl/plugins/jrnl_importer.py +++ b/jrnl/plugins/jrnl_importer.py @@ -4,6 +4,11 @@ import sys +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgType + class JRNLImporter: """This plugin imports entries from other jrnl files.""" @@ -22,8 +27,11 @@ def import_(journal, input=None): try: other_journal_txt = sys.stdin.read() except KeyboardInterrupt: - print("[Entries NOT imported into journal.]", file=sys.stderr) - sys.exit(0) + raise JrnlException( + Message(MsgText.KeyboardInterruptMsg, MsgType.ERROR), + Message(MsgText.ImportAborted, MsgType.WARNING), + ) + journal.import_(other_journal_txt) new_cnt = len(journal.entries) print( diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py index 158f8de3f..3027f4e7e 100644 --- a/jrnl/upgrade.py +++ b/jrnl/upgrade.py @@ -10,10 +10,15 @@ from .config import is_config_json from .config import load_config from .config import scope_config -from .exception import UpgradeValidationException -from .exception import UserAbort from .prompt import yesno +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 backup(filename, binary=False): print(f" Created a backup at {filename}.backup", file=sys.stderr) @@ -27,13 +32,9 @@ def backup(filename, binary=False): backup.write(contents) except FileNotFoundError: print(f"\nError: {filename} does not exist.") - try: - cont = yesno(f"\nCreate {filename}?", default=False) - if not cont: - raise KeyboardInterrupt - - except KeyboardInterrupt: - raise UserAbort("jrnl NOT upgraded, exiting.") + cont = yesno(f"\nCreate {filename}?", default=False) + if not cont: + raise JrnlException(Message(MsgText.UpgradeAborted), MsgType.WARNING) def check_exists(path): @@ -121,12 +122,9 @@ def upgrade_jrnl(config_path): file=sys.stderr, ) - try: - cont = yesno("\nContinue upgrading jrnl?", default=False) - if not cont: - raise KeyboardInterrupt - except KeyboardInterrupt: - raise UserAbort("jrnl NOT upgraded, exiting.") + cont = yesno("\nContinue upgrading jrnl?", default=False) + if not cont: + raise JrnlException(Message(MsgText.UpgradeAborted), MsgType.WARNING) for journal_name, path in encrypted_journals.items(): print( @@ -154,16 +152,19 @@ def upgrade_jrnl(config_path): failed_journals = [j for j in all_journals if not j.validate_parsing()] if len(failed_journals) > 0: - print( - "\nThe following journal{} failed to upgrade:\n{}".format( - "s" if len(failed_journals) > 1 else "", - "\n".join(j.name for j in failed_journals), - ), - file=sys.stderr, + print_msg("Aborting upgrade.", msg=Message.NORMAL) + + raise JrnlException( + Message( + MsgText.JournalFailedUpgrade, + MsgType.ERROR, + { + "s": "s" if len(failed_journals) > 1 else "", + "failed_journals": "\n".join(j.name for j in failed_journals), + }, + ) ) - raise UpgradeValidationException - # write all journals - or - don't for j in all_journals: j.write() diff --git a/tests/bdd/features/delete.feature b/tests/bdd/features/delete.feature index cfbe08ee2..fe323966a 100644 --- a/tests/bdd/features/delete.feature +++ b/tests/bdd/features/delete.feature @@ -41,6 +41,7 @@ Feature: Delete entries from journal Scenario Outline: Delete flag with nonsense input deletes nothing (issue #932) Given we use the config "" When we run "jrnl --delete asdfasdf" + Then the output should contain "No entries to delete" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. diff --git a/tests/bdd/features/write.feature b/tests/bdd/features/write.feature index 062c5fef6..60f220021 100644 --- a/tests/bdd/features/write.feature +++ b/tests/bdd/features/write.feature @@ -78,7 +78,7 @@ Feature: Writing new entries. And we write nothing to the editor if opened And we use the password "test" if prompted When we run "jrnl --edit" - Then the error output should contain "[Nothing saved to file]" + Then the error output should contain "Nothing saved to file" And the editor should have been called Examples: configs diff --git a/tests/unit/test_config_file.py b/tests/unit/test_config_file.py index 04766f4a3..f9cdb7ecd 100644 --- a/tests/unit/test_config_file.py +++ b/tests/unit/test_config_file.py @@ -2,6 +2,7 @@ import os from jrnl.install import find_alt_config +from jrnl.exception import JrnlException def test_find_alt_config(request): @@ -14,9 +15,9 @@ def test_find_alt_config(request): def test_find_alt_config_not_exist(request): bad_config_path = os.path.join( - request.fspath.dirname, "..", "data", "configs", "not-existing-config.yaml" + request.fspath.dirname, "..", "data", "configs", "does-not-exist.yaml" ) - with pytest.raises(SystemExit) as ex: + with pytest.raises(JrnlException) as ex: found_alt_config = find_alt_config(bad_config_path) assert found_alt_config is not None - assert isinstance(ex.value, SystemExit) + assert isinstance(ex.value, JrnlException) diff --git a/tests/unit/test_export.py b/tests/unit/test_export.py index 05c29a1f7..1ca8856f3 100644 --- a/tests/unit/test_export.py +++ b/tests/unit/test_export.py @@ -1,7 +1,6 @@ import pytest from jrnl.exception import JrnlException - from jrnl.plugins.fancy_exporter import check_provided_linewrap_viability