Implement datetime handling in pytest-bdd

- This was awful and convoluted

Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
This commit is contained in:
Jonathan Wren 2021-05-01 15:57:16 -07:00
parent cda07bf8d9
commit 4aabb73847
16 changed files with 133 additions and 87 deletions

View file

@ -21,9 +21,16 @@ lint: ## Check style with various tools
poetry run pyflakes jrnl tests poetry run pyflakes jrnl tests
poetry run black --check --diff . poetry run black --check --diff .
test: lint ## Run unit tests and behave tests unit: # unit tests
poetry run pytest poetry run pytest tests/unit
poetry run behave --no-skipped --format progress2
e2e: # end-to-end tests
poetry run pytest tests/step_defs --gherkin-terminal-reporter --tb=native --diff-type=unified
e2e-debug: # end-to-end tests
poetry run pytest tests/step_defs --gherkin-terminal-reporter --tb=native --diff-type=unified -x -vv
test: lint unit e2e ## Run unit tests and behave tests
build: build:
poetry build poetry build

View file

@ -1,4 +1,4 @@
from datetime import datetime import datetime
import fnmatch import fnmatch
import os import os
from pathlib import Path from pathlib import Path
@ -116,7 +116,7 @@ class DayOne(Journal.Journal):
"""Writes only the entries that have been modified into plist files.""" """Writes only the entries that have been modified into plist files."""
for entry in self.entries: for entry in self.entries:
if entry.modified: if entry.modified:
utc_time = datetime.utcfromtimestamp( utc_time = datetime.datetime.utcfromtimestamp(
time.mktime(entry.date.timetuple()) time.mktime(entry.date.timetuple())
) )

View file

@ -2,7 +2,7 @@
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
from datetime import datetime import datetime
import re import re
import ansiwrap import ansiwrap
@ -14,7 +14,7 @@ from .color import highlight_tags_with_background_color
class Entry: class Entry:
def __init__(self, journal, date=None, text="", starred=False): def __init__(self, journal, date=None, text="", starred=False):
self.journal = journal # Reference to journal mainly to access its config self.journal = journal # Reference to journal mainly to access its config
self.date = date or datetime.now() self.date = date or datetime.datetime.now()
self.text = text self.text = text
self._title = None self._title = None
self._body = None self._body = None

View file

@ -2,7 +2,7 @@
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
from datetime import datetime import datetime
import logging import logging
import os import os
import re import re
@ -134,7 +134,9 @@ class Journal:
for match in date_blob_re.finditer(journal_txt): for match in date_blob_re.finditer(journal_txt):
date_blob = match.groups()[0] date_blob = match.groups()[0]
try: try:
new_date = datetime.strptime(date_blob, self.config["timeformat"]) new_date = datetime.datetime.strptime(
date_blob, self.config["timeformat"]
)
except ValueError: except ValueError:
# Passing in a date that had brackets around it # Passing in a date that had brackets around it
new_date = time.parse(date_blob, bracketed=True) new_date = time.parse(date_blob, bracketed=True)
@ -347,7 +349,7 @@ class LegacyJournal(Journal):
"""Parses a journal that's stored in a string and returns a list of entries""" """Parses a journal that's stored in a string and returns a list of entries"""
# Entries start with a line that looks like 'date title' - let's figure out how # Entries start with a line that looks like 'date title' - let's figure out how
# long the date will be by constructing one # long the date will be by constructing one
date_length = len(datetime.today().strftime(self.config["timeformat"])) date_length = len(datetime.datetime.today().strftime(self.config["timeformat"]))
# Initialise our current entry # Initialise our current entry
entries = [] entries = []
@ -357,7 +359,7 @@ class LegacyJournal(Journal):
line = line.rstrip() line = line.rstrip()
try: try:
# try to parse line as date => new entry begins # try to parse line as date => new entry begins
new_date = datetime.strptime( new_date = datetime.datetime.strptime(
line[:date_length], self.config["timeformat"] line[:date_length], self.config["timeformat"]
) )

View file

@ -1,11 +1,11 @@
# Copyright (C) 2012-2021 jrnl contributors # Copyright (C) 2012-2021 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html # License: https://www.gnu.org/licenses/gpl-3.0.html
from datetime import datetime import datetime
FAKE_YEAR = 9999 FAKE_YEAR = 9999
DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59) DEFAULT_FUTURE = datetime.datetime(FAKE_YEAR, 12, 31, 23, 59, 59)
DEFAULT_PAST = datetime(FAKE_YEAR, 1, 1, 0, 0) DEFAULT_PAST = datetime.datetime(FAKE_YEAR, 1, 1, 0, 0)
def __get_pdt_calendar(): def __get_pdt_calendar():
@ -27,7 +27,7 @@ def parse(
"""Parses a string containing a fuzzy date and returns a datetime.datetime object""" """Parses a string containing a fuzzy date and returns a datetime.datetime object"""
if not date_str: if not date_str:
return None return None
elif isinstance(date_str, datetime): elif isinstance(date_str, datetime.datetime):
return date_str return date_str
# Don't try to parse anything with 6 or less characters and was parsed from the existing journal. # Don't try to parse anything with 6 or less characters and was parsed from the existing journal.
@ -44,7 +44,9 @@ def parse(
date = dateparse(date_str, default=default_date) date = dateparse(date_str, default=default_date)
if date.year == FAKE_YEAR: if date.year == FAKE_YEAR:
date = datetime(datetime.now().year, date.timetuple()[1:6]) date = datetime.datetime(
datetime.datetime.now().year, date.timetuple()[1:6]
)
else: else:
year_present = True year_present = True
flag = 1 if date.hour == date.minute == 0 else 2 flag = 1 if date.hour == date.minute == 0 else 2
@ -52,7 +54,7 @@ def parse(
except Exception as e: except Exception as e:
if e.args[0] == "day is out of range for month": if e.args[0] == "day is out of range for month":
y, m, d, H, M, S = default_date.timetuple()[:6] y, m, d, H, M, S = default_date.timetuple()[:6]
default_date = datetime(y, m, d - 1, H, M, S) default_date = datetime.datetime(y, m, d - 1, H, M, S)
else: else:
calendar = __get_pdt_calendar() calendar = __get_pdt_calendar()
date, flag = calendar.parse(date_str) date, flag = calendar.parse(date_str)
@ -60,26 +62,26 @@ def parse(
if not flag: # Oops, unparsable. if not flag: # Oops, unparsable.
try: # Try and parse this as a single year try: # Try and parse this as a single year
year = int(date_str) year = int(date_str)
return datetime(year, 1, 1) return datetime.datetime(year, 1, 1)
except ValueError: except ValueError:
return None return None
except TypeError: except TypeError:
return None return None
if flag == 1: # Date found, but no time. Use the default time. if flag == 1: # Date found, but no time. Use the default time.
date = datetime( date = datetime.datetime(
*date[:3], *date[:3],
hour=23 if inclusive else default_hour or 0, hour=23 if inclusive else default_hour or 0,
minute=59 if inclusive else default_minute or 0, minute=59 if inclusive else default_minute or 0,
second=59 if inclusive else 0 second=59 if inclusive else 0
) )
else: else:
date = datetime(*date[:6]) date = datetime.datetime(*date[:6])
# Ugly heuristic: if the date is more than 4 weeks in the future, we got the year wrong. # Ugly heuristic: if the date is more than 4 weeks in the future, we got the year wrong.
# Rather then this, we would like to see parsedatetime patched so we can tell it to prefer # Rather then this, we would like to see parsedatetime patched so we can tell it to prefer
# past dates # past dates
dt = datetime.now() - date dt = datetime.datetime.now() - date
if dt.days < -28 and not year_present: if dt.days < -28 and not year_present:
date = date.replace(date.year - 1) date = date.replace(date.year - 1)
return date return date

View file

@ -36,10 +36,10 @@ Feature: Reading and writing to journal with custom date formats
When we run "jrnl <command>" When we run "jrnl <command>"
Then we should see the message "Entry added" Then we should see the message "Entry added"
When we run "jrnl -n 1" When we run "jrnl -n 1"
Then the output should contain "<output>" Then the output should contain "<expected_output>"
Examples: Day-first Dates Examples: Day-first Dates
| config_file | command | output | | config_file | command | expected_output |
| little_endian_dates.yaml | 2020-09-19: My first entry. | 19.09.2020 09:00 My first entry. | | little_endian_dates.yaml | 2020-09-19: My first entry. | 19.09.2020 09:00 My first entry. |
| little_endian_dates.yaml | 2020-08-09: My second entry. | 09.08.2020 09:00 My second entry. | | little_endian_dates.yaml | 2020-08-09: My second entry. | 09.08.2020 09:00 My second entry. |
| little_endian_dates.yaml | 2020-02-29: Test. | 29.02.2020 09:00 Test. | | little_endian_dates.yaml | 2020-02-29: Test. | 29.02.2020 09:00 Test. |
@ -53,10 +53,10 @@ Feature: Reading and writing to journal with custom date formats
Scenario Outline: Searching for dates with custom date Scenario Outline: Searching for dates with custom date
Given we use the config "<config_file>" Given we use the config "<config_file>"
When we run "jrnl <command>" When we run "jrnl <command>"
Then the output should be "<output>" Then the output should be "<expected_output>"
Examples: Day-first Dates Examples: Day-first Dates
| config_file | command | output | | config_file | command | expected_output |
| little_endian_dates.yaml | -on '2013-07-10' --short | 10.07.2013 15:40 Life is good. | | little_endian_dates.yaml | -on '2013-07-10' --short | 10.07.2013 15:40 Life is good. |
| little_endian_dates.yaml | -on 'june 9 2013' --short | 09.06.2013 15:39 My first entry. | | little_endian_dates.yaml | -on 'june 9 2013' --short | 09.06.2013 15:39 My first entry. |
| little_endian_dates.yaml | -on 'july 10 2013' --short | 10.07.2013 15:40 Life is good. | | little_endian_dates.yaml | -on 'july 10 2013' --short | 10.07.2013 15:40 Life is good. |
@ -83,47 +83,48 @@ Feature: Reading and writing to journal with custom date formats
Then the output should not contain "Life is good" Then the output should not contain "Life is good"
And the output should not contain "But I'm better." And the output should not contain "But I'm better."
Scenario Outline: Create entry using day of the week as entry date one.
Scenario Outline: Create entry using day of the week as entry date.
Given we use the config "simple.yaml" Given we use the config "simple.yaml"
And now is "2019-03-12 01:30:32 PM"
When we run "jrnl <command>" When we run "jrnl <command>"
Then we should see the message "Entry added" Then we should see the message "Entry added"
When we run "jrnl -1" When we run "jrnl -1"
Then the output should contain "<output>" Then the output should contain "<expected_output>"
Then the output should contain the date "<date>" Then the output should contain the date "<date>"
Examples: Days of the week Examples: Days of the week
| command | output | date | | command | expected_output | date |
| Monday: entry on a monday | entry on a monday | monday at 9am | | Monday: entry on a monday | entry on a monday | 2019-03-11 09:00 |
| Tuesday: entry on a tuesday | entry on a tuesday | tuesday at 9am | | Tuesday: entry on a tuesday | entry on a tuesday | 2019-03-05 09:00 |
| Wednesday: entry on a wednesday | entry on a wednesday | wednesday at 9am | | Wednesday: entry on a wednesday | entry on a wednesday | 2019-03-06 09:00 |
| Thursday: entry on a thursday | entry on a thursday | thursday at 9am | | Thursday: entry on a thursday | entry on a thursday | 2019-03-07 09:00 |
| Friday: entry on a friday | entry on a friday | friday at 9am | | Friday: entry on a friday | entry on a friday | 2019-03-08 09:00 |
| Saturday: entry on a saturday | entry on a saturday | saturday at 9am | | Saturday: entry on a saturday | entry on a saturday | 2019-03-09 09:00 |
| Sunday: entry on a sunday | entry on a sunday | sunday at 9am | | Sunday: entry on a sunday | entry on a sunday | 2019-03-10 09:00 |
| sunday: entry on a sunday | entry on a sunday | sunday at 9am | | sunday: entry on a sunday | entry on a sunday | 2019-03-10 09:00 |
| sUndAy: entry on a sunday | entry on a sunday | sunday at 9am | | sUndAy: entry on a sunday | entry on a sunday | 2019-03-10 09:00 |
Scenario Outline: Create entry using day of the week as entry date. Scenario Outline: Create entry using day of the week as entry date two.
Given we use the config "simple.yaml" Given we use the config "simple.yaml"
And now is "2019-03-12 01:30:32 PM"
When we run "jrnl <command>" When we run "jrnl <command>"
Then we should see the message "Entry added" Then we should see the message "Entry added"
When we run "jrnl -1" When we run "jrnl -1"
Then the output should contain "<output>" Then the output should contain "<expected_output>"
Then the output should contain the date "<date>" Then the output should contain the date "<date>"
Examples: Days of the week Examples: Days of the week
| command | output | date | | command | expected_output | date |
| Mon: entry on a monday | entry on a monday | monday at 9am | | Mon: entry on a monday | entry on a monday | 2019-03-11 09:00 |
| Tue: entry on a tuesday | entry on a tuesday | tuesday at 9am | | Tue: entry on a tuesday | entry on a tuesday | 2019-03-05 09:00 |
| Wed: entry on a wednesday | entry on a wednesday | wednesday at 9am | | Wed: entry on a wednesday | entry on a wednesday | 2019-03-06 09:00 |
| Thu: entry on a thursday | entry on a thursday | thursday at 9am | | Thu: entry on a thursday | entry on a thursday | 2019-03-07 09:00 |
| Fri: entry on a friday | entry on a friday | friday at 9am | | Fri: entry on a friday | entry on a friday | 2019-03-08 09:00 |
| Sat: entry on a saturday | entry on a saturday | saturday at 9am | | Sat: entry on a saturday | entry on a saturday | 2019-03-09 09:00 |
| Sun: entry on a sunday | entry on a sunday | sunday at 9am | | Sun: entry on a sunday | entry on a sunday | 2019-03-10 09:00 |
| sun: entry on a sunday | entry on a sunday | sunday at 9am | | sun: entry on a sunday | entry on a sunday | 2019-03-10 09:00 |
| sUn: entry on a sunday | entry on a sunday | sunday at 9am | | sUn: entry on a sunday | entry on a sunday | 2019-03-10 09:00 |
Scenario: Journals with unreadable dates should still be loaded Scenario: Journals with unreadable dates should still be loaded

View file

@ -4,6 +4,7 @@
import ast import ast
import json import json
import os import os
from datetime import datetime
from collections import defaultdict from collections import defaultdict
from keyring import backend from keyring import backend
from keyring import set_keyring from keyring import set_keyring
@ -15,6 +16,7 @@ import re
import shutil import shutil
import tempfile import tempfile
from unittest.mock import patch from unittest.mock import patch
from unittest.mock import MagicMock
from xml.etree import ElementTree from xml.etree import ElementTree
from pytest_bdd import given from pytest_bdd import given
@ -31,6 +33,7 @@ from jrnl.cli import cli
from jrnl.config import load_config from jrnl.config import load_config
from jrnl.os_compat import split_args from jrnl.os_compat import split_args
from jrnl.os_compat import on_windows from jrnl.os_compat import on_windows
from jrnl.time import __get_pdt_calendar
class TestKeyring(backend.KeyringBackend): class TestKeyring(backend.KeyringBackend):
@ -99,10 +102,6 @@ def pytest_bdd_apply_tag(tag, function):
# ----- UTILS ----- # # ----- UTILS ----- #
def failed_msg(msg, expected, actual):
return f"{msg}\nExpected:\n{expected}\n---end---\nActual:\n{actual}\n---end---\n"
def read_value_from_string(string): def read_value_from_string(string):
if string[0] == "{": if string[0] == "{":
# Handle value being a dictionary # Handle value being a dictionary
@ -142,6 +141,11 @@ def password():
return "" return ""
@fixture
def now_date():
return {"datetime": datetime, "calendar_parse": __get_pdt_calendar()}
@fixture @fixture
def cache_dir(): def cache_dir():
return {"exists": False, "path": ""} return {"exists": False, "path": ""}
@ -251,6 +255,29 @@ def we_enter_editor(editor_method, editor_input, editor_state):
editor_state["intent"] = {"method": file_method, "input": editor_input} editor_state["intent"] = {"method": file_method, "input": editor_input}
@given(parse('now is "<date_str>"'), target_fixture="now_date")
@given(parse('now is "{date_str}"'), target_fixture="now_date")
def now_is_str(date_str):
class DatetimeMagicMock(MagicMock):
# needed because jrnl does some reflection on datetime
def __instancecheck__(self, subclass):
return isinstance(subclass, datetime)
my_date = datetime.strptime(date_str, "%Y-%m-%d %I:%M:%S %p")
# jrnl uses two different classes to parse dates, so both must be mocked
datetime_mock = DatetimeMagicMock(wraps=datetime)
datetime_mock.now.return_value = my_date
pdt = __get_pdt_calendar()
calendar_mock = MagicMock(wraps=pdt)
calendar_mock.parse.side_effect = lambda date_str_input: pdt.parse(
date_str_input, my_date
)
return {"datetime": datetime_mock, "calendar_parse": calendar_mock}
@then(parse("the editor should have been called")) @then(parse("the editor should have been called"))
@then(parse("the editor should have been called with {num_args} arguments")) @then(parse("the editor should have been called with {num_args} arguments"))
def count_editor_args(num_args, cli_run, editor_state): def count_editor_args(num_args, cli_run, editor_state):
@ -328,9 +355,9 @@ def we_run(
cli_run, cli_run,
capsys, capsys,
password, password,
keyring,
cache_dir, cache_dir,
editor, editor,
now_date,
): ):
if cache_dir["exists"]: if cache_dir["exists"]:
command = command.format(cache_dir=cache_dir["path"]) command = command.format(cache_dir=cache_dir["path"])
@ -354,6 +381,8 @@ def we_run(
patch("sys.stdin.read", side_effect=user_input) as mock_stdin, \ patch("sys.stdin.read", side_effect=user_input) as mock_stdin, \
patch("builtins.input", side_effect=user_input) as mock_input, \ patch("builtins.input", side_effect=user_input) as mock_input, \
patch("getpass.getpass", side_effect=password) as mock_getpass, \ patch("getpass.getpass", side_effect=password) as mock_getpass, \
patch("datetime.datetime", new=now_date["datetime"]), \
patch("jrnl.time.__get_pdt_calendar", return_value=now_date["calendar_parse"]), \
patch("jrnl.install.get_config_path", return_value=config_path), \ patch("jrnl.install.get_config_path", return_value=config_path), \
patch("jrnl.config.get_config_path", return_value=config_path), \ patch("jrnl.config.get_config_path", return_value=config_path), \
patch("subprocess.call", side_effect=editor) as mock_editor \ patch("subprocess.call", side_effect=editor) as mock_editor \
@ -392,48 +421,53 @@ def output_should_match(regex, cli_run):
assert matches, f"\nRegex didn't match:\n{regex}\n{str(out)}\n{str(matches)}" assert matches, f"\nRegex didn't match:\n{regex}\n{str(out)}\n{str(matches)}"
@then(parse("the output should contain\n{output}")) @then(parse("the output should contain\n{expected_output}"))
@then(parse('the output should contain "{output}"')) @then(parse('the output should contain "{expected_output}"'))
@then('the output should contain "<output>"') @then('the output should contain "<expected_output>"')
@then(parse("the {which_output_stream} output should contain\n{output}")) @then(parse("the {which_output_stream} output should contain\n{expected_output}"))
@then(parse('the {which_output_stream} output should contain "{output}"')) @then(parse('the {which_output_stream} output should contain "{expected_output}"'))
def output_should_contain(output, which_output_stream, cli_run): def output_should_contain(expected_output, which_output_stream, cli_run):
assert output assert expected_output
if which_output_stream is None: if which_output_stream is None:
assert (output in cli_run["stdout"]) or (output in cli_run["stderr"]) assert (expected_output in cli_run["stdout"]) or (
expected_output in cli_run["stderr"]
)
elif which_output_stream == "standard": elif which_output_stream == "standard":
assert output in cli_run["stdout"] assert expected_output in cli_run["stdout"]
elif which_output_stream == "error": elif which_output_stream == "error":
assert output in cli_run["stderr"] assert expected_output in cli_run["stderr"]
else: else:
assert output in cli_run[which_output_stream] assert expected_output in cli_run[which_output_stream]
@then(parse("the output should not contain\n{output}")) @then(parse("the output should not contain\n{expected_output}"))
@then(parse('the output should not contain "{output}"')) @then(parse('the output should not contain "{expected_output}"'))
@then('the output should not contain "<output>"') @then('the output should not contain "<expected_output>"')
def output_should_not_contain(output, cli_run): def output_should_not_contain(expected_output, cli_run):
assert output not in cli_run["stdout"] assert expected_output not in cli_run["stdout"]
@then(parse("the output should be\n{expected_output}"))
@then(parse('the output should be "{expected_output}"'))
@then('the output should be "<expected_output>"')
def output_should_be(expected_output, cli_run):
actual = cli_run["stdout"].strip()
expected = expected_output.strip()
assert expected == actual
@then(parse("the output should be\n{str_value}"))
@then(parse('the output should be "{str_value}"'))
@then('the output should be "<str_value>"')
@then("the output should be empty") @then("the output should be empty")
def output_should_be(str_value, cli_run): def output_should_be_empty(cli_run):
actual_out = cli_run["stdout"].strip() actual = cli_run["stdout"].strip()
expected = str_value.strip() assert actual == ""
assert expected == actual_out, failed_msg(
"Output does not match.", expected, actual_out
)
@then('the output should contain the date "<date>"') @then('the output should contain the date "<date>"')
def output_should_contain_date(output, cli_run): def output_should_contain_date(date, cli_run):
assert output and output in cli_run["stdout"] assert date and date in cli_run["stdout"]
@then("the output should contain pyproject.toml version") @then("the output should contain pyproject.toml version")
@ -574,12 +608,12 @@ def assert_output_is_valid_language(cli_run, language_name):
@given(parse("we parse the output as {language_name}"), target_fixture="parsed_output") @given(parse("we parse the output as {language_name}"), target_fixture="parsed_output")
def parse_output_as_language(cli_run, language_name): def parse_output_as_language(cli_run, language_name):
language_name = language_name.upper() language_name = language_name.upper()
output = cli_run["stdout"] actual_output = cli_run["stdout"]
if language_name == "XML": if language_name == "XML":
parsed_output = ElementTree.fromstring(output) parsed_output = ElementTree.fromstring(actual_output)
elif language_name == "JSON": elif language_name == "JSON":
parsed_output = json.loads(output) parsed_output = json.loads(actual_output)
else: else:
assert False, f"Language name {language_name} not recognized" assert False, f"Language name {language_name} not recognized"
@ -669,6 +703,6 @@ def assert_output_field_content(
@then(parse('there should be {number:d} "{item}" elements')) @then(parse('there should be {number:d} "{item}" elements'))
def count_elements(number, item, cli_run): def count_elements(number, item, cli_run):
output = cli_run["stdout"] actual_output = cli_run["stdout"]
xml_tree = ElementTree.fromstring(output) xml_tree = ElementTree.fromstring(actual_output)
assert len(xml_tree.findall(".//" + item)) == number assert len(xml_tree.findall(".//" + item)) == number