From 9cd2250a61e3446a77b0344fc13e90f0d2969e40 Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 2 Oct 2021 16:52:47 -0700 Subject: [PATCH] Add config overrides steps to pytest This requires some patching around the config object, which now happens in every test. Co-authored-by: Micah Jerome Ellison --- jrnl/jrnl.py | 4 +- jrnl/override.py | 7 +++- tests/bdd/features/override.feature | 19 ++++----- tests/lib/fixtures.py | 7 +++- tests/lib/helpers.py | 11 +++++ tests/lib/then_steps.py | 52 +++++++++++++++++++---- tests/lib/when_steps.py | 13 ++++++ tests/unit/test_override.py | 64 +++++++++++++++++++++-------- 8 files changed, 138 insertions(+), 39 deletions(-) diff --git a/jrnl/jrnl.py b/jrnl/jrnl.py index 2d06115d..bc7e0b88 100644 --- a/jrnl/jrnl.py +++ b/jrnl/jrnl.py @@ -40,9 +40,7 @@ def run(args): original_config = config.copy() # Apply config overrides - overrides = args.config_override - if overrides: - config = apply_overrides(overrides, config) + config = apply_overrides(args, config) args = get_journal_name(args, config) config = scope_config(config, args.journal_name) diff --git a/jrnl/override.py b/jrnl/override.py index 7fd718f0..760b003e 100644 --- a/jrnl/override.py +++ b/jrnl/override.py @@ -1,7 +1,8 @@ from .config import update_config, make_yaml_valid_dict +from argparse import Namespace # import logging -def apply_overrides(overrides: list, base_config: dict) -> dict: +def apply_overrides(args: Namespace, base_config: dict) -> dict: """Unpack CLI provided overrides into the configuration tree. :param overrides: List of configuration key-value pairs collected from the CLI @@ -11,6 +12,10 @@ def apply_overrides(overrides: list, base_config: dict) -> dict: :return: Configuration to be used during runtime with the overrides applied :rtype: dict """ + overrides = vars(args).get("config_override", None) + if not overrides: + return base_config + cfg_with_overrides = base_config.copy() for pairs in overrides: diff --git a/tests/bdd/features/override.feature b/tests/bdd/features/override.feature index 3d9fb27f..b29b11f0 100644 --- a/tests/bdd/features/override.feature +++ b/tests/bdd/features/override.feature @@ -8,13 +8,11 @@ Feature: Implementing Runtime Overrides for Select Configuration Keys And the editor should not have been called - # @todo implement this step in pytest (doesn't currently support overrides) - @skip Scenario: Postconfig commands with overrides Given we use the config "basic_encrypted.yaml" And we use the password "test" if prompted When we run "jrnl --decrypt --config-override highlight false --config-override editor nano" - Then the config should contain "highlight: false" + Then the config in memory should contain "highlight: false" Then the editor should not have been called @@ -38,23 +36,24 @@ Feature: Implementing Runtime Overrides for Select Configuration Keys ┖─────────────────────┘ - # @todo implement this step in pytest (doesn't currently support overrides) - @skip Scenario: Override color selections with runtime overrides Given we use the config "basic_encrypted.yaml" And we use the password "test" if prompted When we run "jrnl -1 --config-override colors.body blue" - Then the config should have "colors.body" set to "blue" + Then the config in memory should contain "colors.body: blue" - # @todo implement this step in pytest (doesn't currently support overrides) - @skip Scenario: Apply multiple config overrides Given we use the config "basic_encrypted.yaml" And we use the password "test" if prompted When we run "jrnl -1 --config-override colors.body green --config-override editor 'nano'" - Then the config should have "colors.body" set to "green" - And the config should have "editor" set to "nano" + Then the config in memory should contain + editor: nano + colors: + title: none + body: green + tags: none + date: none Scenario: Override default journal diff --git a/tests/lib/fixtures.py b/tests/lib/fixtures.py index a93a7e43..1b4c74dc 100644 --- a/tests/lib/fixtures.py +++ b/tests/lib/fixtures.py @@ -143,10 +143,15 @@ def user_input(): @fixture -def config_data(config_path): +def config_on_disk(config_path): return load_config(config_path) +@fixture +def config_in_memory(): + return dict() + + @fixture def journal_name(): return None diff --git a/tests/lib/helpers.py b/tests/lib/helpers.py index 7d089597..2e1f454a 100644 --- a/tests/lib/helpers.py +++ b/tests/lib/helpers.py @@ -1,6 +1,7 @@ # Copyright (C) 2012-2021 jrnl contributors # License: https://www.gnu.org/licenses/gpl-3.0.html +import functools import os @@ -38,3 +39,13 @@ def assert_equal_tags_ignoring_order( [actual_tags, expected_tags], [expected_content, actual_content], ] + + +# @see: https://stackoverflow.com/a/65782539/569146 +def get_nested_val(dictionary, path, *default): + try: + return functools.reduce(lambda x, y: x[y], path.split("."), dictionary) + except KeyError: + if default: + return default[0] + raise diff --git a/tests/lib/then_steps.py b/tests/lib/then_steps.py index 15130867..627cb323 100644 --- a/tests/lib/then_steps.py +++ b/tests/lib/then_steps.py @@ -15,6 +15,7 @@ from jrnl.config import scope_config from .helpers import assert_equal_tags_ignoring_order from .helpers import does_directory_contain_files from .helpers import parse_should_or_should_not +from .helpers import get_nested_val @then("we should get no error") @@ -110,10 +111,10 @@ def should_see_the_message(text, cli_run): ) @then(parse('the config {should_or_should_not} contain "{some_yaml}"')) @then(parse("the config {should_or_should_not} contain\n{some_yaml}")) -def config_var(config_data, journal_name, should_or_should_not, some_yaml): +def config_var_on_disk(config_on_disk, journal_name, should_or_should_not, some_yaml): we_should = parse_should_or_should_not(should_or_should_not) - actual = config_data + actual = config_on_disk if journal_name: actual = actual["journals"][journal_name] @@ -121,6 +122,7 @@ def config_var(config_data, journal_name, should_or_should_not, some_yaml): actual_slice = actual if type(actual) is dict: + # `expected` objects formatted in yaml only compare one level deep actual_slice = {key: actual.get(key, None) for key in expected.keys()} if we_should: @@ -129,6 +131,40 @@ def config_var(config_data, journal_name, should_or_should_not, some_yaml): assert expected != actual_slice +@then( + parse( + 'the config in memory for journal "{journal_name}" {should_or_should_not} contain "{some_yaml}"' + ) +) +@then( + parse( + 'the config in memory for journal "{journal_name}" {should_or_should_not} contain\n{some_yaml}' + ) +) +@then(parse('the config in memory {should_or_should_not} contain "{some_yaml}"')) +@then(parse("the config in memory {should_or_should_not} contain\n{some_yaml}")) +def config_var_in_memory( + config_in_memory, journal_name, should_or_should_not, some_yaml +): + we_should = parse_should_or_should_not(should_or_should_not) + + actual = config_in_memory["overrides"] + if journal_name: + actual = actual["journals"][journal_name] + + expected = yaml.load(some_yaml, Loader=yaml.SafeLoader) + + actual_slice = actual + if type(actual) is dict: + # `expected` objects formatted in yaml only compare one level deep + actual_slice = {key: get_nested_val(actual, key) for key in expected.keys()} + + if we_should: + assert expected == actual_slice + else: + assert expected != actual_slice + + @then("we should be prompted for a password") def password_was_called(cli_run): assert cli_run["mocks"]["getpass"].called @@ -145,15 +181,15 @@ def assert_dir_contains_files(file_list, cache_dir): @then(parse("the journal directory should contain\n{file_list}")) -def journal_directory_should_contain(config_data, file_list): - scoped_config = scope_config(config_data, "default") +def journal_directory_should_contain(config_on_disk, file_list): + scoped_config = scope_config(config_on_disk, "default") assert does_directory_contain_files(file_list, scoped_config["journal"]) @then(parse('journal "{journal_name}" should not exist')) -def journal_directory_should_not_exist(config_data, journal_name): - scoped_config = scope_config(config_data, journal_name) +def journal_directory_should_not_exist(config_on_disk, journal_name): + scoped_config = scope_config(config_on_disk, journal_name) assert not does_directory_contain_files( scoped_config["journal"], "." @@ -161,8 +197,8 @@ def journal_directory_should_not_exist(config_data, journal_name): @then(parse("the journal {should_or_should_not} exist")) -def journal_should_not_exist(config_data, should_or_should_not): - scoped_config = scope_config(config_data, "default") +def journal_should_not_exist(config_on_disk, should_or_should_not): + scoped_config = scope_config(config_on_disk, "default") expected_path = scoped_config["journal"] contains_files = does_directory_contain_files(expected_path, ".") diff --git a/tests/lib/when_steps.py b/tests/lib/when_steps.py index 642249e9..80d8a7fb 100644 --- a/tests/lib/when_steps.py +++ b/tests/lib/when_steps.py @@ -34,6 +34,7 @@ def when_we_change_directory(directory_name): def we_run( command, config_path, + config_in_memory, user_input, cli_run, capsys, @@ -63,7 +64,19 @@ def we_run( password = user_input with ExitStack() as stack: + # Always mock + from jrnl.override import apply_overrides + def my_overrides(*args, **kwargs): + result = apply_overrides(*args, **kwargs) + config_in_memory["overrides"] = result + return result + + stack.enter_context( + patch("jrnl.jrnl.apply_overrides", side_effect=my_overrides) + ) + + # Conditionally mock stack.enter_context(patch("sys.argv", ["jrnl"] + args)) mock_stdin = stack.enter_context( diff --git a/tests/unit/test_override.py b/tests/unit/test_override.py index d22709f4..2719e884 100644 --- a/tests/unit/test_override.py +++ b/tests/unit/test_override.py @@ -6,6 +6,8 @@ from jrnl.override import _get_key_and_value_from_pair from jrnl.override import _recursively_apply from jrnl.override import apply_overrides +from argparse import Namespace + @pytest.fixture() def minimal_config(): @@ -18,31 +20,61 @@ def minimal_config(): return cfg +def expected_args(overrides): + default_args = { + "contains": None, + "debug": False, + "delete": False, + "edit": False, + "end_date": None, + "today_in_history": False, + "month": None, + "day": None, + "year": None, + "excluded": [], + "export": False, + "filename": None, + "limit": None, + "on_date": None, + "preconfig_cmd": None, + "postconfig_cmd": None, + "short": False, + "starred": False, + "start_date": None, + "strict": False, + "tags": False, + "text": [], + "config_override": [], + } + return Namespace(**{**default_args, **overrides}) + + def test_apply_override(minimal_config): - overrides = [["editor", "nano"]] - apply_overrides(overrides, minimal_config) + overrides = {"config_override": [["editor", "nano"]]} + apply_overrides(expected_args(overrides), minimal_config) assert minimal_config["editor"] == "nano" def test_override_dot_notation(minimal_config): - overrides = [["colors.body", "blue"]] - - cfg = apply_overrides(overrides=overrides, base_config=minimal_config) - assert cfg["colors"] == {"body": "blue", "date": "green"} + overrides = {"config_override": [["colors.body", "blue"]]} + apply_overrides(expected_args(overrides), minimal_config) + assert minimal_config["colors"] == {"body": "blue", "date": "green"} def test_multiple_overrides(minimal_config): - overrides = [ - ["colors.title", "magenta"], - ["editor", "nano"], - ["journals.burner", "/tmp/journals/burner.jrnl"], - ] # as returned by parse_args, saved in parser.config_override + overrides = { + "config_override": [ + ["colors.title", "magenta"], + ["editor", "nano"], + ["journals.burner", "/tmp/journals/burner.jrnl"], + ] + } - cfg = apply_overrides(overrides, minimal_config) - assert cfg["editor"] == "nano" - assert cfg["colors"]["title"] == "magenta" - assert "burner" in cfg["journals"] - assert cfg["journals"]["burner"] == "/tmp/journals/burner.jrnl" + actual = apply_overrides(expected_args(overrides), minimal_config) + assert actual["editor"] == "nano" + assert actual["colors"]["title"] == "magenta" + assert "burner" in actual["journals"] + assert actual["journals"]["burner"] == "/tmp/journals/burner.jrnl" def test_recursively_apply():