Merge branch 'develop' into develop

This commit is contained in:
Aaron Lichtman 2024-01-15 14:50:30 -08:00 committed by GitHub
commit ec724109c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 912 additions and 1079 deletions

View file

@ -14,6 +14,7 @@ runs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
- name: Capture full Python version in env
run: echo "PYTHON_FULL_VERSION=$(python --version)" >> $GITHUB_ENV

View file

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
token: ${{ secrets.JRNL_BOT_TOKEN }}

View file

@ -36,7 +36,7 @@ jobs:
os: [ ubuntu-latest ]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4

View file

@ -71,7 +71,7 @@ jobs:
python-version: '3.11'
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
token: ${{ secrets.JRNL_BOT_TOKEN }}

View file

@ -28,7 +28,7 @@ jobs:
os: [ ubuntu-latest ]
steps:
- run: git config --global core.autocrlf false
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Check workflow files
uses: docker://rhysd/actionlint:latest
with:

View file

@ -37,11 +37,11 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ '3.10', '3.11' ]
python-version: [ '3.10', '3.11', '3.12' ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- run: git config --global core.autocrlf false
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Run tests
uses: ./.github/actions/run_tests
with:

View file

@ -17,11 +17,11 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ '3.10', '3.11' ]
python-version: [ '3.10', '3.11', '3.12' ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- run: git config --global core.autocrlf false
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Run tests
uses: ./.github/actions/run_tests
with:

View file

@ -5,6 +5,13 @@
# Required
version: 2
# Set the OS
build:
os: ubuntu-22.04
tools:
python: "3"
# Build documentation in the docs/ directory
mkdocs:
configuration: mkdocs.yml

View file

@ -1,5 +1,49 @@
# Changelog
## [Unreleased](https://github.com/jrnl-org/jrnl/)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v4.1...HEAD)
**Documentation:**
- Document security risks of using a computer that someone else has admin access to [\#1793](https://github.com/jrnl-org/jrnl/issues/1793)
## [v4.1](https://pypi.org/project/jrnl/v4.1/) (2023-11-04)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v4.1-beta2...v4.1)
**Build:**
- Add Python 3.12 support [\#1761](https://github.com/jrnl-org/jrnl/pull/1761) ([micahellison](https://github.com/micahellison))
- Set new required build fields in the ReadTheDocs config file [\#1803](https://github.com/jrnl-org/jrnl/pull/1803) ([micahellison](https://github.com/micahellison))
- Replace flake8 and isort with ruff linter and add `black --check` to linting step [\#1763](https://github.com/jrnl-org/jrnl/pull/1763) ([micahellison](https://github.com/micahellison))
**Documentation:**
- Add note about messages going to `stderr` and the implication for piping [\#1768](https://github.com/jrnl-org/jrnl/pull/1768) ([micahellison](https://github.com/micahellison))
**Packaging:**
- Drop/replace ansiwrap dependency [\#1191](https://github.com/jrnl-org/jrnl/issues/1191)
- Use rich instead of ansiwrap to wrap text [\#1693](https://github.com/jrnl-org/jrnl/pull/1693) ([micahellison](https://github.com/micahellison))
- Update actions/checkout action to v4 [\#1788](https://github.com/jrnl-org/jrnl/pull/1788) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency black to v23.10.1 [\#1811](https://github.com/jrnl-org/jrnl/pull/1811) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency cryptography to v41.0.5 [\#1815](https://github.com/jrnl-org/jrnl/pull/1815) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency keyring to v24.2.0 [\#1760](https://github.com/jrnl-org/jrnl/pull/1760) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency mkdocs to v1.5.3 [\#1795](https://github.com/jrnl-org/jrnl/pull/1795) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency parse-type to v0.6.2 [\#1762](https://github.com/jrnl-org/jrnl/pull/1762) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency poethepoet to v0.24.1 [\#1806](https://github.com/jrnl-org/jrnl/pull/1806) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency pytest to v7.4.3 [\#1816](https://github.com/jrnl-org/jrnl/pull/1816) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency pytest-bdd to v7 [\#1807](https://github.com/jrnl-org/jrnl/pull/1807) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency rich to v13.6.0 [\#1794](https://github.com/jrnl-org/jrnl/pull/1794) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency ruamel.yaml to v0.18.3 [\#1813](https://github.com/jrnl-org/jrnl/pull/1813) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency ruff to v0.1.3 [\#1810](https://github.com/jrnl-org/jrnl/pull/1810) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency tox to v4.11.3 [\#1782](https://github.com/jrnl-org/jrnl/pull/1782) ([renovate[bot]](https://github.com/apps/renovate))
- Update dependency tzlocal to v5.2 [\#1814](https://github.com/jrnl-org/jrnl/pull/1814) ([renovate[bot]](https://github.com/apps/renovate))
**Special thanks:**
- jrnl uses UTC instead of local time for entries in WSL/Ubuntu [\#1607](https://github.com/jrnl-org/jrnl/issues/1607) investigated and reported upstream by [giuseppedandrea](https://github.com/giuseppedandrea)
## [v4.0.1](https://pypi.org/project/jrnl/v4.0.1/) (2023-06-20)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v4.0.1-beta...v4.0.1)

View file

@ -117,6 +117,11 @@ These formats are mainly intended for piping or exporting your journal to other
programs. Even so, they can still be used in the same way as any other format (like
written to a file, or displayed in your terminal, if you want).
!!! note
You may see boxed messages like "2 entries found" when using these formats, but
those messages are written to `stderr` instead of `stdout`, and won't be piped when
using the `|` operator.
### JSON
``` sh

View file

@ -14,6 +14,35 @@ program there are some limitations to be aware of.
passwords can be easily circumvented by someone with basic security skills
to access to your encrypted `jrnl` file.
## Plausible deniability
You may be able to hide the contents of your journal behind a layer of encryption,
but if someone has access to your configuration file, then they can figure out that
you have a journal, where that journal file is, and when you last edited it.
With a sufficient power imbalance, someone may be able to force you to unencrypt
it through non-technical means.
## Spying
While `jrnl` can protect against unauthorized access to your journal entries while
it isn't open, it cannot protect you against an unsafe computer/location.
For example:
- Someone installs a keylogger, tracking what you type into your journal.
- Someone watches your screen while you write your entry.
- Someone installs a backdoor into `jrnl` or poisons your journal into revealing your entries.
## Saved Passwords
When creating an encrypted journal, you'll be prompted as to whether or not you
want to "store the password in your keychain." This keychain is accessed using
the [Python keyring library](https://pypi.org/project/keyring/), which has different
behavior depending on your operating system.
In Windows, the keychain is the Windows Credential Manager (WCM), which can't be locked
and can be accessed by any other application running under your username. If this is
a concern for you, you may not want to store your password.
## Shell history
Since you can enter entries from the command line, any tool that logs command
@ -198,25 +227,6 @@ vim.api.nvim_create_autocmd( {"BufNewFile","BufReadPre" }, {
Please see `:h <option>` in Neovim for more information about the options mentioned.
## Plausible deniability
You may be able to hide the contents of your journal behind a layer of encryption,
but if someone has access to your configuration file, then they can figure out that
you have a journal, where that journal file is, and when you last edited it.
With a sufficient power imbalance, someone may be able to force you to unencrypt
it through non-technical means.
## Saved Passwords
When creating an encrypted journal, you'll be prompted as to whether or not you
want to "store the password in your keychain." This keychain is accessed using
the [Python keyring library](https://pypi.org/project/keyring/), which has different
behavior depending on your operating system.
In Windows, the keychain is the Windows Credential Manager (WCM), which can't be locked
and can be accessed by any other application running under your username. If this is
a concern for you, you may not want to store your password.
## Notice any other risks?
Please let the maintainers know by [filing an issue on GitHub](https://github.com/jrnl-org/jrnl/issues).

View file

@ -1 +1 @@
__version__ = "v4.0.1"
__version__ = "v4.1"

View file

@ -78,7 +78,7 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
"""
We gratefully thank all contributors!
Come see the whole list of code and financial contributors at https://github.com/jrnl-org/jrnl
And special thanks to Bad Lip Reading for the Yoda joke in the Writing section above :)"""
And special thanks to Bad Lip Reading for the Yoda joke in the Writing section above :)""" # noqa: E501
),
)
@ -214,7 +214,8 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
composing.add_argument(
"--template",
dest="template",
help="Path to template file. Can be a local path, absolute path, or a path relative to $XDG_DATA_HOME/jrnl/templates/",
help="Path to template file. Can be a local path, absolute path, or a path "
"relative to $XDG_DATA_HOME/jrnl/templates/",
)
read_msg = (
@ -265,13 +266,15 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
"-contains",
dest="contains",
metavar="TEXT",
help="Show entries containing specific text (put quotes around text with spaces)",
help="Show entries containing specific text (put quotes around text with "
"spaces)",
)
reading.add_argument(
"-and",
dest="strict",
action="store_true",
help='Show only entries that match all conditions, like saying "x AND y" (default: OR)',
help='Show only entries that match all conditions, like saying "x AND y" '
"(default: OR)",
)
reading.add_argument(
"-starred",
@ -290,7 +293,8 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
dest="limit",
default=None,
metavar="NUMBER",
help="Show a maximum of NUMBER entries (note: '-n 3' and '-3' have the same effect)",
help="Show a maximum of NUMBER entries (note: '-n 3' and '-3' have the same "
"effect)",
nargs="?",
type=int,
)
@ -308,8 +312,12 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
),
)
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"""
search_options_msg = (
" " # Preserves indentation
"""
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(
"Searching Options", textwrap.dedent(search_options_msg)
)
@ -360,7 +368,8 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
"--tags",
dest="tags",
action="store_true",
help="Alias for '--format tags'. Returns a list of all tags and number of occurrences",
help="Alias for '--format tags'. Returns a list of all tags and number of "
"occurrences",
)
exporting.add_argument(
"--short",
@ -400,7 +409,7 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
\t jrnl --config-override editor "nano" \n
\t - Override color selections\n
\t jrnl --config-override colors.body blue --config-override colors.title green
""",
""", # noqa: E501
)
config_overrides.add_argument(
"--co",
@ -430,7 +439,7 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
\t jrnl --config-file /home/user1/work_config.yaml
\t - Use a personal config file stored on a thumb drive: \n
\t jrnl --config-file /media/user1/my-thumb-drive/personal_config.yaml
""",
""", # noqa: E501
)
alternate_config.add_argument(

View file

@ -142,7 +142,7 @@ def postconfig_encrypt(
def postconfig_decrypt(
args: argparse.Namespace, config: dict, original_config: dict
) -> int:
"""Decrypts into new file. If filename is not set, we encrypt the journal file itself."""
"""Decrypts to file. If filename is not set, we encrypt the journal file itself."""
from jrnl.config import update_config
from jrnl.install import save_config
from jrnl.journals import open_journal

View file

@ -37,9 +37,10 @@ def make_yaml_valid_dict(input: list) -> dict:
The dict is created through the yaml loader, with the assumption that
"input[0]: input[1]" is valid yaml.
:param input: list of configuration keys in dot-notation and their respective values.
:param input: list of configuration keys in dot-notation and their respective values
:type input: list
:return: A single level dict of the configuration keys in dot-notation and their respective desired values
:return: A single level dict of the configuration keys in dot-notation and their
respective desired values
:rtype: dict
"""
@ -105,7 +106,7 @@ def scope_config(config: dict, journal_name: str) -> dict:
return config
config = config.copy()
journal_conf = config["journals"].get(journal_name)
if type(journal_conf) is dict:
if isinstance(journal_conf, dict):
# We can override the default config on a by-journal basis
logging.debug(
"Updating configuration with specific journal overrides:\n%s",
@ -180,7 +181,7 @@ def update_config(
"""Updates a config dict with new values - either global if scope is None
or config['journals'][scope] is just a string pointing to a journal file,
or within the scope"""
if scope and type(config["journals"][scope]) is dict: # Update to journal specific
if scope and isinstance(config["journals"][scope], dict):
config["journals"][scope].update(new_config)
elif scope and force_local: # Convert to dict
config["journals"][scope] = {"journal": config["journals"][scope]}

View file

@ -34,9 +34,9 @@ if TYPE_CHECKING:
def run(args: "Namespace"):
"""
Flow:
1. Run standalone command if it doesn't require config (help, version, etc), then exit
1. Run standalone command if it doesn't need config (help, version, etc), then exit
2. Load config
3. Run standalone command if it does require config (encrypt, decrypt, etc), then exit
3. Run standalone command if it does need config (encrypt, decrypt, etc), then exit
4. Load specified journal
5. Start append mode, or search mode
6. Perform actions with results from search mode (if needed)
@ -181,7 +181,9 @@ def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -
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')}"
"Get template:\n"
f"--template: {args.template}\n"
f"from config: {config.get('template')}"
)
template_path = args.template or config.get("template")

View file

@ -81,7 +81,8 @@ 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."
f"Couldn't open {actual_template_path}. "
"Treating template path like a local / abs path."
)
actual_template_path = absolute_path(template_path)

View file

@ -31,11 +31,11 @@ from jrnl.upgrade import is_old_version
def upgrade_config(config_data: dict, alt_config_path: str | None = None) -> None:
"""Checks if there are keys missing in a given config dict, and if so, updates the config file accordingly.
This essentially automatically ports jrnl installations if new config parameters are introduced in later
versions.
Also checks for existence of and difference in version number between config dict and current jrnl version,
and if so, update the config file accordingly.
"""Checks if there are keys missing in a given config dict, and if so, updates the
config file accordingly. This essentially automatically ports jrnl installations
if new config parameters are introduced in later versions. Also checks for
existence of and difference in version number between config dict
and current jrnl version, and if so, update the config file accordingly.
Supply alt_config_path if using an alternate config through --config-file."""
default_config = get_default_config()
missing_keys = set(default_config).difference(config_data)
@ -167,7 +167,7 @@ def install() -> dict:
def _initialize_autocomplete() -> None:
# readline is not included in Windows Active Python and perhaps some other distributions
# readline is not included in Windows Active Python and perhaps some other distss
if sys.modules.get("readline"):
import readline

View file

@ -7,10 +7,9 @@ import os
import re
from typing import TYPE_CHECKING
import ansiwrap
from jrnl.color import colorize
from jrnl.color import highlight_tags_with_background_color
from jrnl.output import wrap_with_ansi_colors
if TYPE_CHECKING:
from .Journal import Journal
@ -89,7 +88,7 @@ class Entry:
}
def __str__(self):
"""Returns a string representation of the entry to be written into a journal file."""
"""Returns string representation of the entry to be written to journal file."""
date_str = self.date.strftime(self.journal.config["timeformat"])
title = "[{}] {}".format(date_str, self.title.rstrip("\n "))
if self.starred:
@ -129,7 +128,7 @@ class Entry:
columns = 79
# Color date / title and bold title
title = ansiwrap.fill(
title = wrap_with_ansi_colors(
date_str
+ " "
+ highlight_tags_with_background_color(
@ -143,35 +142,17 @@ class Entry:
body = highlight_tags_with_background_color(
self, self.body.rstrip(" \n"), self.journal.config["colors"]["body"]
)
body_text = [
colorize(
ansiwrap.fill(
line,
columns,
initial_indent=indent,
subsequent_indent=indent,
drop_whitespace=True,
),
self.journal.config["colors"]["body"],
)
or indent
for line in body.rstrip(" \n").splitlines()
]
# ansiwrap doesn't handle lines with only the "\n" character and some
# ANSI escapes properly, so we have this hack here to make sure the
# beginning of each line has the indent character and it's colored
# properly. textwrap doesn't have this issue, however, it doesn't wrap
# the strings properly as it counts ANSI escapes as literal characters.
# TL;DR: I'm sorry.
body = "\n".join(
[
body = wrap_with_ansi_colors(body, columns - len(indent))
if indent:
# Without explicitly colorizing the indent character, it will lose its
# color after a tag appears.
body = "\n".join(
colorize(indent, self.journal.config["colors"]["body"]) + line
if not ansiwrap.strip_color(line).startswith(indent)
else line
for line in body_text
]
)
for line in body.splitlines()
)
body = colorize(body, self.journal.config["colors"]["body"])
else:
title = (
date_str
@ -233,7 +214,7 @@ SENTENCE_SPLITTER = re.compile(
\s+ # AND a sequence of required spaces.
)
|[\uFF01\uFF0E\uFF1F\uFF61\u3002] # CJK full/half width terminals usually do not have following spaces.
""",
""", # noqa: E501
re.VERBOSE,
)

View file

@ -122,7 +122,8 @@ class Folder(Journal):
@staticmethod
def _get_files(journal_path: str) -> list[str]:
"""Searches through sub directories starting with journal_path and find all text files that look like entries"""
"""Searches through sub directories starting with journal_path and find all text
files that look like entries"""
for year_folder in Folder._get_year_folders(pathlib.Path(journal_path)):
for month_folder in Folder._get_month_folders(year_folder):
yield from Folder._get_day_files(month_folder)

View file

@ -102,7 +102,7 @@ class Journal:
return self.encryption_method.encrypt(text)
def open(self, filename: str | None = None) -> "Journal":
"""Opens the journal file defined in the config and parses it into a list of Entries.
"""Opens the journal file and parses it into a list of Entries
Entries have the form (date, title, body)."""
filename = filename or self.config["journal"]
dirname = os.path.dirname(filename)
@ -144,7 +144,7 @@ class Journal:
self._store(filename, text)
def validate_parsing(self) -> bool:
"""Confirms that the jrnl is still parsed correctly after being dumped to text."""
"""Confirms that the jrnl is still parsed correctly after conversion to text."""
new_entries = self._parse(self._to_text())
return all(entry == new_entries[i] for i, entry in enumerate(self.entries))
@ -225,8 +225,9 @@ class Journal:
@property
def tags(self) -> list[Tag]:
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time
# I came across this construction, worry not and embrace the ensuing moment of enlightment.
# Astute reader: should the following line leave you as puzzled as me the first
# time I came across this construction, worry not and embrace the ensuing moment
# of enlightment.
tags = [tag for entry in self.entries for tag in set(entry.tags)]
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
tag_counts = {(tags.count(tag), tag) for tag in tags}
@ -343,7 +344,8 @@ class Journal:
def new_entry(self, raw: str, date=None, sort: bool = True) -> Entry:
"""Constructs a new entry from some raw text input.
If a date is given, it will parse and use this, otherwise scan for a date in the input first.
If a date is given, it will parse and use this, otherwise scan for a date in
the input first.
"""
raw = raw.replace("\\n ", "\n").replace("\\n", "\n")

View file

@ -43,8 +43,8 @@ class MsgText(Enum):
Do you want to encrypt your journal? (You can always change this later)
"""
UseColorsQuestion = """
Do you want jrnl to use colors when displaying entries? (You can always change this later)
"""
Do you want jrnl to use colors to display entries? (You can always change this later)
""" # noqa: E501 - the line is still under 88 when dedented
YesOrNoPromptDefaultYes = "[Y/n]"
YesOrNoPromptDefaultNo = "[y/N]"
ContinueUpgrade = "Continue upgrading jrnl?"

View file

@ -131,3 +131,12 @@ def format_msg_text(msg: Message) -> Text:
text = textwrap.dedent(text)
text = text.strip()
return Text(text)
def wrap_with_ansi_colors(text: str, width: int) -> str:
richtext = Text.from_ansi(text, no_wrap=False, tab_size=None)
console = Console(width=width, force_terminal=True)
with console.capture() as capture:
console.print(richtext, sep="", end="")
return capture.get()

View file

@ -56,7 +56,8 @@ def _recursively_apply(tree: dict, nodes: list, override_value) -> dict:
Args:
config (dict): Configuration to modify
nodes (list): Vector of override keys; the length of the vector indicates tree depth
nodes (list): Vector of override keys; the length of the vector indicates tree
depth
override_value (str): Runtime override passed from the command-line
"""
key = nodes[0]

View file

@ -18,7 +18,7 @@ if TYPE_CHECKING:
class FancyExporter(TextExporter):
"""This Exporter can convert entries and journals into text with unicode box drawing characters."""
"""This Exporter converts entries and journals into text with unicode boxes."""
names = ["fancy", "boxed"]
extension = "txt"

View file

@ -12,7 +12,7 @@ if TYPE_CHECKING:
class TagExporter(TextExporter):
"""This Exporter can lists the tags for entries and journals, exported as a plain text file."""
"""This Exporter lists the tags for entries and journals."""
names = ["tags"]
extension = "tags"

View file

@ -13,7 +13,8 @@ if TYPE_CHECKING:
def get_tags_count(journal: "Journal") -> set[tuple[int, str]]:
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time
# I came across this construction, worry not and embrace the ensuing moment of enlightment.
# I came across this construction, worry not and embrace the ensuing moment of
# enlightment.
tags = [tag for entry in journal.entries for tag in set(entry.tags)]
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
tag_counts = {(tags.count(tag), tag) for tag in tags}

View file

@ -18,14 +18,15 @@ if TYPE_CHECKING:
class YAMLExporter(TextExporter):
"""This Exporter can convert entries and journals into Markdown formatted text with YAML front matter."""
"""This Exporter converts entries and journals into Markdown formatted text with
YAML front matter."""
names = ["yaml"]
extension = "md"
@classmethod
def export_entry(cls, entry: "Entry", to_multifile: bool = True) -> str:
"""Returns a markdown representation of a single entry, with YAML front matter."""
"""Returns a markdown representation of an entry, with YAML front matter."""
if to_multifile is False:
raise JrnlException(Message(MsgText.YamlMustBeDirectory, MsgStyle.ERROR))
@ -117,7 +118,14 @@ class YAMLExporter(TextExporter):
# source directory is entry.journal.config['journal']
# output directory is...?
return "{start}\ntitle: {title}\ndate: {date}\nstarred: {starred}\ntags: {tags}\n{dayone}body: |{body}{end}".format(
return (
"{start}\n"
"title: {title}\n"
"date: {date}\n"
"starred: {starred}\n"
"tags: {tags}\n"
"{dayone}body: |{body}{end}"
).format(
start="---",
date=date_str,
title=entry.title,

View file

@ -9,14 +9,11 @@ DEFAULT_PAST = datetime.datetime(FAKE_YEAR, 1, 1, 0, 0)
def __get_pdt_calendar():
try:
import parsedatetime.parsedatetime_consts as pdt
except ImportError:
import parsedatetime as pdt
import parsedatetime as pdt
consts = pdt.Constants(usePyICU=False)
consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
calendar = pdt.Calendar(consts)
calendar = pdt.Calendar(consts, version=pdt.VERSION_CONTEXT_STYLE)
return calendar
@ -34,14 +31,18 @@ def parse(
elif isinstance(date_str, datetime.datetime):
return date_str
# Don't try to parse anything with 6 or fewer characters and was parsed from the existing journal.
# It's probably a markdown footnote
# Don't try to parse anything with 6 or fewer characters and was parsed from the
# existing journal. It's probably a markdown footnote
if len(date_str) <= 6 and bracketed:
return None
default_date = DEFAULT_FUTURE if inclusive else DEFAULT_PAST
date = None
year_present = False
hasTime = False
hasDate = False
while not date:
try:
from dateutil.parser import parse as dateparse
@ -53,7 +54,8 @@ def parse(
)
else:
year_present = True
flag = 1 if date.hour == date.minute == 0 else 2
hasTime = not (date.hour == date.minute == 0)
hasDate = True
date = date.timetuple()
except Exception as e:
if e.args[0] == "day is out of range for month":
@ -61,9 +63,11 @@ def parse(
default_date = datetime.datetime(y, m, d - 1, H, M, S)
else:
calendar = __get_pdt_calendar()
date, flag = calendar.parse(date_str)
date, parse_context = calendar.parse(date_str)
hasTime = parse_context.hasTime
hasDate = parse_context.hasDate
if not flag: # Oops, unparsable.
if not hasDate and not hasTime:
try: # Try and parse this as a single year
year = int(date_str)
return datetime.datetime(year, 1, 1)
@ -72,8 +76,8 @@ def parse(
except TypeError:
return None
if flag == 1: # Date found, but no time. Use the default time.
date = datetime.datetime(
if hasDate and not hasTime:
date = datetime.datetime( # Use the default time
*date[:3],
hour=23 if inclusive else default_hour or 0,
minute=59 if inclusive else default_minute or 0,
@ -82,9 +86,9 @@ def parse(
else:
date = datetime.datetime(*date[:6])
# Ugly heuristic: if the date is more than 4 weeks in the future, we got the year wrong.
# Rather than this, we would like to see parsedatetime patched so we can tell it to prefer
# past dates
# Ugly heuristic: if the date is more than 4 weeks in the future, we got the year
# wrong. Rather than this, we would like to see parsedatetime patched so we can
# tell it to prefer past dates
dt = datetime.datetime.now() - date
if dt.days < -28 and not year_present:
date = date.replace(date.year - 1)

1541
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "jrnl"
version = "v4.0.1"
version = "v4.1"
description = "Collect your thoughts and notes without leaving the command line."
authors = [
"jrnl contributors <maintainers@jrnl.sh>",
@ -29,7 +29,6 @@ classifiers = [
[tool.poetry.dependencies]
python = ">=3.10.0, <3.13"
ansiwrap = "^0.8.4"
colorama = ">=0.4" # https://github.com/tartley/colorama/blob/master/CHANGELOG.rst
cryptography = ">=3.0" # https://cryptography.io/en/latest/api-stability.html
keyring = ">=21.0" # https://github.com/jaraco/keyring#integration
@ -44,13 +43,7 @@ tzlocal = ">=4.0" # https://github.com/regebro/tzlocal/blob/master/CHANGES.txt
[tool.poetry.dev-dependencies]
black = { version = ">=21.5b2", allow-prereleases = true }
flakeheaven = ">=3.0"
flake8-black = ">=0.3.3"
flake8-isort = ">=5.0.0"
flake8-type-checking = ">=2.2.0"
flake8-simplify = ">=0.19"
ipdb = "*"
isort = ">=5.10"
mkdocs = ">=1.4"
parse-type = ">=0.6.0"
poethepoet = "*"
@ -59,6 +52,7 @@ pytest-bdd = ">=6.0"
pytest-clarity = "*"
pytest-xdist = ">=2.5.0"
requests = "*"
ruff = ">=0.0.276"
toml = ">=0.10"
tox = "*"
xmltodict = "*"
@ -88,18 +82,18 @@ test-run = [
# Groups of tasks
format.default_item_type = "cmd"
format.sequence = [
"isort .",
"ruff check . --select I --fix", # equivalent to "isort ."
"black .",
]
lint.env = { FLAKEHEAVEN_CACHE_TIMEOUT = "0" }
lint.default_item_type = "cmd"
lint.sequence = [
"poetry --version",
"poetry check",
"flakeheaven --version",
"flakeheaven plugins",
"flakeheaven lint",
"ruff --version",
"ruff .",
"black --version",
"black --check ."
]
test = [
@ -107,11 +101,6 @@ test = [
"test-run",
]
[tool.isort]
profile = "black"
force_single_line = true
known_first_party = ["jrnl", "tests"]
[tool.pytest.ini_options]
minversion = "6.0"
required_plugins = [
@ -132,34 +121,40 @@ addopts = [
filterwarnings = [
"ignore::DeprecationWarning",
"ignore:Flag style will be deprecated in.*",
"ignore:[WinError 32].*",
"ignore:[WinError 5].*"
]
[tool.flakeheaven]
max_line_length = 88
[tool.ruff]
line-length = 88
target-version = "py310"
# https://beta.ruff.rs/docs/rules/
select = [
'F', # Pyflakes
'E', # pycodestyle errors
'W', # pycodestyle warnings
'I', # isort
'ASYNC', # flake8-async
'S110', # try-except-pass
'S112', # try-except-continue
'EM', # flake8-errmsg
'ISC', # flake8-implicit-str-concat
'Q', # flake8-quotes
'RSE', # flake8-raise
'TID', # flake8-tidy-imports
'TCH', # flake8-type-checking
'T100', # debugger, don't allow break points
'ICN' # flake8-import-conventions
]
exclude = [".git", ".tox", ".venv", "node_modules"]
[tool.flakeheaven.plugins]
"py*" = ["+*"]
pycodestyle = [
"-E101",
"-E111", "-E114", "-E115", "-E116", "-E117",
"-E12*",
"-E13*",
"-E2*",
"-E3*",
"-E401",
"-E5*",
"-E70",
"-W1*", "-W2*", "-W3*", "-W5*",
]
"flake8-*" = ["+*"]
flake8-black = ["-BLK901"]
[tool.ruff.isort]
force-single-line = true
known-first-party = ["jrnl", "tests"]
[tool.flakeheaven.exceptions."*/__init__.py"]
pyflakes = ["-F401"]
[tool.ruff.per-file-ignores]
"__init__.py" = ["F401"] # unused imports
[build-system]
requires = ["poetry-core>=1.0.0"]

View file

@ -72,7 +72,7 @@ Feature: Custom formats
And the output should be
2020-08-29 11:11 Entry the first.
| Lorem @ipsum dolor sit amet, consectetur adipiscing elit. Praesent malesuada
| quis est ac dignissim. Aliquam dignissim rutrum pretium. Phasellus
| quis est ac dignissim. Aliquam dignissim rutrum pretium. Phasellus
| pellentesque
| augue et venenatis facilisis. Suspendisse potenti. Sed dignissim sed nisl eu
| consequat. Aenean ante ex, elementum ut interdum et, mattis eget lacus. In

View file

@ -38,38 +38,41 @@ def output_should_match(regex, cli_run):
assert matches, f"\nRegex didn't match:\n{regex}\n{str(out)}\n{str(matches)}"
@then(parse("the output {it_should:Should} contain\n{expected_output}", SHOULD_DICT))
@then(parse('the output {it_should:Should} contain "{expected_output}"', SHOULD_DICT))
@then(parse("the output {it_should:Should} contain\n{expected}", SHOULD_DICT))
@then(parse('the output {it_should:Should} contain "{expected}"', SHOULD_DICT))
@then(
parse(
"the {which_output_stream} output {it_should:Should} contain\n{expected_output}",
"the {which_output_stream} output {it_should:Should} contain\n{expected}",
SHOULD_DICT,
)
)
@then(
parse(
'the {which_output_stream} output {it_should:Should} contain "{expected_output}"',
'the {which_output_stream} output {it_should:Should} contain "{expected}"',
SHOULD_DICT,
)
)
def output_should_contain(expected_output, which_output_stream, cli_run, it_should):
output_str = f"\nEXPECTED:\n{expected_output}\n\nACTUAL STDOUT:\n{cli_run['stdout']}\n\nACTUAL STDERR:\n{cli_run['stderr']}"
assert expected_output
def output_should_contain(expected, which_output_stream, cli_run, it_should):
output_str = (
f"\nEXPECTED:\n{expected}\n\n"
f"ACTUAL STDOUT:\n{cli_run['stdout']}\n\n"
f"ACTUAL STDERR:\n{cli_run['stderr']}"
)
assert expected
if which_output_stream is None:
assert ((expected_output in cli_run["stdout"]) == it_should) or (
(expected_output in cli_run["stderr"]) == it_should
assert ((expected in cli_run["stdout"]) == it_should) or (
(expected in cli_run["stderr"]) == it_should
), output_str
elif which_output_stream == "standard":
assert (expected_output in cli_run["stdout"]) == it_should, output_str
assert (expected in cli_run["stdout"]) == it_should, output_str
elif which_output_stream == "error":
assert (expected_output in cli_run["stderr"]) == it_should, output_str
assert (expected in cli_run["stderr"]) == it_should, output_str
else:
assert (
expected_output in cli_run[which_output_stream]
) == it_should, output_str
assert (expected in cli_run[which_output_stream]) == it_should, output_str
@then(parse("the output should not contain\n{expected_output}"))
@ -119,7 +122,8 @@ def output_should_be_columns_wide(cli_run, width):
@then(
parse(
'the default journal "{journal_file}" should be in the "{journal_dir}" directory'
'the default journal "{journal_file}" '
'should be in the "{journal_dir}" directory'
)
)
def default_journal_location(journal_file, journal_dir, config_on_disk, temp_dir):
@ -135,13 +139,15 @@ def default_journal_location(journal_file, journal_dir, config_on_disk, temp_dir
@then(
parse(
'the config for journal "{journal_name}" {it_should:Should} contain "{some_yaml}"',
'the config for journal "{journal_name}" '
'{it_should:Should} contain "{some_yaml}"',
SHOULD_DICT,
)
)
@then(
parse(
'the config for journal "{journal_name}" {it_should:Should} contain\n{some_yaml}',
'the config for journal "{journal_name}" '
"{it_should:Should} contain\n{some_yaml}",
SHOULD_DICT,
)
)
@ -155,7 +161,7 @@ def config_var_on_disk(config_on_disk, journal_name, it_should, some_yaml):
expected = YAML(typ="safe").load(some_yaml)
actual_slice = actual
if type(actual) is dict:
if isinstance(actual, dict):
# `expected` objects formatted in yaml only compare one level deep
actual_slice = {key: actual.get(key) for key in expected.keys()}
@ -164,13 +170,15 @@ def config_var_on_disk(config_on_disk, journal_name, it_should, some_yaml):
@then(
parse(
'the config in memory for journal "{journal_name}" {it_should:Should} contain "{some_yaml}"',
'the config in memory for journal "{journal_name}" '
'{it_should:Should} contain "{some_yaml}"',
SHOULD_DICT,
)
)
@then(
parse(
'the config in memory for journal "{journal_name}" {it_should:Should} contain\n{some_yaml}',
'the config in memory for journal "{journal_name}" '
"{it_should:Should} contain\n{some_yaml}",
SHOULD_DICT,
)
)
@ -188,7 +196,7 @@ def config_var_in_memory(config_in_memory, journal_name, it_should, some_yaml):
expected = YAML(typ="safe").load(some_yaml)
actual_slice = actual
if type(actual) is dict:
if isinstance(actual, dict):
# `expected` objects formatted in yaml only compare one level deep
actual_slice = {key: get_nested_val(actual, key) for key in expected.keys()}
@ -360,7 +368,7 @@ def assert_output_field_content(field_name, comparison, expected_keys, parsed_ou
my_obj = my_obj[node]
if comparison == "be":
if type(my_obj) is str:
if isinstance(my_obj, str):
assert expected_keys == my_obj, [my_obj, expected_keys]
else:
assert set(expected_keys) == set(my_obj), [
@ -368,7 +376,7 @@ def assert_output_field_content(field_name, comparison, expected_keys, parsed_ou
set(expected_keys),
]
elif comparison == "contain":
if type(my_obj) is str:
if isinstance(my_obj, str):
assert expected_keys in my_obj, [my_obj, expected_keys]
else:
assert all(elem in my_obj for elem in expected_keys), [

View file

@ -242,7 +242,9 @@ def test_color_override():
def test_multiple_overrides():
parsed_args = cli_as_dict(
'--config-override colors.title green --config-override editor "nano" --config-override journal.scratchpad "/tmp/scratchpad"'
"--config-override colors.title green "
'--config-override editor "nano" '
'--config-override journal.scratchpad "/tmp/scratchpad"'
)
assert parsed_args == expected_args(
config_override=[