More refactoring of cli.py

break up code from cli.py (now in jrnl.py) up into smaller functions
get rid of export mode
move --encrypt and --decrypt to commands.py
clean up the help screen even more
update flag name for import
This commit is contained in:
Jonathan Wren 2020-07-25 18:07:27 -07:00
parent 6f71c98e13
commit 38b78b1d1f
No known key found for this signature in database
GPG key ID: 43D5FF8722E7F68A
7 changed files with 450 additions and 273 deletions

View file

@ -180,7 +180,7 @@ Feature: Basic reading and writing to a journal
And the journal should contain "Life is good."
But the journal should not contain "I have an @idea"
And the journal should not contain "I met with"
When we run "jrnl --import -i features/journals/tags.journal"
When we run "jrnl --import --file features/journals/tags.journal"
Then the journal should contain "My first entry."
And the journal should contain "Life is good."
And the journal should contain "PROFIT!"
@ -191,10 +191,11 @@ Feature: Basic reading and writing to a journal
And the journal should contain "Life is good."
But the journal should not contain "I have an @idea"
And the journal should not contain "I met with"
When we run "jrnl --import -i features/journals/tags.journal" and pipe
When we run "jrnl --import --file features/journals/tags.journal" and pipe
"""
[2020-07-05 15:00] I should not exist!
"""
Then the journal should contain "My first entry."
And the journal should contain "PROFIT!"
But the journal should not contain "I should not exist!"

View file

@ -47,6 +47,7 @@ class EncryptedJournal(Journal):
print(f"[Directory {dirname} created]", file=sys.stderr)
self.create_file(filename)
self.password = util.create_password(self.name)
print(
f"Encrypted journal '{self.name}' created at {filename}",
file=sys.stderr,
@ -90,11 +91,16 @@ class EncryptedJournal(Journal):
@classmethod
def from_journal(cls, other: Journal):
new_journal = super().from_journal(other)
try:
new_journal.password = (
other.password
if hasattr(other, "password")
else util.create_password(other.name)
)
except KeyboardInterrupt:
print("[Interrupted while creating new journal]", file=sys.stderr)
sys.exit(1)
return new_journal

View file

@ -24,75 +24,15 @@ import packaging.version
import platform
import sys
from . import install, plugins, util
from . import install, util
from . import jrnl
from .parse_args import parse_args
from .Journal import PlainJournal, open_journal
from .util import ERROR_COLOR, RESET_COLOR, UserAbort
from .Journal import open_journal
from .util import UserAbort
from .util import get_journal_name
from .util import WARNING_COLOR, RESET_COLOR
log = logging.getLogger(__name__)
logging.getLogger("keyring.backend").setLevel(logging.ERROR)
def guess_mode(args, config):
"""Guesses the mode (compose, read or export) from the given arguments"""
compose = True
export = False
if (
args.decrypt is not False
or args.encrypt is not False
or args.export is not False
or any((args.short, args.tags, args.edit, args.delete))
):
compose = False
export = True
elif any(
(
args.start_date,
args.end_date,
args.on_date,
args.limit,
args.strict,
args.starred,
args.contains,
)
):
# Any sign of displaying stuff?
compose = False
elif args.text and all(
word[0] in config["tagsymbols"] for word in " ".join(args.text).split()
):
# No date and only tags?
compose = False
return compose, export
def encrypt(journal, filename=None):
""" Encrypt into new file. If filename is not set, we encrypt the journal file itself. """
from .EncryptedJournal import EncryptedJournal
journal.config["encrypt"] = True
new_journal = EncryptedJournal.from_journal(journal)
new_journal.write(filename)
print(
"Journal encrypted to {}.".format(filename or new_journal.config["journal"]),
file=sys.stderr,
)
def decrypt(journal, filename=None):
""" Decrypts into new file. If filename is not set, we encrypt the journal file itself. """
journal.config["encrypt"] = False
new_journal = PlainJournal.from_journal(journal)
new_journal.write(filename)
print(
"Journal decrypted to {}.".format(filename or new_journal.config["journal"]),
file=sys.stderr,
)
def update_config(config, new_config, scope, force_local=False):
@ -113,9 +53,8 @@ def configure_logger(debug=False):
level=logging.DEBUG if debug else logging.ERROR,
format="%(levelname)-8s %(name)-12s %(message)s",
)
logging.getLogger("parsedatetime").setLevel(
logging.INFO
) # disable parsedatetime debug logging
logging.getLogger("parsedatetime").setLevel(logging.INFO)
logging.getLogger("keyring.backend").setLevel(logging.ERROR)
def run(manual_args=None):
@ -132,18 +71,29 @@ Please update to Python 3.7 (or higher) in order to use jrnl.
)
sys.exit(1)
"""
Flow:
1. Parse cli arguments
2. Run standalone command if it doesn't require config (help, version, etc), then exit
3. Load config
4. Run standalone command if it does require config (encrypt, decrypt, etc), then exit
5. Load specified journal
6. Start write mode, or search mode
7. Profit
"""
if manual_args is None:
manual_args = sys.argv[1:]
args = parse_args(manual_args)
configure_logger(args.debug)
log.debug("Parsed args: %s", args)
# Run command if possible before config is available
if callable(args.preconfig_cmd):
args.preconfig_cmd(args)
sys.exit(0)
# Load the config
# Load the config, and extract journal name
try:
config = install.load_or_install_jrnl()
original_config = config.copy()
@ -155,7 +105,7 @@ Please update to Python 3.7 (or higher) in order to use jrnl.
# Run post-config command now that config is ready
if callable(args.postconfig_cmd):
args.postconfig_cmd(args=args, config=config)
args.postconfig_cmd(args=args, config=config, original_config=original_config)
sys.exit(0)
# --- All the standalone commands are now done --- #
@ -163,144 +113,15 @@ Please update to Python 3.7 (or higher) in order to use jrnl.
# Get the journal we're going to be working with
journal = open_journal(args.journal_name, config)
mode_compose, mode_export = guess_mode(args, config)
kwargs = {
"args": args,
"config": config,
"journal": journal,
}
if mode_compose and not args.text:
if not sys.stdin.isatty():
# Piping data into jrnl
raw = sys.stdin.read()
elif config["editor"]:
template = ""
if config["template"]:
try:
template = open(config["template"]).read()
except OSError:
print(
f"[Could not read template at '{config['template']}']",
file=sys.stderr,
)
sys.exit(1)
raw = util.get_text_from_editor(config, template)
if jrnl._is_write_mode(**kwargs):
jrnl.write_mode(**kwargs)
else:
try:
_how_to_quit = (
"Ctrl+z and then Enter" if "win32" in sys.platform else "Ctrl+d"
)
print(
f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n",
file=sys.stderr,
)
raw = sys.stdin.read()
except KeyboardInterrupt:
print("[Entry NOT saved to journal]", file=sys.stderr)
sys.exit(0)
if raw:
args.text = [raw]
else:
sys.exit()
jrnl.search_mode(**kwargs)
# Writing mode
if mode_compose:
raw = " ".join(args.text).strip()
log.debug('Appending raw line "%s" to journal "%s"', raw, args.journal_name)
journal.new_entry(raw)
print(f"[Entry added to {args.journal_name} journal]", file=sys.stderr)
journal.write()
if not mode_compose:
old_entries = journal.entries
if args.on_date:
args.start_date = args.end_date = args.on_date
journal.filter(
tags=args.text,
start_date=args.start_date,
end_date=args.end_date,
strict=args.strict,
starred=args.starred,
exclude=args.excluded,
contains=args.contains,
)
journal.limit(args.limit)
# Reading mode
if not mode_compose and not mode_export:
print(journal.pprint())
# Various export modes
elif args.short:
print(journal.pprint(short=True))
elif args.tags:
print(plugins.get_exporter("tags").export(journal))
elif args.export is not False:
exporter = plugins.get_exporter(args.export)
print(exporter.export(journal, args.output))
elif args.encrypt is not False:
encrypt(journal, filename=args.encrypt)
# Not encrypting to a separate file: update config!
if not args.encrypt:
update_config(
original_config, {"encrypt": True}, args.journal_name, force_local=True
)
install.save_config(original_config)
elif args.decrypt is not False:
decrypt(journal, filename=args.decrypt)
# Not decrypting to a separate file: update config!
if not args.decrypt:
update_config(
original_config, {"encrypt": False}, args.journal_name, force_local=True
)
install.save_config(original_config)
elif args.edit:
if not config["editor"]:
print(
"[{1}ERROR{2}: You need to specify an editor in {0} to use the --edit function.]".format(
install.CONFIG_FILE_PATH, ERROR_COLOR, RESET_COLOR
),
file=sys.stderr,
)
sys.exit(1)
other_entries = [e for e in old_entries if e not in journal.entries]
# Edit
old_num_entries = len(journal)
edited = util.get_text_from_editor(config, journal.editable_str())
journal.parse_editable_str(edited)
num_deleted = old_num_entries - len(journal)
num_edited = len([e for e in journal.entries if e.modified])
prompts = []
if num_deleted:
prompts.append(
"{} {} deleted".format(
num_deleted, "entry" if num_deleted == 1 else "entries"
)
)
if num_edited:
prompts.append(
"{} {} modified".format(
num_edited, "entry" if num_deleted == 1 else "entries"
)
)
if prompts:
print("[{}]".format(", ".join(prompts).capitalize()), file=sys.stderr)
journal.entries += other_entries
journal.sort()
journal.write()
elif args.delete:
if journal.entries:
entries_to_delete = journal.prompt_delete_entries()
if entries_to_delete:
journal.entries = old_entries
journal.delete_entries(entries_to_delete)
journal.write()
else:
print(
"No entries deleted, because the search returned no results.",
file=sys.stderr,
)
# All done!

View file

@ -1,19 +1,35 @@
"""
Functions in this file are standalone commands. All standalone commands are split into
two categories depending on whether they require the config to be loaded to be able to
run.
1. "preconfig" commands don't require the config at all, and can be run before the
config has been loaded.
2. "postconfig" commands require to config to have already been loaded, parsed, and
scoped before they can be run.
Also, please note that all (non-builtin) imports should be scoped to each function to
avoid any possible overhead for these standalone commands.
"""
import platform
import sys
def preconfig_diagnostic(_):
import platform
import sys
import jrnl
from jrnl import __version__
print(
f"jrnl: {jrnl.__version__}\n"
f"jrnl: {__version__}\n"
f"Python: {sys.version}\n"
f"OS: {platform.system()} {platform.release()}"
)
def preconfig_version(_):
import jrnl
from jrnl import __title__
from jrnl import __version__
version_str = f"{jrnl.__title__} version {jrnl.__version__}"
version_str = f"{__title__} version {__version__}"
print(version_str)
@ -31,4 +47,59 @@ def postconfig_import(args, config, **kwargs):
journal = open_journal(args.journal_name, config)
format = args.export if args.export else "jrnl"
get_importer(format).import_(journal, args.input)
get_importer(format).import_(journal, args.filename)
def postconfig_encrypt(args, config, original_config, **kwargs):
"""
Encrypt a journal in place, or optionally to a new file
"""
from .EncryptedJournal import EncryptedJournal
from .Journal import open_journal
from .cli import update_config
from .install import save_config
# Open the journal
journal = open_journal(args.journal_name, config)
journal.config["encrypt"] = True
new_journal = EncryptedJournal.from_journal(journal)
new_journal.write(args.filename)
print(
f"Journal encrypted to {args.filename or new_journal.config['journal']}.",
file=sys.stderr,
)
# Update the config, if we encrypted in place
if not args.filename:
update_config(
original_config, {"encrypt": True}, args.journal_name, force_local=True
)
save_config(original_config)
def postconfig_decrypt(args, config, original_config, **kwargs):
""" Decrypts into new file. If filename is not set, we encrypt the journal file itself. """
from .Journal import PlainJournal
from .Journal import open_journal
from .cli import update_config
from .install import save_config
journal = open_journal(args.journal_name, config)
journal.config["encrypt"] = False
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,
)
# Update the config, if we decrypted in place
if not args.filename:
update_config(
original_config, {"encrypt": False}, args.journal_name, force_local=True
)
save_config(original_config)

266
jrnl/jrnl.py Normal file
View file

@ -0,0 +1,266 @@
import logging
import sys
from . import util
from . import install
from . import plugins
from .util import ERROR_COLOR
from .util import RESET_COLOR
log = logging.getLogger(__name__)
def _is_write_mode(args, config, **kwargs):
"""Determines if we are in write mode (as opposed to search mode)"""
write_mode = True
# Are any search filters present? If so, then search mode.
write_mode = not any(
(
args.contains,
args.delete,
args.edit,
args.export,
args.end_date,
args.limit,
args.on_date,
args.short,
args.starred,
args.start_date,
args.strict,
args.tags,
)
)
# If the text is entirely tags, then we are also searching (not writing)
if (
write_mode
and args.text
and all(word[0] in config["tagsymbols"] for word in " ".join(args.text).split())
):
write_mode = False
return write_mode
def write_mode(args, config, journal, **kwargs):
"""
Gets input from the user to write to the journal
1. Check for input from cli
2. Check input being piped in
3. Open editor if configured (prepopulated with template if available)
4. Use stdin.read as last resort
6. Write any found text to journal, or exit
"""
log.debug("Write mode: starting")
if args.text:
log.debug("Write mode: cli text detected: %s", args.text)
raw = " ".join(args.text).strip()
elif not sys.stdin.isatty():
log.debug("Write mode: receiving piped text")
raw = sys.stdin.read()
else:
raw = _write_in_editor(config)
if not raw:
log.error("Write mode: couldn't get raw text")
sys.exit()
log.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)
journal.write()
log.debug("Write mode: completed journal.write()", args.journal_name, raw)
def search_mode(args, journal, **kwargs):
"""
Search for entries in a journal, then either:
1. Send them to configured editor for user manipulation
2. Delete them (with confirmation for each entry)
3. Display them (with formatting options)
"""
kwargs = {
**kwargs,
"args": args,
"journal": journal,
"old_entries": journal.entries,
}
# Filters the journal entries in place
_search_journal(**kwargs)
# Where do the search results go?
if args.edit:
_edit_search_results(**kwargs)
elif args.delete:
_delete_search_results(**kwargs)
else:
_display_search_results(**kwargs)
def _write_in_editor(config):
if config["editor"]:
log.debug("Write mode: opening editor")
template = _get_editor_template(config)
raw = util.get_text_from_editor(config, template)
else:
_how_to_quit = "Ctrl+z and then Enter" if "win32" in sys.platform else "Ctrl+d"
print(
f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n",
file=sys.stderr,
)
try:
raw = sys.stdin.read()
except KeyboardInterrupt:
log.error("Write mode: keyboard interrupt")
print("[Entry NOT saved to journal]", file=sys.stderr)
sys.exit(0)
return raw
def _get_editor_template(config, **kwargs):
log.debug("Write mode: loading template for entry")
if not config["template"]:
log.debug("Write mode: no template configured")
return ""
try:
template = open(config["template"]).read()
log.debug("Write mode: template loaded: %s", template)
except OSError:
log.error("Write mode: template not loaded")
print(
f"[Could not read template at '{config['template']}']", file=sys.stderr,
)
sys.exit(1)
return template
def _search_journal(args, journal, **kwargs):
""" Search the journal with the given args"""
if args.on_date:
args.start_date = args.end_date = args.on_date
journal.filter(
tags=args.text,
start_date=args.start_date,
end_date=args.end_date,
strict=args.strict,
starred=args.starred,
exclude=args.excluded,
contains=args.contains,
)
journal.limit(args.limit)
def _edit_search_results(config, journal, old_entries, **kwargs):
"""
1. Send the given journal entries to the user-configured editor
2. Print out stats on any modifications to journal
3. Write modifications to journal
"""
if not config["editor"]:
print(
f"""
[{ERROR_COLOR}ERROR{RESET_COLOR}: There is no editor configured.]
Please specify an editor in config file ({install.CONFIG_FILE_PATH})
to use the --edit option.
""",
file=sys.stderr,
)
sys.exit(1)
# separate entries we are not editing
other_entries = [e for e in old_entries if e not in journal.entries]
# Get stats now for summary later
old_stats = _get_predit_stats(journal)
# Send user to the editor
edited = util.get_text_from_editor(config, journal.editable_str())
journal.parse_editable_str(edited)
# Print summary if available
_print_edited_summary(journal, old_stats)
# Put back entries we separated earlier, sort, and write the journal
journal.entries += other_entries
journal.sort()
journal.write()
def _print_edited_summary(journal, old_stats, **kwargs):
stats = {
"deleted": old_stats["count"] - len(journal),
"modified": len([e for e in journal.entries if e.modified]),
}
prompts = []
if stats["deleted"]:
prompts.append(
f"{stats['deleted']} {_pluralize_entry(stats['deleted'])} deleted"
)
if stats["modified"]:
prompts.append(
f"{stats['modified']} {_pluralize_entry(stats['modified'])} modified"
)
if prompts:
print(f"[{', '.join(prompts).capitalize()}]", file=sys.stderr)
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:
print(
"[No entries deleted, because the search returned no results.]",
file=sys.stderr,
)
sys.exit(1)
entries_to_delete = journal.prompt_delete_entries()
if entries_to_delete:
journal.entries = old_entries
journal.delete_entries(entries_to_delete)
journal.write()
def _display_search_results(args, journal, **kwargs):
if args.short:
print(journal.pprint(short=True))
elif args.tags:
print(plugins.get_exporter("tags").export(journal))
elif args.export:
exporter = plugins.get_exporter(args.export)
print(exporter.export(journal, args.filename))
else:
# Default display mode
print(journal.pprint())

View file

@ -9,13 +9,18 @@ from .commands import preconfig_version
from .commands import preconfig_diagnostic
from .commands import postconfig_list
from .commands import postconfig_import
from .commands import postconfig_encrypt
from .commands import postconfig_decrypt
from .util import deprecated_cmd
class WrappingFormatter(argparse.RawDescriptionHelpFormatter):
class WrappingFormatter(argparse.RawTextHelpFormatter):
def _split_lines(self, text, width):
text = self._whitespace_matcher.sub(" ", text).strip()
return textwrap.wrap(text, width=56)
text = text.split("\n\n")
text = map(lambda t: self._whitespace_matcher.sub(" ", t).strip(), text)
text = map(lambda t: textwrap.wrap(t, width=56), text)
text = [item for sublist in text for item in sublist]
return text
def parse_args(args=[]):
@ -54,7 +59,7 @@ def parse_args(args=[]):
action="store_const",
const=preconfig_version,
dest="preconfig_cmd",
help="prints version information",
help="Print version information",
)
standalone.add_argument(
"-v",
@ -75,7 +80,7 @@ def parse_args(args=[]):
action="store_const",
const=postconfig_list,
dest="postconfig_cmd",
help="list all configured journals",
help="List all configured journals",
)
standalone.add_argument(
"--ls",
@ -95,21 +100,19 @@ def parse_args(args=[]):
)
standalone.add_argument(
"--encrypt",
metavar="FILENAME",
dest="encrypt",
help="Encrypts your existing journal with a new password",
nargs="?",
default=False,
const=None,
help="Encrypt selected journal with a password",
action="store_const",
metavar="TYPE",
dest="postconfig_cmd",
const=postconfig_encrypt,
)
standalone.add_argument(
"--decrypt",
metavar="FILENAME",
dest="decrypt",
help="Decrypts your journal and stores it in plain text",
nargs="?",
default=False,
const=None,
help="Decrypt selected journal and store it in plain text",
action="store_const",
metavar="TYPE",
dest="postconfig_cmd",
const=postconfig_decrypt,
)
standalone.add_argument(
"--import",
@ -117,18 +120,27 @@ def parse_args(args=[]):
metavar="TYPE",
dest="postconfig_cmd",
const=postconfig_import,
help=f"Import entries into your journal. TYPE can be: {util.oxford_list(IMPORT_FORMATS)} (default: jrnl)",
help=f"""
Import entries from another journal.
Optional parameters:
--file FILENAME (default: uses stdin)
--format [{util.oxford_list(IMPORT_FORMATS)}] (default: jrnl)
""",
)
standalone.add_argument(
"-i",
"--file",
metavar="FILENAME",
dest="input",
help="Optionally specifies input file when using --import.",
default=False,
const=None,
dest="filename",
help=argparse.SUPPRESS,
default=None,
)
standalone.add_argument("-i", dest="filename", help=argparse.SUPPRESS)
compose_msg = """ To add a new entry into your journal, simply write it on the command line:
compose_msg = """
To add a new entry into your journal, simply write it on the command line:
jrnl yesterday: I was walking and I found this big log.
@ -171,9 +183,7 @@ def parse_args(args=[]):
metavar="DATE",
help="Show entries before, or on, this date (alias: -until)",
)
reading.add_argument(
"-until", dest="end_date", help=argparse.SUPPRESS,
)
reading.add_argument("-until", dest="end_date", help=argparse.SUPPRESS)
reading.add_argument(
"-contains",
dest="contains",
@ -214,7 +224,7 @@ def parse_args(args=[]):
search_options_msg = """ These help you do various tasks with the selected entries from your search.
If used on their own (with no search), they will act on your entire journal"""
exporting = parser.add_argument_group(
"Options for Searching", textwrap.dedent(search_options_msg)
"Searching Options", textwrap.dedent(search_options_msg)
)
exporting.add_argument(
"--edit",
@ -233,7 +243,15 @@ def parse_args(args=[]):
metavar="TYPE",
dest="export",
choices=EXPORT_FORMATS,
help=f"Display selected entries in an alternate format (other than jrnl). TYPE can be: {util.oxford_list(EXPORT_FORMATS)}.",
help=f"""
Display selected entries in an alternate format.
TYPE can be: {util.oxford_list(EXPORT_FORMATS)}.
Optional parameters:
--file FILENAME Write output to file instead of stdout
""",
default=False,
)
exporting.add_argument(
@ -259,17 +277,11 @@ def parse_args(args=[]):
"-s", dest="short", action="store_true", help=argparse.SUPPRESS,
)
exporting.add_argument(
"-o",
metavar="FILENAME",
dest="output",
help="Optionally specifies output file (or directory) when using --format.",
default=False,
const=None,
"-o", dest="filename", help=argparse.SUPPRESS,
)
# Handle '-123' as a shortcut for '-n 123'
num = re.compile(r"^-(\d+)$")
args = [num.sub(r"-n \1", arg) for arg in args]
# return parser.parse_args(args)
return parser.parse_intermixed_args(args)

View file

@ -14,17 +14,14 @@ def expected_args(**kwargs):
default_args = {
"contains": None,
"debug": False,
"decrypt": False,
"delete": False,
"edit": False,
"encrypt": False,
"end_date": None,
"excluded": [],
"export": False,
"input": False,
"filename": None,
"limit": None,
"on_date": None,
"output": False,
"preconfig_cmd": None,
"postconfig_cmd": None,
"short": False,
@ -66,7 +63,15 @@ def test_edit_alone():
def test_encrypt_alone():
assert cli_as_dict("--encrypt 'test.txt'") == expected_args(encrypt="test.txt")
from jrnl.commands import postconfig_encrypt
assert cli_as_dict("--encrypt") == expected_args(postconfig_cmd=postconfig_encrypt)
def test_decrypt_alone():
from jrnl.commands import postconfig_decrypt
assert cli_as_dict("--decrypt") == expected_args(postconfig_cmd=postconfig_decrypt)
def test_end_date_alone():
@ -110,15 +115,10 @@ def test_import_alone():
assert cli_as_dict("--import") == expected_args(postconfig_cmd=postconfig_import)
def test_input_flag_alone():
assert cli_as_dict("-i test.txt") == expected_args(input="test.txt")
assert cli_as_dict("-i 'lorem ipsum.txt'") == expected_args(input="lorem ipsum.txt")
def test_output_flag_alone():
assert cli_as_dict("-o test.txt") == expected_args(output="test.txt")
assert cli_as_dict("-o 'lorem ipsum.txt'") == expected_args(
output="lorem ipsum.txt"
def test_file_flag_alone():
assert cli_as_dict("--file test.txt") == expected_args(filename="test.txt")
assert cli_as_dict("--file 'lorem ipsum.txt'") == expected_args(
filename="lorem ipsum.txt"
)