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 <micah.jerome.ellison@gmail.com>
This commit is contained in:
Jonathan Wren 2022-06-11 13:32:11 -07:00 committed by GitHub
parent 4d683a13c0
commit f53110c69b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 912 additions and 470 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

11
jrnl/messages/Message.py Normal file
View file

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

89
jrnl/messages/MsgStyle.py Normal file
View file

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

248
jrnl/messages/MsgText.py Normal file
View file

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

View file

@ -0,0 +1,7 @@
from .Message import Message
from .MsgStyle import MsgStyle
from .MsgText import MsgText
Message = Message
MsgStyle = MsgStyle
MsgText = MsgText

View file

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

View file

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

View file

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

View file

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

View file

@ -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
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
print_msg(
Message(
MsgText.JournalExportedTo,
MsgStyle.NORMAL,
{"path": path},
)
except RuntimeError as e:
return e
return "[Journal exported to {}]".format(path)
)
return ""
def _slugify(string):
"""Slugifies a string.

View file

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

View file

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

View file

@ -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"]]),
}
_print_journal_summary(
journals=encrypted_journals,
header=Message(
MsgText.JournalsToUpgrade,
params={
"version": __version__,
},
),
**kwargs,
)
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=plain_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=other_journals,
header=Message(MsgText.JournalsToIgnore),
**kwargs,
)
cont = yesno("\nContinue upgrading jrnl?", default=False)
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)

View file

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

View file

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

View file

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

View file

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

View file

@ -429,7 +429,7 @@ Feature: Custom formats
Given we use the config "<config_file>"
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(request, password_input, stdin_input):
def _mock_user_input():
user_input = get_fixture(request, "user_input", None)
# 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 = user_input.splitlines() if is_tty else [user_input]
user_input = iter(user_input.splitlines())
return patch(target, side_effect=user_input)
def mock_console_input(**kwargs):
if kwargs["password"] and not isinstance(password_input, Exception):
return password_input
return _mock_user_input
if isinstance(user_input, Iterable):
return next(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

View file

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

View file

@ -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}"'))

View file

@ -21,12 +21,12 @@ def when_we_change_directory(directory_name):
# These variables are used in the `@when(re(...))` section below
command = '(?P<command>[^"]*)'
input_method = "(?P<input_method>enter|pipe)"
user_input = '("(?P<user_input>[^"]*)")'
input_method = "(?P<input_method>enter|pipe|type)"
all_input = '("(?P<all_input>[^"]*)")'
@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):

27
tests/unit/test_output.py Normal file
View file

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