Refactor --template code (#1711)

* Move path concerns to path.py and template concerns to editor.py -- BDD tests are failing
* Move path-related constants from config.py to path.py
* Mock get_templates_path in its new calling file
* Mediate template arg vs. config in controller then read template text in editor and unify those two use cases.
Some tests still failing
* Fix test whose message had changed
* poe format
* Refactor for easier unit testing and add unit tests
* Use path strings instead of Path objects in return values to prevent side effects that caused unit tests to fail on some platforms
* poe format
* Attempt to bypass getcwd errors in CI with patch
* Consistently use strings for paths instead of some strings and some pathlib.Path
* Keep pathlib within a function for readability
* fix for ruamel.yaml versions >=0.17.22
* Run poe format

---------

Co-authored-by: Jonathan Wren <jonathan@nowandwren.com>
This commit is contained in:
Micah Jerome Ellison 2023-05-20 16:13:24 -07:00 committed by GitHub
parent 78d11d74bd
commit 4a7057c038
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 190 additions and 147 deletions

View file

@ -4,12 +4,10 @@
import argparse import argparse
import logging import logging
import os import os
from pathlib import Path
from typing import Any from typing import Any
from typing import Callable from typing import Callable
import colorama import colorama
import xdg.BaseDirectory
from rich.pretty import pretty_repr from rich.pretty import pretty_repr
from ruamel.yaml import YAML from ruamel.yaml import YAML
from ruamel.yaml import constructor from ruamel.yaml import constructor
@ -21,13 +19,10 @@ from jrnl.messages import MsgStyle
from jrnl.messages import MsgText from jrnl.messages import MsgText
from jrnl.output import list_journals from jrnl.output import list_journals
from jrnl.output import print_msg from jrnl.output import print_msg
from jrnl.path import home_dir from jrnl.path import get_config_path
from jrnl.path import get_default_journal_path
# Constants # Constants
DEFAULT_CONFIG_NAME = "jrnl.yaml"
XDG_RESOURCE = "jrnl"
DEFAULT_JOURNAL_NAME = "journal.txt"
DEFAULT_JOURNAL_KEY = "default" DEFAULT_JOURNAL_KEY = "default"
YAML_SEPARATOR = ": " YAML_SEPARATOR = ": "
@ -73,31 +68,6 @@ def save_config(config: dict, alt_config_path: str | None = None) -> None:
yaml.dump(config, f) yaml.dump(config, f)
def get_config_directory() -> str:
try:
return xdg.BaseDirectory.save_config_path(XDG_RESOURCE)
except FileExistsError:
raise JrnlException(
Message(
MsgText.ConfigDirectoryIsFile,
MsgStyle.ERROR,
{
"config_directory_path": os.path.join(
xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE
)
},
),
)
def get_config_path() -> Path:
try:
config_directory_path = get_config_directory()
except JrnlException:
return Path(home_dir(), DEFAULT_CONFIG_NAME)
return Path(config_directory_path, DEFAULT_CONFIG_NAME)
def get_default_config() -> dict[str, Any]: def get_default_config() -> dict[str, Any]:
return { return {
"version": __version__, "version": __version__,
@ -130,20 +100,6 @@ def get_default_colors() -> dict[str, Any]:
} }
def get_default_journal_path() -> str:
journal_data_path = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or home_dir()
return os.path.join(journal_data_path, DEFAULT_JOURNAL_NAME)
def get_templates_path() -> Path:
# jrnl_xdg_resource_path is created by save_data_path if it does not exist
jrnl_xdg_resource_path = Path(xdg.BaseDirectory.save_data_path(XDG_RESOURCE))
jrnl_templates_path = jrnl_xdg_resource_path / "templates"
# Create the directory if needed.
jrnl_templates_path.mkdir(exist_ok=True)
return jrnl_templates_path
def scope_config(config: dict, journal_name: str) -> dict: def scope_config(config: dict, journal_name: str) -> dict:
if journal_name not in config["journals"]: if journal_name not in config["journals"]:
return config return config

View file

@ -11,10 +11,10 @@ from jrnl import time
from jrnl.config import DEFAULT_JOURNAL_KEY from jrnl.config import DEFAULT_JOURNAL_KEY
from jrnl.config import get_config_path from jrnl.config import get_config_path
from jrnl.config import get_journal_name from jrnl.config import get_journal_name
from jrnl.config import get_templates_path
from jrnl.config import scope_config from jrnl.config import scope_config
from jrnl.editor import get_text_from_editor from jrnl.editor import get_text_from_editor
from jrnl.editor import get_text_from_stdin from jrnl.editor import get_text_from_stdin
from jrnl.editor import read_template_file
from jrnl.exception import JrnlException from jrnl.exception import JrnlException
from jrnl.journals import open_journal from jrnl.journals import open_journal
from jrnl.messages import Message from jrnl.messages import Message
@ -23,7 +23,6 @@ from jrnl.messages import MsgText
from jrnl.output import print_msg from jrnl.output import print_msg
from jrnl.output import print_msgs from jrnl.output import print_msgs
from jrnl.override import apply_overrides from jrnl.override import apply_overrides
from jrnl.path import absolute_path
if TYPE_CHECKING: if TYPE_CHECKING:
from argparse import Namespace from argparse import Namespace
@ -130,74 +129,6 @@ def _is_append_mode(args: "Namespace", config: dict, **kwargs) -> bool:
return append_mode return append_mode
def _read_template_file(template_arg: str, template_path_from_config: str) -> str:
"""
This function is called when either a template file is passed with --template, or config.template is set.
The processing logic is:
If --template was not used: Load the global template file.
If --template was used:
* Check $XDG_DATA_HOME/jrnl/templates/template_arg.
* Check template_arg as an absolute / relative path.
If a file is found, its contents are returned as a string.
If not, a JrnlException is raised.
"""
logging.debug(
"Append mode: Either a template arg was passed, or the global config is set."
)
# If filename is unset, we are in this flow due to a global template being configured
if not template_arg:
logging.debug("Append mode: Global template configuration detected.")
global_template_path = absolute_path(template_path_from_config)
try:
with open(global_template_path, encoding="utf-8") as f:
template_data = f.read()
return template_data
except FileNotFoundError:
raise JrnlException(
Message(
MsgText.CantReadTemplateGlobalConfig,
MsgStyle.ERROR,
{
"global_template_path": global_template_path,
},
)
)
else: # A template CLI arg was passed.
logging.debug("Trying to load template from $XDG_DATA_HOME/jrnl/templates/")
jrnl_template_dir = get_templates_path()
logging.debug(f"Append mode: jrnl templates directory: {jrnl_template_dir}")
template_path = jrnl_template_dir / template_arg
try:
with open(template_path, encoding="utf-8") as f:
template_data = f.read()
return template_data
except FileNotFoundError:
logging.debug(
f"Couldn't open {template_path}. Treating --template argument like a local / abs path."
)
pass
normalized_template_arg_filepath = absolute_path(template_arg)
try:
with open(normalized_template_arg_filepath, encoding="utf-8") as f:
template_data = f.read()
return template_data
except FileNotFoundError:
raise JrnlException(
Message(
MsgText.CantReadTemplateCLIArg,
MsgStyle.ERROR,
{
"normalized_template_arg_filepath": normalized_template_arg_filepath,
"jrnl_template_dir": template_path,
},
)
)
def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -> None: def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -> None:
""" """
Gets input from the user to write to the journal Gets input from the user to write to the journal
@ -210,26 +141,22 @@ def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -
""" """
logging.debug("Append mode: starting") logging.debug("Append mode: starting")
if args.template or config["template"]: template_text = _get_template(args, config)
logging.debug(f"Append mode: template CLI arg detected: {args.template}")
# Read template file and pass as raw text into the composer if args.text:
template_data = _read_template_file(args.template, config["template"])
raw = _write_in_editor(config, template_data)
if raw == template_data:
logging.error("Append mode: raw text was the same as the template")
raise JrnlException(Message(MsgText.NoChangesToTemplate, MsgStyle.NORMAL))
elif args.text:
logging.debug(f"Append mode: cli text detected: {args.text}") logging.debug(f"Append mode: cli text detected: {args.text}")
raw = " ".join(args.text).strip() raw = " ".join(args.text).strip()
if args.edit: if args.edit:
raw = _write_in_editor(config, raw) raw = _write_in_editor(config, raw)
elif not sys.stdin.isatty(): elif not sys.stdin.isatty():
logging.debug("Append mode: receiving piped text") logging.debug("Append mode: receiving piped text")
raw = sys.stdin.read() raw = sys.stdin.read()
else: else:
raw = _write_in_editor(config) raw = _write_in_editor(config, template_text)
if template_text is not None and raw == template_text:
logging.error("Append mode: raw text was the same as the template")
raise JrnlException(Message(MsgText.NoChangesToTemplate, MsgStyle.NORMAL))
if not raw or raw.isspace(): if not raw or raw.isspace():
logging.error("Append mode: couldn't get raw text or entry was empty") logging.error("Append mode: couldn't get raw text or entry was empty")
@ -251,6 +178,21 @@ def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -
logging.debug("Append mode: completed journal.write()") logging.debug("Append mode: completed journal.write()")
def _get_template(args, config) -> str:
# Read template file and pass as raw text into the composer
logging.debug(
f"Get template:\n--template: {args.template}\nfrom config: {config.get('template')}"
)
template_path = args.template or config.get("template")
template_text = None
if template_path:
template_text = read_template_file(template_path)
return template_text
def search_mode(args: "Namespace", journal: "Journal", **kwargs) -> None: def search_mode(args: "Namespace", journal: "Journal", **kwargs) -> None:
""" """
Search for entries in a journal, and return the Search for entries in a journal, and return the

View file

@ -15,6 +15,8 @@ from jrnl.messages import MsgText
from jrnl.os_compat import on_windows from jrnl.os_compat import on_windows
from jrnl.os_compat import split_args from jrnl.os_compat import split_args
from jrnl.output import print_msg from jrnl.output import print_msg
from jrnl.path import absolute_path
from jrnl.path import get_templates_path
def get_text_from_editor(config: dict, template: str = "") -> str: def get_text_from_editor(config: dict, template: str = "") -> str:
@ -73,3 +75,46 @@ def get_text_from_stdin() -> str:
) )
return raw return raw
def get_template_path(template_path: str, jrnl_template_dir: str) -> str:
actual_template_path = os.path.join(jrnl_template_dir, template_path)
if not os.path.exists(actual_template_path):
logging.debug(
f"Couldn't open {actual_template_path}. Treating template path like a local / abs path."
)
actual_template_path = absolute_path(template_path)
return actual_template_path
def read_template_file(template_path: str) -> str:
"""
Reads the template file given a template path in this order:
* Check $XDG_DATA_HOME/jrnl/templates/template_path.
* Check template_arg as an absolute / relative path.
If a file is found, its contents are returned as a string.
If not, a JrnlException is raised.
"""
jrnl_template_dir = get_templates_path()
actual_template_path = get_template_path(template_path, jrnl_template_dir)
try:
with open(actual_template_path, encoding="utf-8") as f:
template_data = f.read()
return template_data
except FileNotFoundError:
raise JrnlException(
Message(
MsgText.CantReadTemplate,
MsgStyle.ERROR,
{
"template_path": template_path,
"actual_template_path": actual_template_path,
"jrnl_template_dir": str(jrnl_template_dir) + os.sep,
},
)
)

View file

@ -105,16 +105,12 @@ class MsgText(Enum):
KeyboardInterruptMsg = "Aborted by user" KeyboardInterruptMsg = "Aborted by user"
CantReadTemplateGlobalConfig = """ CantReadTemplate = """
Could not read template file defined in config: Unable to find a template file {template_path}.
{global_template_path}
"""
CantReadTemplateCLIArg = """ The following paths were checked:
Unable to find a template file based on the passed arg, and no global template was detected. * {jrnl_template_dir}{template_path}
The following filepaths were checked: * {actual_template_path}
jrnl XDG Template Directory : {jrnl_template_dir}
Local Filepath : {normalized_template_arg_filepath}
""" """
NoNamedJournal = "No '{journal_name}' journal configured\n{journals}" NoNamedJournal = "No '{journal_name}' journal configured\n{journals}"

View file

@ -39,7 +39,10 @@ def journal_list_to_yaml(journal_list: dict) -> str:
from ruamel.yaml import YAML from ruamel.yaml import YAML
output = StringIO() output = StringIO()
YAML().dump(journal_list, output) dumper = YAML()
dumper.width = 1000
dumper.dump(journal_list, output)
return output.getvalue() return output.getvalue()

View file

@ -2,6 +2,19 @@
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
import os.path import os.path
from pathlib import Path
import xdg.BaseDirectory
from jrnl.exception import JrnlException
from jrnl.messages import Message
from jrnl.messages import MsgStyle
from jrnl.messages import MsgText
# Constants
XDG_RESOURCE = "jrnl"
DEFAULT_CONFIG_NAME = "jrnl.yaml"
DEFAULT_JOURNAL_NAME = "journal.txt"
def home_dir() -> str: def home_dir() -> str:
@ -14,3 +27,46 @@ def expand_path(path: str) -> str:
def absolute_path(path: str) -> str: def absolute_path(path: str) -> str:
return os.path.abspath(expand_path(path)) return os.path.abspath(expand_path(path))
def get_default_journal_path() -> str:
journal_data_path = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or home_dir()
return os.path.join(journal_data_path, DEFAULT_JOURNAL_NAME)
def get_templates_path() -> str:
"""
Get the path to the XDG templates directory. Creates the directory if it
doesn't exist.
"""
# jrnl_xdg_resource_path is created by save_data_path if it does not exist
jrnl_xdg_resource_path = Path(xdg.BaseDirectory.save_data_path(XDG_RESOURCE))
jrnl_templates_path = jrnl_xdg_resource_path / "templates"
# Create the directory if needed.
jrnl_templates_path.mkdir(exist_ok=True)
return str(jrnl_templates_path)
def get_config_directory() -> str:
try:
return xdg.BaseDirectory.save_config_path(XDG_RESOURCE)
except FileExistsError:
raise JrnlException(
Message(
MsgText.ConfigDirectoryIsFile,
MsgStyle.ERROR,
{
"config_directory_path": os.path.join(
xdg.BaseDirectory.xdg_config_home, XDG_RESOURCE
)
},
),
)
def get_config_path() -> str:
try:
config_directory_path = get_config_directory()
except JrnlException:
return os.path.join(home_dir(), DEFAULT_CONFIG_NAME)
return os.path.join(config_directory_path, DEFAULT_CONFIG_NAME)

10
poetry.lock generated
View file

@ -1598,18 +1598,18 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "ruamel-yaml" name = "ruamel-yaml"
version = "0.17.21" version = "0.17.24"
description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3" python-versions = ">=3"
files = [ files = [
{file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, {file = "ruamel.yaml-0.17.24-py3-none-any.whl", hash = "sha256:f251bd9096207af604af69d6495c3c650a3338d0493d27b04bc55477d7a884ed"},
{file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, {file = "ruamel.yaml-0.17.24.tar.gz", hash = "sha256:90e398ee24524ebe20fc48cd1861cedd25520457b9a36cfb548613e57fde30a0"},
] ]
[package.dependencies] [package.dependencies]
"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} "ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.12\""}
[package.extras] [package.extras]
docs = ["ryd"] docs = ["ryd"]
@ -1953,4 +1953,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10.0, <3.13" python-versions = ">=3.10.0, <3.13"
content-hash = "1306520c524e8cf20ca672e3b77eb0aa1fcc67e5994c86bd99a293415a0795b8" content-hash = "037329f79e6d33799cf05f4c71e15ea17368143eeb5e31adf59325c44e46fb45"

View file

@ -36,7 +36,7 @@ keyring = ">=21.0" # https://github.com/jaraco/keyring#integration
parsedatetime = ">=2.6" parsedatetime = ">=2.6"
python-dateutil = "^2.8" # https://github.com/dateutil/dateutil/blob/master/RELEASING python-dateutil = "^2.8" # https://github.com/dateutil/dateutil/blob/master/RELEASING
pyxdg = ">=0.27.0" pyxdg = ">=0.27.0"
"ruamel.yaml" = "0.17.21" # locked to this version until bug is resolved: https://github.com/jrnl-org/jrnl/issues/1736 "ruamel.yaml" = ">=0.17.22"
rich = ">=12.2.0, <14.0.0" rich = ">=12.2.0, <14.0.0"
# dayone-only deps # dayone-only deps

View file

@ -38,7 +38,7 @@ Feature: Using templates
And we use the password "test" if prompted And we use the password "test" if prompted
When we run "jrnl --template this_template_does_not_exist.template" When we run "jrnl --template this_template_does_not_exist.template"
Then we should get an error Then we should get an error
Then the error output should contain "Unable to find a template file based on the passed arg" Then the error output should contain "Unable to find a template file"
Examples: configs Examples: configs
| config_file | | config_file |

View file

@ -183,10 +183,10 @@ def mock_default_journal_path(temp_dir):
@fixture @fixture
def mock_default_templates_path(temp_dir): def mock_default_templates_path(temp_dir):
templates_path = Path(temp_dir.name, "templates") templates_path = os.path.join(temp_dir.name, "templates")
return { return {
"get_templates_path": lambda: patch( "get_templates_path": lambda: patch(
"jrnl.controller.get_templates_path", return_value=templates_path "jrnl.editor.get_templates_path", return_value=templates_path
), ),
} }

45
tests/unit/test_editor.py Normal file
View file

@ -0,0 +1,45 @@
# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html
import os
from unittest.mock import mock_open
from unittest.mock import patch
import pytest
from jrnl.editor import get_template_path
from jrnl.editor import read_template_file
from jrnl.exception import JrnlException
@patch(
"os.getcwd", side_effect="/"
) # prevent failures in CI if current directory has been deleted
@patch("builtins.open", side_effect=FileNotFoundError())
def test_read_template_file_with_no_file_raises_exception(mock_open, mock_getcwd):
with pytest.raises(JrnlException) as ex:
read_template_file("invalid_file.txt")
assert isinstance(ex.value, JrnlException)
@patch(
"os.getcwd", side_effect="/"
) # prevent failures in CI if current directory has been deleted
@patch("builtins.open", new_callable=mock_open, read_data="template text")
def test_read_template_file_with_valid_file_returns_text(mock_file, mock_getcwd):
assert read_template_file("valid_file.txt") == "template text"
def test_get_template_path_when_exists_returns_correct_path():
with patch("os.path.exists", return_value=True):
output = get_template_path("template", "templatepath")
assert output == os.path.join("templatepath", "template")
@patch("jrnl.editor.absolute_path")
def test_get_template_path_when_doesnt_exist_returns_correct_path(mock_absolute_paths):
with patch("os.path.exists", return_value=False):
output = get_template_path("template", "templatepath")
assert output == mock_absolute_paths.return_value