From f53110c69be8a8ebfa806ee85c4683f09320ac6e Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 11 Jun 2022 13:32:11 -0700 Subject: [PATCH] Rework how all output and messaging works in jrnl (#1475) * fix missed statement from last PR * replace print statement for adding an entry to a journal * clean up linting and format * change print statement over to new print_msg function * make print_msg always print to stderr * change print statement over to new print_msg function * update importer to use new message function * update yaml format to use new message function * code cleanup * update yaml format to use new message function * update yaml format to use new exception handling * update Journal class to use new message function * update install module to use new message function * update config module to use new message function * update upgrade module to properly use new message and exception handling * fix typo * update upgrade module to use new message handling * update welcome message to use new handling * update upgrade module to use new message handling * update upgrade module journal summaries to use new message handling * take out old code * update upgrade module to use new message handling * update upgrade module to use new message handling * update more modules to use new message handling * take out old comment * update deprecated_cmd to use new message handling * update text_exporter with new message handling, get rid of old color constants * get rid of hardcoded text * whitespace changes * rework MsgType into MsgStyle so messages can have different styles * add comment * Move around code to separate concerns of each function a bit more * update create_password and yesno prompt functions for new messaging * fix missing newline for keyboard interrupts * fix misc linting * fix bug with panel titles always showing 'error' after one error * fix missing import * update debug output after uncaught exception * update exception for new exception handling * rewrite yesno function to use new centralized messages * reduce the debug output slightly * clean up print_msgs function * clean up create_password function * clean up misc linting * rename screen_input to hide_input to be more clear * update encrypted journal prompt to use new messaging functionality * fix typo in message key * move rich console into function so we can mock properly * update password mock to use rich console instead of getpass * add more helpful output to then step * fix test by updating expected output * update message to use new functionality * rework mocks in test suite for new messaging functionality * fix linting issue * fix more tests * fix more tests * fix more tests * fix more tests * fix merge bug * update prompt_action_entries to use new messaging functionality * Add new input_method "type" This does the same thing as input_method "pipe" but is more clear what it's doing (typing text into the builtin composer) * get rid of old commented code * get rid of unused code * move some files around Co-authored-by: Micah Jerome Ellison --- jrnl/EncryptedJournal.py | 45 ++-- jrnl/FolderJournal.py | 1 - jrnl/Journal.py | 43 +++- jrnl/cli.py | 16 +- jrnl/color.py | 4 - jrnl/commands.py | 24 +- jrnl/config.py | 26 +- jrnl/editor.py | 12 +- jrnl/install.py | 43 ++-- jrnl/jrnl.py | 63 +++-- jrnl/messages.py | 141 ----------- jrnl/messages/Message.py | 11 + jrnl/messages/MsgStyle.py | 89 +++++++ jrnl/messages/MsgText.py | 248 +++++++++++++++++++ jrnl/messages/__init__.py | 7 + jrnl/output.py | 85 +++++-- jrnl/plugins/fancy_exporter.py | 4 +- jrnl/plugins/jrnl_importer.py | 21 +- jrnl/plugins/markdown_exporter.py | 19 +- jrnl/plugins/text_exporter.py | 48 ++-- jrnl/plugins/yaml_exporter.py | 33 ++- jrnl/prompt.py | 63 +++-- jrnl/upgrade.py | 157 +++++++----- pyproject.toml | 1 + tests/bdd/features/config_file.feature | 2 +- tests/bdd/features/datetime.feature | 2 +- tests/bdd/features/encrypt.feature | 6 +- tests/bdd/features/format.feature | 2 +- tests/bdd/features/multiple_journals.feature | 2 +- tests/bdd/features/override.feature | 2 +- tests/bdd/features/star.feature | 3 +- tests/bdd/features/upgrade.feature | 4 +- tests/bdd/features/write.feature | 16 +- tests/lib/fixtures.py | 81 +++--- tests/lib/given_steps.py | 6 +- tests/lib/then_steps.py | 17 +- tests/lib/when_steps.py | 8 +- tests/unit/test_output.py | 27 ++ 38 files changed, 912 insertions(+), 470 deletions(-) delete mode 100644 jrnl/messages.py create mode 100644 jrnl/messages/Message.py create mode 100644 jrnl/messages/MsgStyle.py create mode 100644 jrnl/messages/MsgText.py create mode 100644 jrnl/messages/__init__.py create mode 100644 tests/unit/test_output.py diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index 704a091a..fb4ba5ce 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -1,9 +1,7 @@ import base64 -import getpass import hashlib import logging import os -import sys from typing import Callable from typing import Optional @@ -24,7 +22,8 @@ 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 +from jrnl.messages import MsgStyle +from jrnl.output import print_msg def make_key(password): @@ -46,21 +45,26 @@ def decrypt_content( keychain: str = None, max_attempts: int = 3, ) -> str: + def get_pw(): + return print_msg( + Message(MsgText.Password, MsgStyle.PROMPT), get_input=True, hide_input=True + ) + pwd_from_keychain = keychain and get_keychain(keychain) - password = pwd_from_keychain or getpass.getpass() + password = pwd_from_keychain or get_pw() result = decrypt_func(password) # Password is bad: if result is None and pwd_from_keychain: set_keychain(keychain, None) attempt = 1 while result is None and attempt < max_attempts: - print("Wrong password, try again.", file=sys.stderr) - password = getpass.getpass() + print_msg(Message(MsgText.WrongPasswordTryAgain, MsgStyle.WARNING)) + password = get_pw() result = decrypt_func(password) attempt += 1 if result is None: - raise JrnlException(Message(MsgText.PasswordMaxTriesExceeded, MsgType.ERROR)) + raise JrnlException(Message(MsgText.PasswordMaxTriesExceeded, MsgStyle.ERROR)) return result @@ -79,13 +83,22 @@ class EncryptedJournal(Journal): if not os.path.exists(filename): if not os.path.isdir(dirname): os.makedirs(dirname) - print(f"[Directory {dirname} created]", file=sys.stderr) + print_msg( + Message( + MsgText.DirectoryCreated, + MsgStyle.NORMAL, + {"directory_name": dirname}, + ) + ) self.create_file(filename) self.password = create_password(self.name) - print( - f"Encrypted journal '{self.name}' created at {filename}", - file=sys.stderr, + print_msg( + Message( + MsgText.JournalCreated, + MsgStyle.NORMAL, + {"journal_name": self.name, "filename": filename}, + ) ) text = self._load(filename) @@ -179,7 +192,7 @@ def get_keychain(journal_name): return keyring.get_password("jrnl", journal_name) except keyring.errors.KeyringError as e: if not isinstance(e, keyring.errors.NoKeyringError): - print("Failed to retrieve keyring", file=sys.stderr) + print_msg(Message(MsgText.KeyringRetrievalFailure, MsgStyle.ERROR)) return "" @@ -196,9 +209,7 @@ def set_keychain(journal_name, password): keyring.set_password("jrnl", journal_name, password) except keyring.errors.KeyringError as e: if isinstance(e, keyring.errors.NoKeyringError): - print( - "Keyring backend not found. Please install one of the supported backends by visiting: https://pypi.org/project/keyring/", - file=sys.stderr, - ) + msg = Message(MsgText.KeyringBackendNotFound, MsgStyle.WARNING) else: - print("Failed to retrieve keyring", file=sys.stderr) + msg = Message(MsgText.KeyringRetrievalFailure, MsgStyle.ERROR) + print_msg(msg) diff --git a/jrnl/FolderJournal.py b/jrnl/FolderJournal.py index 62b7ade5..a381c872 100644 --- a/jrnl/FolderJournal.py +++ b/jrnl/FolderJournal.py @@ -81,7 +81,6 @@ class Folder(Journal.Journal): filenames = get_files(self.config["journal"]) for filename in filenames: if os.stat(filename).st_size <= 0: - # print("empty file: {}".format(filename)) os.remove(filename) def delete_entries(self, entries_to_delete): diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 5a2f0b5a..b2e5383b 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -6,13 +6,17 @@ import datetime import logging import os import re -import sys from . import Entry from . import time from .prompt import yesno from .path import expand_path +from jrnl.output import print_msg +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgStyle + class Tag: def __init__(self, name, count=0): @@ -83,9 +87,24 @@ class Journal: if not os.path.exists(filename): if not os.path.isdir(dirname): os.makedirs(dirname) - print(f"[Directory {dirname} created]", file=sys.stderr) + print_msg( + Message( + MsgText.DirectoryCreated, + MsgStyle.NORMAL, + {"directory_name": dirname}, + ) + ) self.create_file(filename) - print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr) + print_msg( + Message( + MsgText.JournalCreated, + MsgStyle.NORMAL, + { + "journal_name": self.name, + "filename": filename, + }, + ) + ) text = self._load(filename) self.entries = self._parse(text) @@ -269,14 +288,17 @@ class Journal: for entry in self.entries: entry.date = date - def prompt_action_entries(self, message): + def prompt_action_entries(self, msg: MsgText): """Prompts for action for each entry in a journal, using given message. Returns the entries the user wishes to apply the action on.""" to_act = [] def ask_action(entry): return yesno( - f"{message} '{entry.pprint(short=True)}'?", + Message( + msg, + params={"entry_title": entry.pprint(short=True)}, + ), default=False, ) @@ -415,9 +437,14 @@ def open_journal(journal_name, config, legacy=False): if os.path.isdir(config["journal"]): if config["encrypt"]: - print( - "Warning: This journal's config has 'encrypt' set to true, but this type of journal can't be encrypted.", - file=sys.stderr, + print_msg( + Message( + MsgText.ConfigEncryptedForUnencryptableJournalType, + MsgStyle.WARNING, + { + "journal_name": journal_name, + }, + ) ) if config["journal"].strip("/").endswith(".dayone") or "entries" in os.listdir( diff --git a/jrnl/cli.py b/jrnl/cli.py index cd33f2ec..305e4502 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -12,7 +12,7 @@ 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 +from jrnl.messages import MsgStyle def configure_logger(debug=False): @@ -45,7 +45,13 @@ def cli(manual_args=None): except KeyboardInterrupt: status_code = 1 - print_msg("\nKeyboardInterrupt", "\nAborted by user", msg=Message.ERROR) + + print_msg( + Message( + MsgText.KeyboardInterruptMsg, + MsgStyle.ERROR_ON_NEW_LINE, + ) + ) except Exception as e: # uncaught exception @@ -61,13 +67,15 @@ def cli(manual_args=None): debug = True if debug: - print("\n") + from rich.console import Console + traceback.print_tb(sys.exc_info()[2]) + Console(stderr=True).print_exception(extra_lines=1) print_msg( Message( MsgText.UncaughtException, - MsgType.ERROR, + MsgStyle.ERROR, {"name": type(e).__name__, "exception": e}, ) ) diff --git a/jrnl/color.py b/jrnl/color.py index 691cce9c..7cc8df2f 100644 --- a/jrnl/color.py +++ b/jrnl/color.py @@ -9,10 +9,6 @@ from .os_compat import on_windows if on_windows(): colorama.init() -WARNING_COLOR = colorama.Fore.YELLOW -ERROR_COLOR = colorama.Fore.RED -RESET_COLOR = colorama.Fore.RESET - def colorize(string, color, bold=False): """Returns the string colored with colorama.Fore.color. If the color set by diff --git a/jrnl/commands.py b/jrnl/commands.py index 87d3981d..7ef9f09c 100644 --- a/jrnl/commands.py +++ b/jrnl/commands.py @@ -13,10 +13,12 @@ avoid any possible overhead for these standalone commands. """ import platform import sys + +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 +from jrnl.messages import MsgStyle from jrnl.prompt import create_password @@ -77,7 +79,7 @@ def postconfig_encrypt(args, config, original_config, **kwargs): raise JrnlException( Message( MsgText.CannotEncryptJournalType, - MsgType.ERROR, + MsgStyle.ERROR, { "journal_name": args.journal_name, "journal_type": journal.__class__.__name__, @@ -95,9 +97,12 @@ def postconfig_encrypt(args, config, original_config, **kwargs): journal.config["encrypt"] = True new_journal.write(args.filename) - print( - f"Journal encrypted to {args.filename or new_journal.config['journal']}.", - file=sys.stderr, + print_msg( + Message( + MsgText.JournalEncryptedTo, + MsgStyle.NORMAL, + {"path": args.filename or new_journal.config["journal"]}, + ) ) # Update the config, if we encrypted in place @@ -120,9 +125,12 @@ def postconfig_decrypt(args, config, original_config, **kwargs): new_journal = PlainJournal.from_journal(journal) new_journal.write(args.filename) - print( - f"Journal decrypted to {args.filename or new_journal.config['journal']}.", - file=sys.stderr, + print_msg( + Message( + MsgText.JournalDecryptedTo, + MsgStyle.NORMAL, + {"path": args.filename or new_journal.config["journal"]}, + ) ) # Update the config, if we decrypted in place diff --git a/jrnl/config.py b/jrnl/config.py index 67284c4a..c9b860bb 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -1,20 +1,18 @@ import logging import os -import sys import colorama from ruamel.yaml import YAML import xdg.BaseDirectory from . import __version__ +from jrnl.output import list_journals +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 +from jrnl.messages import MsgStyle -from .color import ERROR_COLOR -from .color import RESET_COLOR -from .output import list_journals from .path import home_dir # Constants @@ -75,7 +73,7 @@ def get_config_path(): raise JrnlException( Message( MsgText.ConfigDirectoryIsFile, - MsgType.ERROR, + MsgStyle.ERROR, { "config_directory_path": os.path.join( xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE @@ -143,11 +141,15 @@ def verify_config_colors(config): if upper_color == "NONE": continue if not getattr(colorama.Fore, upper_color, None): - print( - "[{2}ERROR{3}: {0} set to invalid color: {1}]".format( - key, color, ERROR_COLOR, RESET_COLOR - ), - file=sys.stderr, + print_msg( + Message( + MsgText.InvalidColor, + MsgStyle.NORMAL, + { + "key": key, + "color": color, + }, + ) ) all_valid_colors = False return all_valid_colors @@ -197,7 +199,7 @@ def get_journal_name(args, config): raise JrnlException( Message( MsgText.NoDefaultJournal, - MsgType.ERROR, + MsgStyle.ERROR, {"journals": list_journals(config)}, ), ) diff --git a/jrnl/editor.py b/jrnl/editor.py index 24c625de..91aa4d52 100644 --- a/jrnl/editor.py +++ b/jrnl/editor.py @@ -12,7 +12,7 @@ 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 +from jrnl.messages import MsgStyle def get_text_from_editor(config, template=""): @@ -33,7 +33,7 @@ def get_text_from_editor(config, template=""): raise JrnlException( Message( MsgText.EditorMisconfigured, - MsgType.ERROR, + MsgStyle.ERROR, {"editor_key": config["editor"]}, ) ) @@ -43,7 +43,7 @@ def get_text_from_editor(config, template=""): os.remove(tmpfile) if not raw: - raise JrnlException(Message(MsgText.NoTextReceived, MsgType.ERROR)) + raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.ERROR)) return raw @@ -52,7 +52,7 @@ def get_text_from_stdin(): print_msg( Message( MsgText.WritingEntryStart, - MsgType.TITLE, + MsgStyle.TITLE, { "how_to_quit": MsgText.HowToQuitWindows if on_windows() @@ -66,8 +66,8 @@ def get_text_from_stdin(): except KeyboardInterrupt: logging.error("Write mode: keyboard interrupt") raise JrnlException( - Message(MsgText.KeyboardInterruptMsg, MsgType.ERROR), - Message(MsgText.JournalNotSaved, MsgType.WARNING), + Message(MsgText.KeyboardInterruptMsg, MsgStyle.ERROR_ON_NEW_LINE), + Message(MsgText.JournalNotSaved, MsgStyle.WARNING), ) return raw diff --git a/jrnl/install.py b/jrnl/install.py index 0e29eb48..88663d58 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -20,10 +20,11 @@ from .config import verify_config_colors from .prompt import yesno from .upgrade import is_old_version +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 +from jrnl.messages import MsgStyle def upgrade_config(config_data, alt_config_path=None): @@ -38,9 +39,10 @@ def upgrade_config(config_data, alt_config_path=None): config_data[key] = default_config[key] save_config(config_data, alt_config_path) config_path = alt_config_path if alt_config_path else get_config_path() - print( - f"[Configuration updated to newest version at {config_path}]", - file=sys.stderr, + print_msg( + Message( + MsgText.ConfigUpdated, MsgStyle.NORMAL, {"config_path": config_path} + ) ) @@ -57,7 +59,7 @@ def find_alt_config(alt_config): if not os.path.exists(alt_config): raise JrnlException( Message( - MsgText.AltConfigNotFound, MsgType.ERROR, {"config_file": alt_config} + MsgText.AltConfigNotFound, MsgStyle.ERROR, {"config_file": alt_config} ) ) @@ -79,8 +81,15 @@ def load_or_install_jrnl(alt_config_path): config = load_config(config_path) if config is None: - print("Unable to parse config file", file=sys.stderr) - sys.exit() + raise JrnlException( + Message( + MsgText.CantParseConfigFile, + MsgStyle.ERROR, + { + "config_path": config_path, + }, + ) + ) if is_old_version(config_path): from jrnl import upgrade @@ -103,8 +112,17 @@ def install(): # Where to create the journal? default_journal_path = get_default_journal_path() - path_query = f"Path to your journal file (leave blank for {default_journal_path}): " - journal_path = absolute_path(input(path_query).strip() or default_journal_path) + user_given_path = print_msg( + Message( + MsgText.InstallJournalPathQuestion, + MsgStyle.PROMPT, + params={ + "default_journal_path": default_journal_path, + }, + ), + get_input=True, + ) + journal_path = absolute_path(user_given_path or default_journal_path) default_config = get_default_config() default_config["journals"][DEFAULT_JOURNAL_KEY] = journal_path @@ -116,13 +134,10 @@ def install(): pass # Encrypt it? - encrypt = yesno( - "Do you want to encrypt your journal? You can always change this later", - default=False, - ) + encrypt = yesno(Message(MsgText.EncryptJournalQuestion), default=False) if encrypt: default_config["encrypt"] = True - print("Journal will be encrypted.", file=sys.stderr) + print_msg(Message(MsgText.JournalEncrypted, MsgStyle.NORMAL)) save_config(default_config) return default_config diff --git a/jrnl/jrnl.py b/jrnl/jrnl.py index cd69a124..18ee4ed4 100644 --- a/jrnl/jrnl.py +++ b/jrnl/jrnl.py @@ -14,12 +14,14 @@ from .editor import get_text_from_editor from .editor import get_text_from_stdin from . import time from .override import apply_overrides +from jrnl.output import print_msg +from jrnl.output import print_msgs from .path import expand_path from jrnl.exception import JrnlException from jrnl.messages import Message from jrnl.messages import MsgText -from jrnl.messages import MsgType +from jrnl.messages import MsgStyle def run(args): @@ -139,13 +141,19 @@ def write_mode(args, config, journal, **kwargs): if not raw or raw.isspace(): logging.error("Write mode: couldn't get raw text or entry was empty") - raise JrnlException(Message(MsgText.NoTextReceived, MsgType.ERROR)) + raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.ERROR)) logging.debug( 'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw ) journal.new_entry(raw) - print(f"[Entry added to {args.journal_name} journal]", file=sys.stderr) + print_msg( + Message( + MsgText.JournalEntryAdded, + MsgStyle.NORMAL, + {"journal_name": args.journal_name}, + ) + ) journal.write() logging.debug("Write mode: completed journal.write()") @@ -229,7 +237,7 @@ def _get_editor_template(config, **kwargs): raise JrnlException( Message( MsgText.CantReadTemplate, - MsgType.ERROR, + MsgStyle.ERROR, {"template": template_path}, ) ) @@ -277,7 +285,7 @@ def _edit_search_results(config, journal, old_entries, **kwargs): raise JrnlException( Message( MsgText.EditorNotConfigured, - MsgType.ERROR, + MsgStyle.ERROR, {"config_file": get_config_path()}, ) ) @@ -307,40 +315,45 @@ def _print_edited_summary(journal, old_stats, **kwargs): "deleted": old_stats["count"] - len(journal), "modified": len([e for e in journal.entries if e.modified]), } - - prompts = [] + stats["modified"] -= stats["added"] + msgs = [] if stats["added"] > 0: - prompts.append(f"{stats['added']} {_pluralize_entry(stats['added'])} added") - stats["modified"] -= stats["added"] + my_msg = ( + MsgText.JournalCountAddedSingular + if stats["added"] == 1 + else MsgText.JournalCountAddedPlural + ) + msgs.append(Message(my_msg, MsgStyle.NORMAL, {"num": stats["added"]})) if stats["deleted"] > 0: - prompts.append( - f"{stats['deleted']} {_pluralize_entry(stats['deleted'])} deleted" + my_msg = ( + MsgText.JournalCountDeletedSingular + if stats["deleted"] == 1 + else MsgText.JournalCountDeletedPlural ) + msgs.append(Message(my_msg, MsgStyle.NORMAL, {"num": stats["deleted"]})) - if stats["modified"]: - prompts.append( - f"{stats['modified']} {_pluralize_entry(stats['modified'])} modified" + if stats["modified"] > 0: + my_msg = ( + MsgText.JournalCountModifiedSingular + if stats["modified"] == 1 + else MsgText.JournalCountModifiedPlural ) + msgs.append(Message(my_msg, MsgStyle.NORMAL, {"num": stats["modified"]})) - if prompts: - print(f"[{', '.join(prompts).capitalize()}]", file=sys.stderr) + print_msgs(msgs) def _get_predit_stats(journal): return {"count": len(journal)} -def _pluralize_entry(num): - return "entry" if num == 1 else "entries" - - def _delete_search_results(journal, old_entries, **kwargs): if not journal.entries: - raise JrnlException(Message(MsgText.NothingToDelete, MsgType.ERROR)) + raise JrnlException(Message(MsgText.NothingToDelete, MsgStyle.ERROR)) - entries_to_delete = journal.prompt_action_entries("Delete entry") + entries_to_delete = journal.prompt_action_entries(MsgText.DeleteEntryQuestion) if entries_to_delete: journal.entries = old_entries @@ -351,7 +364,7 @@ def _delete_search_results(journal, old_entries, **kwargs): def _change_time_search_results(args, journal, old_entries, no_prompt=False, **kwargs): if not journal.entries: - raise JrnlException(Message(MsgText.NothingToModify, MsgType.WARNING)) + raise JrnlException(Message(MsgText.NothingToModify, MsgStyle.WARNING)) # separate entries we are not editing other_entries = _other_entries(journal, old_entries) @@ -359,7 +372,9 @@ def _change_time_search_results(args, journal, old_entries, no_prompt=False, **k if no_prompt: entries_to_change = journal.entries else: - entries_to_change = journal.prompt_action_entries("Change time") + entries_to_change = journal.prompt_action_entries( + MsgText.ChangeTimeEntryQuestion + ) if entries_to_change: other_entries += [e for e in journal.entries if e not in entries_to_change] diff --git a/jrnl/messages.py b/jrnl/messages.py deleted file mode 100644 index 34f45e7d..00000000 --- a/jrnl/messages.py +++ /dev/null @@ -1,141 +0,0 @@ -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 = """ - {name} - {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" - - 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" - - # --- Editor ---# - WritingEntryStart = """ - Writing Entry - To finish writing, press {how_to_quit} on a blank line. - """ - 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 = """ - No entry to save, because no text was received - """ - - # --- 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 - """ - - NothingToModify = """ - No entries to modify, because the search returned no results - """ - - -class Message(NamedTuple): - text: MsgText - type: MsgType = MsgType.NORMAL - params: Mapping = {} diff --git a/jrnl/messages/Message.py b/jrnl/messages/Message.py new file mode 100644 index 00000000..2c02e8a0 --- /dev/null +++ b/jrnl/messages/Message.py @@ -0,0 +1,11 @@ +from typing import NamedTuple +from typing import Mapping + +from .MsgText import MsgText +from .MsgStyle import MsgStyle + + +class Message(NamedTuple): + text: MsgText + style: MsgStyle = MsgStyle.NORMAL + params: Mapping = {} diff --git a/jrnl/messages/MsgStyle.py b/jrnl/messages/MsgStyle.py new file mode 100644 index 00000000..41daa535 --- /dev/null +++ b/jrnl/messages/MsgStyle.py @@ -0,0 +1,89 @@ +from enum import Enum +from typing import NamedTuple +from typing import Callable +from rich.panel import Panel +from rich import box + +from .MsgText import MsgText + + +class MsgStyle(Enum): + class _Color(NamedTuple): + """ + String representing a standard color to display + see: https://rich.readthedocs.io/en/stable/appendix/colors.html + """ + + color: str + + class _Decoration(Enum): + NONE = { + "callback": lambda x, **_: x, + "args": {}, + } + BOX = { + "callback": Panel, + "args": { + "expand": False, + "padding": (0, 2), + "title_align": "left", + "box": box.HEAVY, + }, + } + + @property + def callback(self) -> Callable: + return self.value["callback"] + + @property + def args(self) -> dict: + return self.value["args"] + + PROMPT = { + "decoration": _Decoration.NONE, + "color": _Color("white"), + "append_space": True, + } + TITLE = { + "decoration": _Decoration.BOX, + "color": _Color("cyan"), + } + NORMAL = { + "decoration": _Decoration.BOX, + "color": _Color("white"), + } + WARNING = { + "decoration": _Decoration.BOX, + "color": _Color("yellow"), + } + ERROR = { + "decoration": _Decoration.BOX, + "color": _Color("red"), + "box_title": str(MsgText.Error), + } + ERROR_ON_NEW_LINE = { + "decoration": _Decoration.BOX, + "color": _Color("red"), + "prepend_newline": True, + "box_title": str(MsgText.Error), + } + + @property + def decoration(self) -> _Decoration: + return self.value["decoration"] + + @property + def color(self) -> _Color: + return self.value["color"].color + + @property + def prepend_newline(self) -> bool: + return self.value.get("prepend_newline", False) + + @property + def append_space(self) -> bool: + return self.value.get("append_space", False) + + @property + def box_title(self) -> MsgText: + return self.value.get("box_title", None) diff --git a/jrnl/messages/MsgText.py b/jrnl/messages/MsgText.py new file mode 100644 index 00000000..f8e85b60 --- /dev/null +++ b/jrnl/messages/MsgText.py @@ -0,0 +1,248 @@ +from enum import Enum + + +class MsgText(Enum): + def __str__(self) -> str: + return self.value + + # -- Welcome --- # + WelcomeToJrnl = """ + Welcome to jrnl {version}! + + It looks like you've been using an older version of jrnl until now. That's + okay - jrnl will now upgrade your configuration and journal files. Afterwards + you can enjoy all of the great new features that come with jrnl 2: + + - Support for storing your journal in multiple files + - Faster reading and writing for large journals + - New encryption back-end that makes installing jrnl much easier + - Tons of bug fixes + + Please note that jrnl 1.x is NOT forward compatible with this version of jrnl. + If you choose to proceed, you will not be able to use your journals with + older versions of jrnl anymore. + """ + + AllDoneUpgrade = "We're all done here and you can start enjoying jrnl 2" + + # --- Prompts --- # + InstallJournalPathQuestion = """ + Path to your journal file (leave blank for {default_journal_path}): + """ + DeleteEntryQuestion = "Delete entry '{entry_title}'?" + ChangeTimeEntryQuestion = "Change time for '{entry_title}'?" + EncryptJournalQuestion = """ + Do you want to encrypt your journal? (You can always change this later) + """ + YesOrNoPromptDefaultYes = "[Y/n]" + YesOrNoPromptDefaultNo = "[y/N]" + ContinueUpgrade = "Continue upgrading jrnl?" + + # these should be lowercase, if possible in language + # "lowercase" means whatever `.lower()` returns + OneCharacterYes = "y" + OneCharacterNo = "n" + + # --- Exceptions ---# + Error = "Error" + UncaughtException = """ + {name} + {exception} + + This is probably a bug. Please file an issue at: + https://github.com/jrnl-org/jrnl/issues/new/choose + """ + + ConfigDirectoryIsFile = """ + Problem with config file! + 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. + """ + + CantParseConfigFile = """ + Unable to parse config file at: + {config_path} + """ + + 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. + """ + + ConfigEncryptedForUnencryptableJournalType = """ + The config for journal "{journal_name}" has 'encrypt' set to true, but this type + of journal can't be encrypted. Please fix your config file. + """ + + KeyboardInterruptMsg = "Aborted by user" + + CantReadTemplate = """ + Unreadable template + Could not read template file at: + {template} + """ + + NoDefaultJournal = "No default journal configured\n{journals}" + + DoesNotExist = "{name} does not exist" + + # --- Journal status ---# + JournalNotSaved = "Entry NOT saved to journal" + JournalEntryAdded = "Entry added to {journal_name} journal" + + JournalCountAddedSingular = "{num} entry added" + JournalCountModifiedSingular = "{num} entry modified" + JournalCountDeletedSingular = "{num} entry deleted" + + JournalCountAddedPlural = "{num} entries added" + JournalCountModifiedPlural = "{num} entries modified" + JournalCountDeletedPlural = "{num} entries deleted" + + JournalCreated = "Journal '{journal_name}' created at {filename}" + DirectoryCreated = "Directory {directory_name} created" + JournalEncrypted = "Journal will be encrypted" + JournalEncryptedTo = "Journal encrypted to {path}" + JournalDecryptedTo = "Journal decrypted to {path}" + BackupCreated = "Created a backup at {filename}" + + # --- Editor ---# + WritingEntryStart = """ + Writing Entry + To finish writing, press {how_to_quit} on a blank line. + """ + 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 = """ + No entry to save, because no text was received + """ + + # --- 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" + + AbortingUpgrade = "Aborting upgrade..." + + ImportAborted = "Entries were NOT imported" + + JournalsToUpgrade = """ + The following journals will be upgraded to jrnl {version}: + + """ + + JournalsToIgnore = """ + The following journals will not be touched: + + """ + + UpgradingJournal = """ + Upgrading '{journal_name}' journal stored in {path}... + """ + + UpgradingConfig = "Upgrading config..." + + PaddedJournalName = "{journal_name:{pad}} -> {path}" + + # -- Config --- # + AltConfigNotFound = """ + Alternate configuration file not found at the given path: + {config_file} + """ + + ConfigUpdated = """ + Configuration updated to newest version at {config_path} + """ + + # --- Password --- # + Password = "Password:" + PasswordFirstEntry = "Enter password for journal '{journal_name}': " + PasswordConfirmEntry = "Enter password again: " + PasswordMaxTriesExceeded = "Too many attempts with wrong password" + PasswordCanNotBeEmpty = "Password can't be empty!" + PasswordDidNotMatch = "Passwords did not match, please try again" + WrongPasswordTryAgain = "Wrong password, try again" + PasswordStoreInKeychain = "Do you want to store the password in your keychain?" + + # --- Search --- # + NothingToDelete = """ + No entries to delete, because the search returned no results + """ + + NothingToModify = """ + No entries to modify, because the search returned no results + """ + + # --- Formats --- # + HeadingsPastH6 = """ + Headings increased past H6 on export - {date} {title} + """ + + YamlMustBeDirectory = """ + YAML export must be to a directory, not a single file + """ + + JournalExportedTo = "Journal exported to {path}" + + # --- Import --- # + ImportSummary = """ + {count} imported to {journal_name} journal + """ + + # --- Color --- # + InvalidColor = "{key} set to invalid color: {color}" + + # --- Keyring --- # + KeyringBackendNotFound = """ + Keyring backend not found. + + Please install one of the supported backends by visiting: + https://pypi.org/project/keyring/ + """ + + KeyringRetrievalFailure = "Failed to retrieve keyring" + + # --- Deprecation --- # + DeprecatedCommand = """ + The command {old_cmd} is deprecated and will be removed from jrnl soon. + Please use {new_cmd} instead. + """ diff --git a/jrnl/messages/__init__.py b/jrnl/messages/__init__.py new file mode 100644 index 00000000..930fbe75 --- /dev/null +++ b/jrnl/messages/__init__.py @@ -0,0 +1,7 @@ +from .Message import Message +from .MsgStyle import MsgStyle +from .MsgText import MsgText + +Message = Message +MsgStyle = MsgStyle +MsgText = MsgText diff --git a/jrnl/output.py b/jrnl/output.py index f31a02e2..fb21990b 100644 --- a/jrnl/output.py +++ b/jrnl/output.py @@ -1,25 +1,24 @@ # Copyright (C) 2012-2021 jrnl contributors # 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 typing import Union +from rich.text import Text +from rich.console import Console + from jrnl.messages import Message +from jrnl.messages import MsgStyle +from jrnl.messages import MsgText def deprecated_cmd(old_cmd, new_cmd, callback=None, **kwargs): - - warning_msg = f""" - The command {old_cmd} is deprecated and will be removed from jrnl soon. - Please use {new_cmd} instead. - """ - warning_msg = textwrap.dedent(warning_msg) - logging.warning(warning_msg) - print(f"{WARNING_COLOR}{warning_msg}{RESET_COLOR}", file=sys.stderr) + print_msg( + Message( + MsgText.DeprecatedCommand, + MsgStyle.WARNING, + {"old_cmd": old_cmd, "new_cmd": new_cmd}, + ) + ) if callback is not None: callback(**kwargs) @@ -38,14 +37,56 @@ def list_journals(configuration): return result -def print_msg(msg: Message): - msg_text = textwrap.dedent(msg.text.value.format(**msg.params)).strip().split("\n") +def print_msg(msg: Message, **kwargs) -> Union[None, str]: + """Helper function to print a single message""" + kwargs["style"] = msg.style + return print_msgs([msg], **kwargs) - 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)}]" +def print_msgs( + msgs: list[Message], + delimiter: str = "\n", + style: MsgStyle = MsgStyle.NORMAL, + get_input: bool = False, + hide_input: bool = False, +) -> Union[None, str]: + # Same as print_msg, but for a list + text = Text("", end="") + kwargs = style.decoration.args - print("\n".join(msg_text), file=sys.stderr) + for i, msg in enumerate(msgs): + kwargs = _add_extra_style_args_if_needed(kwargs, msg=msg) + + m = format_msg_text(msg) + + if i != len(msgs) - 1: + m.append(delimiter) + + text.append(m) + + if style.append_space: + text.append(" ") + + decorated_text = style.decoration.callback(text, **kwargs) + + # Always print messages to stderr + console = _get_console(stderr=True) + + if get_input: + return str(console.input(prompt=decorated_text, password=hide_input)) + console.print(decorated_text, new_line_start=style.prepend_newline) + + +def _get_console(stderr: bool = True) -> Console: + return Console(stderr=stderr) + + +def _add_extra_style_args_if_needed(args, msg): + args["border_style"] = msg.style.color + args["title"] = msg.style.box_title + return args + + +def format_msg_text(msg: Message) -> Text: + text = textwrap.dedent(msg.text.value.format(**msg.params)).strip() + return Text(text) diff --git a/jrnl/plugins/fancy_exporter.py b/jrnl/plugins/fancy_exporter.py index c3dbc467..0c440e96 100644 --- a/jrnl/plugins/fancy_exporter.py +++ b/jrnl/plugins/fancy_exporter.py @@ -5,7 +5,7 @@ from jrnl.exception import JrnlException from jrnl.messages import Message from jrnl.messages import MsgText -from jrnl.messages import MsgType +from jrnl.messages import MsgStyle from textwrap import TextWrapper from .text_exporter import TextExporter @@ -90,7 +90,7 @@ def check_provided_linewrap_viability(linewrap, card, journal): raise JrnlException( Message( MsgText.LineWrapTooSmallForDateFormat, - MsgType.NORMAL, + MsgStyle.NORMAL, { "config_linewrap": linewrap, "columns": width_violation, diff --git a/jrnl/plugins/jrnl_importer.py b/jrnl/plugins/jrnl_importer.py index 54dd2ab8..cfdee0d7 100644 --- a/jrnl/plugins/jrnl_importer.py +++ b/jrnl/plugins/jrnl_importer.py @@ -7,7 +7,8 @@ import sys from jrnl.exception import JrnlException from jrnl.messages import Message from jrnl.messages import MsgText -from jrnl.messages import MsgType +from jrnl.messages import MsgStyle +from jrnl.output import print_msg class JRNLImporter: @@ -28,14 +29,20 @@ class JRNLImporter: other_journal_txt = sys.stdin.read() except KeyboardInterrupt: raise JrnlException( - Message(MsgText.KeyboardInterruptMsg, MsgType.ERROR), - Message(MsgText.ImportAborted, MsgType.WARNING), + Message(MsgText.KeyboardInterruptMsg, MsgStyle.ERROR_ON_NEW_LINE), + Message(MsgText.ImportAborted, MsgStyle.WARNING), ) journal.import_(other_journal_txt) new_cnt = len(journal.entries) - print( - "[{} imported to {} journal]".format(new_cnt - old_cnt, journal.name), - file=sys.stderr, - ) journal.write() + print_msg( + Message( + MsgText.ImportSummary, + MsgStyle.NORMAL, + { + "count": new_cnt - old_cnt, + "journal_name": journal.name, + }, + ) + ) diff --git a/jrnl/plugins/markdown_exporter.py b/jrnl/plugins/markdown_exporter.py index 11f748b6..63be85e0 100644 --- a/jrnl/plugins/markdown_exporter.py +++ b/jrnl/plugins/markdown_exporter.py @@ -4,13 +4,14 @@ import os import re -import sys - -from jrnl.color import RESET_COLOR -from jrnl.color import WARNING_COLOR from .text_exporter import TextExporter +from jrnl.output import print_msg +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgStyle + class MarkdownExporter(TextExporter): """This Exporter can convert entries and journals into Markdown.""" @@ -63,10 +64,12 @@ class MarkdownExporter(TextExporter): newbody = newbody + os.linesep if warn_on_heading_level is True: - print( - f"{WARNING_COLOR}WARNING{RESET_COLOR}: " - f"Headings increased past H6 on export - {date_str} {entry.title}", - file=sys.stderr, + print_msg( + Message( + MsgText.HeadingsPastH6, + MsgStyle.WARNING, + {"date": date_str, "title": entry.title}, + ) ) return f"{heading} {date_str} {entry.title}\n{newbody} " diff --git a/jrnl/plugins/text_exporter.py b/jrnl/plugins/text_exporter.py index 4a5300df..d82a1e40 100644 --- a/jrnl/plugins/text_exporter.py +++ b/jrnl/plugins/text_exporter.py @@ -6,8 +6,10 @@ import os import re import unicodedata -from jrnl.color import ERROR_COLOR -from jrnl.color import RESET_COLOR +from jrnl.output import print_msg +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgStyle class TextExporter: @@ -29,14 +31,18 @@ class TextExporter: @classmethod def write_file(cls, journal, path): """Exports a journal into a single file.""" - try: - with open(path, "w", encoding="utf-8") as f: - f.write(cls.export_journal(journal)) - return f"[Journal exported to {path}]" - except IOError as e: - return f"[{ERROR_COLOR}ERROR{RESET_COLOR}: {e.filename} {e.strerror}]" - except RuntimeError as e: - return e + with open(path, "w", encoding="utf-8") as f: + f.write(cls.export_journal(journal)) + print_msg( + Message( + MsgText.JournalExportedTo, + MsgStyle.NORMAL, + { + "path": path, + }, + ) + ) + return "" @classmethod def make_filename(cls, entry): @@ -48,17 +54,17 @@ class TextExporter: def write_files(cls, journal, path): """Exports a journal into individual files for each entry.""" for entry in journal.entries: - try: - full_path = os.path.join(path, cls.make_filename(entry)) - with open(full_path, "w", encoding="utf-8") as f: - f.write(cls.export_entry(entry)) - except IOError as e: - return "[{2}ERROR{3}: {0} {1}]".format( - e.filename, e.strerror, ERROR_COLOR, RESET_COLOR - ) - except RuntimeError as e: - return e - return "[Journal exported to {}]".format(path) + full_path = os.path.join(path, cls.make_filename(entry)) + with open(full_path, "w", encoding="utf-8") as f: + f.write(cls.export_entry(entry)) + print_msg( + Message( + MsgText.JournalExportedTo, + MsgStyle.NORMAL, + {"path": path}, + ) + ) + return "" def _slugify(string): """Slugifies a string. diff --git a/jrnl/plugins/yaml_exporter.py b/jrnl/plugins/yaml_exporter.py index 8983d2e4..f78c781b 100644 --- a/jrnl/plugins/yaml_exporter.py +++ b/jrnl/plugins/yaml_exporter.py @@ -4,14 +4,15 @@ import os import re -import sys - -from jrnl.color import ERROR_COLOR -from jrnl.color import RESET_COLOR -from jrnl.color import WARNING_COLOR from .text_exporter import TextExporter +from jrnl.exception import JrnlException +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgStyle +from jrnl.output import print_msg + class YAMLExporter(TextExporter): """This Exporter can convert entries and journals into Markdown formatted text with YAML front matter.""" @@ -23,10 +24,7 @@ class YAMLExporter(TextExporter): def export_entry(cls, entry, to_multifile=True): """Returns a markdown representation of a single entry, with YAML front matter.""" if to_multifile is False: - raise RuntimeError( - f"{ERROR_COLOR}ERROR{RESET_COLOR}: YAML export must be to individual files. Please \ - specify a directory to export to." - ) + raise JrnlException(Message(MsgText.YamlMustBeDirectory, MsgStyle.ERROR)) date_str = entry.date.strftime(entry.journal.config["timeformat"]) body_wrapper = "\n" if entry.body else "" @@ -78,11 +76,12 @@ class YAMLExporter(TextExporter): spacebody = spacebody + "\t" + line if warn_on_heading_level is True: - print( - "{}WARNING{}: Headings increased past H6 on export - {} {}".format( - WARNING_COLOR, RESET_COLOR, date_str, entry.title - ), - file=sys.stderr, + print_msg( + Message( + MsgText.HeadingsPastH6, + MsgStyle.WARNING, + {"date": date_str, "title": entry.title}, + ) ) dayone_attributes = "" @@ -129,8 +128,4 @@ class YAMLExporter(TextExporter): @classmethod def export_journal(cls, journal): """Returns an error, as YAML export requires a directory as a target.""" - raise RuntimeError( - "{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format( - ERROR_COLOR, RESET_COLOR - ) - ) + raise JrnlException(Message(MsgText.YamlMustBeDirectory, MsgStyle.ERROR)) diff --git a/jrnl/prompt.py b/jrnl/prompt.py index 48dbccdc..843e7425 100644 --- a/jrnl/prompt.py +++ b/jrnl/prompt.py @@ -1,32 +1,65 @@ # Copyright (C) 2012-2021 jrnl contributors # License: https://www.gnu.org/licenses/gpl-3.0.html - -import getpass -import sys +from jrnl.messages import Message +from jrnl.messages import MsgText +from jrnl.messages import MsgStyle +from jrnl.output import print_msg +from jrnl.output import print_msgs def create_password(journal_name: str) -> str: - - prompt = f"Enter password for journal '{journal_name}': " - + kwargs = { + "get_input": True, + "hide_input": True, + } while True: - pw = getpass.getpass(prompt) + pw = print_msg( + Message( + MsgText.PasswordFirstEntry, + MsgStyle.PROMPT, + params={"journal_name": journal_name}, + ), + **kwargs + ) + if not pw: - print("Password can't be an empty string!", file=sys.stderr) + print_msg(Message(MsgText.PasswordCanNotBeEmpty, MsgStyle.WARNING)) continue - elif pw == getpass.getpass("Enter password again: "): + + elif pw == print_msg( + Message(MsgText.PasswordConfirmEntry, MsgStyle.PROMPT), **kwargs + ): break - print("Passwords did not match, please try again", file=sys.stderr) + print_msg(Message(MsgText.PasswordDidNotMatch, MsgStyle.ERROR)) - if yesno("Do you want to store the password in your keychain?", default=True): + if yesno(Message(MsgText.PasswordStoreInKeychain), default=True): from .EncryptedJournal import set_keychain set_keychain(journal_name, pw) + return pw -def yesno(prompt, default=True): - prompt = f"{prompt.strip()} {'[Y/n]' if default else '[y/N]'} " - response = input(prompt) - return {"y": True, "n": False}.get(response.lower().strip(), default) +def yesno(prompt: Message, default: bool = True) -> bool: + response = print_msgs( + [ + prompt, + Message( + MsgText.YesOrNoPromptDefaultYes + if default + else MsgText.YesOrNoPromptDefaultNo + ), + ], + style=MsgStyle.PROMPT, + delimiter=" ", + get_input=True, + ) + + answers = { + str(MsgText.OneCharacterYes): True, + str(MsgText.OneCharacterNo): False, + } + + # Does using `lower()` work in all languages? + return answers.get(str(response).lower().strip(), default) diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py index b9fc0092..5c033534 100644 --- a/jrnl/upgrade.py +++ b/jrnl/upgrade.py @@ -2,7 +2,6 @@ # License: https://www.gnu.org/licenses/gpl-3.0.html import os -import sys from . import Journal from . import __version__ @@ -14,15 +13,14 @@ from .prompt import yesno from .path import expand_path from jrnl.output import print_msg - +from jrnl.output import print_msgs from jrnl.exception import JrnlException from jrnl.messages import Message from jrnl.messages import MsgText -from jrnl.messages import MsgType +from jrnl.messages import MsgStyle def backup(filename, binary=False): - print(f" Created a backup at {filename}.backup", file=sys.stderr) filename = expand_path(filename) try: @@ -31,11 +29,18 @@ def backup(filename, binary=False): with open(filename + ".backup", "wb" if binary else "w") as backup: backup.write(contents) + + print_msg( + Message( + MsgText.BackupCreated, MsgStyle.NORMAL, {"filename": "filename.backup"} + ) + ) + except FileNotFoundError: - print(f"\nError: {filename} does not exist.") + print_msg(Message(MsgText.DoesNotExist, MsgStyle.WARNING, {"name": filename})) cont = yesno(f"\nCreate {filename}?", default=False) if not cont: - raise JrnlException(Message(MsgText.UpgradeAborted), MsgType.WARNING) + raise JrnlException(Message(MsgText.UpgradeAborted, MsgStyle.WARNING)) def check_exists(path): @@ -48,23 +53,7 @@ def check_exists(path): def upgrade_jrnl(config_path): config = load_config(config_path) - print( - f"""Welcome to jrnl {__version__}. - -It looks like you've been using an older version of jrnl until now. That's -okay - jrnl will now upgrade your configuration and journal files. Afterwards -you can enjoy all of the great new features that come with jrnl 2: - -- Support for storing your journal in multiple files -- Faster reading and writing for large journals -- New encryption back-end that makes installing jrnl much easier -- Tons of bug fixes - -Please note that jrnl 1.x is NOT forward compatible with this version of jrnl. -If you choose to proceed, you will not be able to use your journals with -older versions of jrnl anymore. -""" - ) + print_msg(Message(MsgText.WelcomeToJrnl, MsgStyle.NORMAL, {"version": __version__})) encrypted_journals = {} plain_journals = {} @@ -79,8 +68,10 @@ older versions of jrnl anymore. encrypt = config.get("encrypt") path = expand_path(journal_conf) - if not os.path.exists(path): - print(f"\nError: {path} does not exist.") + if os.path.exists(path): + path = os.path.expanduser(path) + else: + print_msg(Message(MsgText.DoesNotExist, MsgStyle.ERROR, {"name": path})) continue if encrypt: @@ -90,46 +81,54 @@ older versions of jrnl anymore. else: plain_journals[journal_name] = path - longest_journal_name = max([len(journal) for journal in config["journals"]]) - if encrypted_journals: - print( - f"\nFollowing encrypted journals will be upgraded to jrnl {__version__}:", - file=sys.stderr, - ) - for journal, path in encrypted_journals.items(): - print( - " {:{pad}} -> {}".format(journal, path, pad=longest_journal_name), - file=sys.stderr, - ) + kwargs = { + # longest journal name + "pad": max([len(journal) for journal in config["journals"]]), + } - if plain_journals: - print( - f"\nFollowing plain text journals will upgraded to jrnl {__version__}:", - file=sys.stderr, - ) - for journal, path in plain_journals.items(): - print( - " {:{pad}} -> {}".format(journal, path, pad=longest_journal_name), - file=sys.stderr, - ) + _print_journal_summary( + journals=encrypted_journals, + header=Message( + MsgText.JournalsToUpgrade, + params={ + "version": __version__, + }, + ), + **kwargs, + ) - if other_journals: - print("\nFollowing journals will be not be touched:", file=sys.stderr) - for journal, path in other_journals.items(): - print( - " {:{pad}} -> {}".format(journal, path, pad=longest_journal_name), - file=sys.stderr, - ) + _print_journal_summary( + journals=plain_journals, + header=Message( + MsgText.JournalsToUpgrade, + params={ + "version": __version__, + }, + ), + **kwargs, + ) - cont = yesno("\nContinue upgrading jrnl?", default=False) + _print_journal_summary( + journals=other_journals, + header=Message(MsgText.JournalsToIgnore), + **kwargs, + ) + + cont = yesno(Message(MsgText.ContinueUpgrade), default=False) if not cont: - raise JrnlException(Message(MsgText.UpgradeAborted), MsgType.WARNING) + raise JrnlException(Message(MsgText.UpgradeAborted), MsgStyle.WARNING) for journal_name, path in encrypted_journals.items(): - print( - f"\nUpgrading encrypted '{journal_name}' journal stored in {path}...", - file=sys.stderr, + print_msg( + Message( + MsgText.UpgradingJournal, + params={ + "journal_name": journal_name, + "path": path, + }, + ) ) + backup(path, binary=True) old_journal = Journal.open_journal( journal_name, scope_config(config, journal_name), legacy=True @@ -137,10 +136,16 @@ older versions of jrnl anymore. all_journals.append(EncryptedJournal.from_journal(old_journal)) for journal_name, path in plain_journals.items(): - print( - f"\nUpgrading plain text '{journal_name}' journal stored in {path}...", - file=sys.stderr, + print_msg( + Message( + MsgText.UpgradingJournal, + params={ + "journal_name": journal_name, + "path": path, + }, + ) ) + backup(path) old_journal = Journal.open_journal( journal_name, scope_config(config, journal_name), legacy=True @@ -151,29 +156,47 @@ older versions of jrnl anymore. failed_journals = [j for j in all_journals if not j.validate_parsing()] if len(failed_journals) > 0: - print_msg("Aborting upgrade.", msg=Message.NORMAL) - raise JrnlException( + Message(MsgText.AbortingUpgrade, MsgStyle.WARNING), Message( MsgText.JournalFailedUpgrade, - MsgType.ERROR, + MsgStyle.ERROR, { "s": "s" if len(failed_journals) > 1 else "", "failed_journals": "\n".join(j.name for j in failed_journals), }, - ) + ), ) # write all journals - or - don't for j in all_journals: j.write() - print("\nUpgrading config...", file=sys.stderr) + print_msg(Message(MsgText.UpgradingConfig, MsgStyle.NORMAL)) backup(config_path) - print("\nWe're all done here and you can start enjoying jrnl 2.", file=sys.stderr) + print_msg(Message(MsgText.AllDoneUpgrade, MsgStyle.NORMAL)) def is_old_version(config_path): return is_config_json(config_path) + + +def _print_journal_summary(journals: dict, header: Message, pad: int) -> None: + if not journals: + return + + msgs = [header] + for journal, path in journals.items(): + msgs.append( + Message( + MsgText.PaddedJournalName, + params={ + "journal_name": journal, + "path": path, + "pad": pad, + }, + ) + ) + print_msgs(msgs) diff --git a/pyproject.toml b/pyproject.toml index a0c04ef6..3dd8baa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ tzlocal = ">2.0, <3.0" # https://github.com/regebro/tzlocal/blob/master/CHANGE pytest = { version = ">=6.2", optional = true } pytest-bdd = { version = ">=4.0.1", optional = true } toml = { version = ">=0.10", optional = true } +rich = "^12.2.0" [tool.poetry.dev-dependencies] mkdocs = ">=1.0,<1.3" diff --git a/tests/bdd/features/config_file.feature b/tests/bdd/features/config_file.feature index 0bba7037..168cd7aa 100644 --- a/tests/bdd/features/config_file.feature +++ b/tests/bdd/features/config_file.feature @@ -73,7 +73,7 @@ Feature: Multiple journals these three eyes these three eyes n - Then the output should contain "Encrypted journal 'new_encrypted' created" + Then the output should contain "Journal 'new_encrypted' created at " Scenario: Don't overwrite main config when encrypting a journal in an alternate config Given the config "basic_onefile.yaml" exists diff --git a/tests/bdd/features/datetime.feature b/tests/bdd/features/datetime.feature index 7676f1b2..1b9d00b9 100644 --- a/tests/bdd/features/datetime.feature +++ b/tests/bdd/features/datetime.feature @@ -69,7 +69,7 @@ Feature: Reading and writing to journal with custom date formats Scenario: Writing an entry at the prompt with custom date Given we use the config "little_endian_dates.yaml" - When we run "jrnl" and enter "2013-05-10: I saw Elvis. He's alive." + When we run "jrnl" and type "2013-05-10: I saw Elvis. He's alive." Then we should get no error When we run "jrnl -999" Then the output should contain "10.05.2013 09:00 I saw Elvis." diff --git a/tests/bdd/features/encrypt.feature b/tests/bdd/features/encrypt.feature index 78b21188..09a80bcc 100644 --- a/tests/bdd/features/encrypt.feature +++ b/tests/bdd/features/encrypt.feature @@ -2,8 +2,8 @@ Feature: Encrypting and decrypting journals Scenario: Decrypting a journal Given we use the config "encrypted.yaml" - # And we use the password "bad doggie no biscuit" if prompted - When we run "jrnl --decrypt" and enter "bad doggie no biscuit" + And we use the password "bad doggie no biscuit" if prompted + When we run "jrnl --decrypt" Then the output should contain "Journal decrypted" And the config for journal "default" should contain "encrypt: false" When we run "jrnl -99 --short" @@ -47,7 +47,7 @@ Feature: Encrypting and decrypting journals Scenario Outline: Running jrnl with encrypt: true on unencryptable journals Given we use the config "" When we run "jrnl --config-override encrypt true here is a new entry" - Then the error output should contain "this type of journal can't be encrypted" + Then the error output should contain "journal can't be encrypted" Examples: configs | config_file | diff --git a/tests/bdd/features/format.feature b/tests/bdd/features/format.feature index 9de28889..ce29a016 100644 --- a/tests/bdd/features/format.feature +++ b/tests/bdd/features/format.feature @@ -429,7 +429,7 @@ Feature: Custom formats Given we use the config "" And we use the password "test" if prompted When we run "jrnl --export yaml --file nonexistent_dir" - Then the output should contain "YAML export must be to individual files" + Then the output should contain "YAML export must be to a directory" And the output should not contain "Traceback" Examples: configs diff --git a/tests/bdd/features/multiple_journals.feature b/tests/bdd/features/multiple_journals.feature index 35df069b..09bd71fd 100644 --- a/tests/bdd/features/multiple_journals.feature +++ b/tests/bdd/features/multiple_journals.feature @@ -87,7 +87,7 @@ Feature: Multiple journals these three eyes these three eyes n - Then the output should contain "Encrypted journal 'new_encrypted' created" + Then the output should contain "Journal 'new_encrypted' created at" Scenario: Read and write to journal with emoji name Given we use the config "multiple.yaml" diff --git a/tests/bdd/features/override.feature b/tests/bdd/features/override.feature index d4b46e97..90b7e40b 100644 --- a/tests/bdd/features/override.feature +++ b/tests/bdd/features/override.feature @@ -3,7 +3,7 @@ Feature: Implementing Runtime Overrides for Select Configuration Keys Scenario: Override configured editor with built-in input === editor:'' Given we use the config "basic_encrypted.yaml" And we use the password "test" if prompted - When we run "jrnl --config-override editor ''" and enter + When we run "jrnl --config-override editor ''" and type This is a journal entry Then the stdin prompt should have been called And the editor should not have been called diff --git a/tests/bdd/features/star.feature b/tests/bdd/features/star.feature index f1340f7b..f59a7ecc 100644 --- a/tests/bdd/features/star.feature +++ b/tests/bdd/features/star.feature @@ -29,7 +29,8 @@ Feature: Starring entries Scenario: Starring an entry will mark it in an encrypted journal Given we use the config "encrypted.yaml" - When we run "jrnl 20 july 2013 *: Best day of my life!" and enter "bad doggie no biscuit" + And we use the password "bad doggie no biscuit" if prompted + When we run "jrnl 20 july 2013 *: Best day of my life!" Then the output should contain "Entry added" When we run "jrnl -on 2013-07-20 -starred" and enter "bad doggie no biscuit" Then the output should contain "2013-07-20 09:00 Best day of my life!" diff --git a/tests/bdd/features/upgrade.feature b/tests/bdd/features/upgrade.feature index e5714a82..c092dedb 100644 --- a/tests/bdd/features/upgrade.feature +++ b/tests/bdd/features/upgrade.feature @@ -41,7 +41,7 @@ Feature: Upgrading Journals from 1.x.x to 2.x.x Scenario: Upgrade with missing journal Given we use the config "upgrade_from_195_with_missing_journal.json" When we run "jrnl --list" and enter "Y" - Then the output should contain "Error: features/journals/missing.journal does not exist." + Then the output should contain "features/journals/missing.journal does not exist" And we should get no error Scenario: Upgrade with missing encrypted journal @@ -49,6 +49,6 @@ Feature: Upgrading Journals from 1.x.x to 2.x.x When we run "jrnl --list" and enter Y bad doggie no biscuit - Then the output should contain "Error: features/journals/missing.journal does not exist." + Then the output should contain "features/journals/missing.journal does not exist" And the output should contain "We're all done" And we should get no error diff --git a/tests/bdd/features/write.feature b/tests/bdd/features/write.feature index 608bc772..ede1934b 100644 --- a/tests/bdd/features/write.feature +++ b/tests/bdd/features/write.feature @@ -172,7 +172,7 @@ Feature: Writing new entries. Scenario Outline: Writing an entry at the prompt (no editor) should store the entry Given we use the config "" And we use the password "bad doggie no biscuit" if prompted - When we run "jrnl" and enter "25 jul 2013: I saw Elvis. He's alive." + When we run "jrnl" and type "25 jul 2013: I saw Elvis. He's alive." Then we should get no error When we run "jrnl -on '2013-07-25'" Then the output should contain "2013-07-25 09:00 I saw Elvis." @@ -233,8 +233,7 @@ Feature: Writing new entries. And we append to the editor if opened [2021-11-13] worked on jrnl tests When we run "jrnl --edit" - Then the output should contain - [1 entry added] + Then the output should contain "1 entry added" Examples: configs | config_file | @@ -252,8 +251,7 @@ Feature: Writing new entries. [2021-11-12] worked on jrnl tests again [2021-11-13] worked on jrnl tests a little bit more When we run "jrnl --edit" - Then the output should contain - [3 entries added] + Then the error output should contain "3 entries added" Examples: configs | config_file | @@ -269,8 +267,8 @@ Feature: Writing new entries. And we write to the editor if opened [2021-11-13] I am replacing my whole journal with this entry When we run "jrnl --edit" - Then the output should contain - [2 entries deleted, 1 entry modified] + Then the output should contain "2 entries deleted" + Then the output should contain "3 entries modified" Examples: configs | config_file | @@ -287,7 +285,7 @@ Feature: Writing new entries. [2021-11-13] I am replacing the last entry with this entry When we run "jrnl --edit -1" Then the output should contain - [1 entry modified] + 1 entry modified Examples: configs | config_file | @@ -304,7 +302,7 @@ Feature: Writing new entries. This is a small addendum to my latest entry. When we run "jrnl --edit" Then the output should contain - [1 entry modified] + 1 entry modified Examples: configs | config_file | diff --git a/tests/lib/fixtures.py b/tests/lib/fixtures.py index 8d820db6..6fc7cc2a 100644 --- a/tests/lib/fixtures.py +++ b/tests/lib/fixtures.py @@ -6,12 +6,15 @@ import os from pathlib import Path import tempfile +from collections.abc import Iterable from keyring import backend from keyring import errors from pytest import fixture from unittest.mock import patch +from unittest.mock import Mock from .helpers import get_fixture import toml +from rich.console import Console from jrnl.config import load_config from jrnl.os_compat import split_args @@ -85,7 +88,6 @@ def cli_run( mock_editor, mock_user_input, mock_overrides, - mock_password, ): # Check if we need more mocks mock_factories.update(mock_args) @@ -94,7 +96,6 @@ def cli_run( mock_factories.update(mock_editor) mock_factories.update(mock_config_path) mock_factories.update(mock_user_input) - mock_factories.update(mock_password) return { "status": 0, @@ -180,26 +181,6 @@ def toml_version(working_dir): return pyproject_contents["tool"]["poetry"]["version"] -@fixture -def mock_password(request): - def _mock_password(): - password = get_fixture(request, "password") - user_input = get_fixture(request, "user_input") - - if password: - password = password.splitlines() - - elif user_input: - password = user_input.splitlines() - - if not password: - password = Exception("Unexpected call for password") - - return patch("getpass.getpass", side_effect=password) - - return {"getpass": _mock_password} - - @fixture def input_method(): return "" @@ -221,30 +202,58 @@ def should_not(): @fixture -def mock_user_input(request, is_tty): - def _generator(target): - def _mock_user_input(): - user_input = get_fixture(request, "user_input", None) +def mock_user_input(request, password_input, stdin_input): + def _mock_user_input(): + # user_input needs to be here because we don't know it until cli_run starts + user_input = get_fixture(request, "all_input", None) + if user_input is None: + user_input = Exception("Unexpected call for user input") + else: + user_input = iter(user_input.splitlines()) - if user_input is None: - user_input = Exception("Unexpected call for user input") - else: - user_input = user_input.splitlines() if is_tty else [user_input] + def mock_console_input(**kwargs): + if kwargs["password"] and not isinstance(password_input, Exception): + return password_input - return patch(target, side_effect=user_input) + if isinstance(user_input, Iterable): + return next(user_input) - return _mock_user_input + # exceptions + return user_input if not kwargs["password"] else password_input + + mock_console = Mock(wraps=Console(stderr=True)) + mock_console.input = Mock(side_effect=mock_console_input) + + return patch("jrnl.output._get_console", return_value=mock_console) return { - "stdin": _generator("sys.stdin.read"), - "input": _generator("builtins.input"), + "user_input": _mock_user_input, + "stdin_input": lambda: patch("sys.stdin.read", side_effect=stdin_input), } +@fixture +def password_input(request): + password_input = get_fixture(request, "password", None) + if password_input is None: + password_input = Exception("Unexpected call for password input") + return password_input + + +@fixture +def stdin_input(request, is_tty): + stdin_input = get_fixture(request, "all_input", None) + if stdin_input is None or is_tty: + stdin_input = Exception("Unexpected call for stdin input") + else: + stdin_input = [stdin_input] + return stdin_input + + @fixture def is_tty(input_method): - assert input_method in ["", "enter", "pipe"] - return input_method != "pipe" + assert input_method in ["", "enter", "pipe", "type"] + return input_method not in ["pipe", "type"] @fixture diff --git a/tests/lib/given_steps.py b/tests/lib/given_steps.py index dd7c4720..4b545849 100644 --- a/tests/lib/given_steps.py +++ b/tests/lib/given_steps.py @@ -120,9 +120,9 @@ def config_exists(config_file, temp_dir, working_dir): shutil.copy2(config_source, config_dest) -@given(parse('we use the password "{pw}" if prompted'), target_fixture="password") -def use_password_forever(pw): - return pw +@given(parse('we use the password "{password}" if prompted')) +def use_password_forever(password): + return password @given("we create a cache directory", target_fixture="cache_dir") diff --git a/tests/lib/then_steps.py b/tests/lib/then_steps.py index 8dc4da71..5ec83d3c 100644 --- a/tests/lib/then_steps.py +++ b/tests/lib/then_steps.py @@ -47,20 +47,23 @@ def output_should_contain( ): we_should = parse_should_or_should_not(should_or_should_not) + output_str = f"\nEXPECTED:\n{expected_output}\n\nACTUAL STDOUT:\n{cli_run['stdout']}\n\nACTUAL STDERR:\n{cli_run['stderr']}" assert expected_output if which_output_stream is None: assert ((expected_output in cli_run["stdout"]) == we_should) or ( (expected_output in cli_run["stderr"]) == we_should - ) + ), output_str elif which_output_stream == "standard": - assert (expected_output in cli_run["stdout"]) == we_should + assert (expected_output in cli_run["stdout"]) == we_should, output_str elif which_output_stream == "error": - assert (expected_output in cli_run["stderr"]) == we_should + assert (expected_output in cli_run["stderr"]) == we_should, output_str else: - assert (expected_output in cli_run[which_output_stream]) == we_should + assert ( + expected_output in cli_run[which_output_stream] + ) == we_should, output_str @then(parse("the output should not contain\n{expected_output}")) @@ -164,12 +167,12 @@ def config_var_in_memory( @then("we should be prompted for a password") def password_was_called(cli_run): - assert cli_run["mocks"]["getpass"].called + assert cli_run["mocks"]["user_input"].called @then("we should not be prompted for a password") def password_was_not_called(cli_run): - assert not cli_run["mocks"]["getpass"].called + assert not cli_run["mocks"]["user_input"].called @then(parse("the cache directory should contain the files\n{file_list}")) @@ -371,7 +374,7 @@ def count_editor_args(num_args, cli_run, editor_state, should_or_should_not): def stdin_prompt_called(cli_run, should_or_should_not): we_should = parse_should_or_should_not(should_or_should_not) - assert cli_run["mocks"]["stdin"].called == we_should + assert cli_run["mocks"]["stdin_input"].called == we_should @then(parse('the editor filename should end with "{suffix}"')) diff --git a/tests/lib/when_steps.py b/tests/lib/when_steps.py index 60302c7c..bc1d742d 100644 --- a/tests/lib/when_steps.py +++ b/tests/lib/when_steps.py @@ -21,12 +21,12 @@ def when_we_change_directory(directory_name): # These variables are used in the `@when(re(...))` section below command = '(?P[^"]*)' -input_method = "(?Penter|pipe)" -user_input = '("(?P[^"]*)")' +input_method = "(?Penter|pipe|type)" +all_input = '("(?P[^"]*)")' -@when(parse('we run "jrnl {command}" and {input_method}\n{user_input}')) -@when(re(f'we run "jrnl ?{command}" and {input_method} {user_input}')) +@when(parse('we run "jrnl {command}" and {input_method}\n{all_input}')) +@when(re(f'we run "jrnl ?{command}" and {input_method} {all_input}')) @when(parse('we run "jrnl {command}"')) @when('we run "jrnl"') def we_run_jrnl(cli_run, capsys, keyring): diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py new file mode 100644 index 00000000..0fa9f45b --- /dev/null +++ b/tests/unit/test_output.py @@ -0,0 +1,27 @@ +# Copyright (C) 2012-2021 jrnl contributors +# License: https://www.gnu.org/licenses/gpl-3.0.html +from unittest.mock import Mock +from unittest.mock import patch + +from jrnl.messages import Message +from jrnl.output import print_msg + + +@patch("jrnl.output.print_msgs") +def test_print_msg_calls_print_msgs_as_list_with_style(print_msgs): + test_msg = Mock(Message) + print_msg(test_msg) + print_msgs.assert_called_once_with([test_msg], style=test_msg.style) + + +@patch("jrnl.output.print_msgs") +def test_print_msg_calls_print_msgs_with_kwargs(print_msgs): + test_msg = Mock(Message) + kwargs = { + "delimter": "test delimiter 🤡", + "get_input": True, + "hide_input": True, + "some_rando_arg": "💩", + } + print_msg(test_msg, **kwargs) + print_msgs.assert_called_once_with([test_msg], style=test_msg.style, **kwargs)