jrnl/tests/lib/given_steps.py
Carl Smedstad 1530ad39a1
Update pytest-bdd to v8.0 (#1955)
* Encapsulate all multiline strings in triple-quotes in Gherkin files

Since pytest-bdd v8.0.0 uses the official Gherkin parser, multiline
strings must now be encapsulated by triple-quotes.

See:
- https://pytest-bdd.readthedocs.io/en/stable/#id2
- https://pytest-bdd.readthedocs.io/en/stable/#docstrings

* Remove comments in Gherkin files causing test breakage

These comments break the step matching.

* Fix compatibility of step-functions matching on multiple lines

In pytest-bdd v8.0.0 it is no longer possible to match based on multiple
lines, which breaks essentially all steps that support docstrings. Solve
this by adding a wrapper-function for each of these instances, that
matches the docstring step, and calls the original function.

So, what used to be:

    @then(parse("the output should match\n{regex}"))
    @then(parse('the output should match "{regex}"'))
    def output_should_match(regex, cli_run):
        ...

Is now:

    @then(parse("the output should match"))
    def output_should_match_docstring(cli_run, docstring):
        output_should_match(docstring, cli_run)

    @then(parse('the output should match "{regex}"'))
    def output_should_match(regex, cli_run):
        ...

There is possibly a way around this that is much better than what I've
done here, but this is a start at least.

* Update version requirement of pytest-bdd to >=8.0

* Update tox config to match poetry config

---------

Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
2025-02-24 20:16:54 -08:00

189 lines
6.6 KiB
Python

# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html
import json
import os
import random
import shutil
import string
from datetime import datetime
from unittest.mock import MagicMock
from unittest.mock import patch
from xml.etree import ElementTree as ET
from pytest_bdd import given
from pytest_bdd.parsers import parse
from pytest_bdd.parsers import re
from jrnl import __version__
from jrnl.time import __get_pdt_calendar
from tests.lib.fixtures import FailedKeyring
from tests.lib.fixtures import NoKeyring
from tests.lib.fixtures import TestKeyring
@given(re(r"we (?P<editor_method>\w+) to the editor if opened"))
def we_enter_editor_docstring(editor_method, editor_state, docstring):
we_enter_editor(editor_method, docstring, editor_state)
@given(parse("we {editor_method} nothing to the editor if opened"))
def we_enter_editor(editor_method, editor_input, editor_state):
file_method = editor_state["intent"]["method"]
if editor_method == "write":
file_method = "w+"
elif editor_method == "append":
file_method = "a+"
else:
assert False, f"Method '{editor_method}' not supported"
editor_state["intent"] = {"method": file_method, "input": editor_input}
@given(parse('now is "{date_str}"'))
def now_is_str(date_str, mock_factories):
class DatetimeMagicMock(MagicMock):
# needed because jrnl does some reflection on datetime
def __instancecheck__(self, subclass):
return isinstance(subclass, datetime)
def mocked_now(tz=None):
now = datetime.strptime(date_str, "%Y-%m-%d %I:%M:%S %p")
if tz:
time_zone = datetime.utcnow().astimezone().tzinfo
now = now.replace(tzinfo=time_zone)
return now
# jrnl uses two different classes to parse dates, so both must be mocked
datetime_mock = DatetimeMagicMock(wraps=datetime)
datetime_mock.now.side_effect = mocked_now
pdt = __get_pdt_calendar()
calendar_mock = MagicMock(wraps=pdt)
calendar_mock.parse.side_effect = lambda date_str_input: pdt.parse(
date_str_input, mocked_now()
)
mock_factories["datetime"] = lambda: patch("datetime.datetime", new=datetime_mock)
mock_factories["calendar_parse"] = lambda: patch(
"jrnl.time.__get_pdt_calendar", return_value=calendar_mock
)
@given("we don't have a keyring", target_fixture="keyring")
def we_dont_have_keyring(keyring_type):
return NoKeyring()
@given("we have a keyring", target_fixture="keyring")
@given(parse("we have a {keyring_type} keyring"), target_fixture="keyring")
def we_have_type_of_keyring(keyring_type):
match keyring_type:
case "failed":
return FailedKeyring()
case _:
return TestKeyring()
@given(parse("we use no config"), target_fixture="config_path")
def we_use_no_config(temp_dir):
os.chdir(temp_dir.name) # @todo move this step to a more universal place
return os.path.join(temp_dir.name, "non_existing_config.yaml")
@given(parse('we use the config "{config_file}"'), target_fixture="config_path")
def we_use_the_config(request, temp_dir, working_dir, config_file):
# Move into temp dir as cwd
os.chdir(temp_dir.name) # @todo move this step to a more universal place
# Copy the config file over
config_source = os.path.join(working_dir, "data", "configs", config_file)
config_dest = os.path.join(temp_dir.name, config_file)
shutil.copy2(config_source, config_dest)
# @todo make this only copy some journals over
# Copy all of the journals over
journal_source = os.path.join(working_dir, "data", "journals")
journal_dest = os.path.join(temp_dir.name, "features", "journals")
shutil.copytree(journal_source, journal_dest)
# @todo maybe only copy needed templates over?
# Copy all of the templates over
template_source = os.path.join(working_dir, "data", "templates")
template_dest = os.path.join(temp_dir.name, "features", "templates")
shutil.copytree(template_source, template_dest)
# @todo get rid of this by using default config values
# merge in version number
if (
config_file.endswith("yaml")
and os.path.exists(config_dest)
and os.path.getsize(config_dest) > 0
):
# Add jrnl version to file for 2.x journals
with open(config_dest, "a") as cf:
cf.write("version: {}".format(__version__))
return config_dest
@given(
parse('we copy the template "{template_file}" to the default templates folder'),
target_fixture="default_templates_path",
)
def we_copy_the_template(request, temp_dir, working_dir, template_file):
# Move into temp dir as cwd
os.chdir(temp_dir.name) # @todo move this step to a more universal place
# Copy template over
template_source = os.path.join(working_dir, "data", "templates", template_file)
template_dest = os.path.join(temp_dir.name, "templates", template_file)
os.makedirs(os.path.dirname(template_dest), exist_ok=True)
shutil.copy2(template_source, template_dest)
return template_dest
@given(parse('the config "{config_file}" exists'), target_fixture="config_path")
def config_exists(config_file, temp_dir, working_dir):
config_source = os.path.join(working_dir, "data", "configs", config_file)
config_dest = os.path.join(temp_dir.name, config_file)
shutil.copy2(config_source, config_dest)
@given(parse('we use the password "{password}" if prompted'), target_fixture="password")
def use_password_forever(password):
return password
@given("we create a cache directory", target_fixture="cache_dir")
def create_cache_dir(temp_dir):
random_str = "".join(random.choices(string.ascii_uppercase + string.digits, k=20))
dir_path = os.path.join(temp_dir.name, "cache_" + random_str)
os.mkdir(dir_path)
return {"exists": True, "path": dir_path}
@given(parse("we parse the output as {language_name}"), target_fixture="parsed_output")
def parse_output_as_language(cli_run, language_name):
language_name = language_name.upper()
actual_output = cli_run["stdout"]
if language_name == "XML":
parsed_output = ET.fromstring(actual_output)
elif language_name == "JSON":
parsed_output = json.loads(actual_output)
else:
assert False, f"Language name {language_name} not recognized"
return {"lang": language_name, "obj": parsed_output}
@given(parse('the home directory is called "{home_dir}"'))
def home_directory(temp_dir, home_dir, monkeypatch):
home_path = os.path.join(temp_dir.name, home_dir)
monkeypatch.setenv("USERPROFILE", home_path) # for windows
monkeypatch.setenv("HOME", home_path) # for *nix