diff --git a/features/overrides.feature b/features/overrides.feature index 4343cdb1..103a43ac 100644 --- a/features/overrides.feature +++ b/features/overrides.feature @@ -2,13 +2,13 @@ Feature: Implementing Runtime Overrides for Select Configuration Keys Scenario: Override configured editor with built-in input === editor:'' Given we use the config "tiny.yaml" - When we run jrnl with --config-override editor:'' + When we run jrnl with --config-override editor '' Then the stdin prompt must be launched @skip_win Scenario: Override configured linewrap with a value of 23 Given we use the config "tiny.yaml" - When we run "jrnl -2 --config-override linewrap:23 --format fancy" + When we run "jrnl -2 --config-override linewrap 23 --format fancy" Then the output should be """ @@ -30,13 +30,13 @@ Feature: Implementing Runtime Overrides for Select Configuration Keys @skip_win Scenario: Override color selections with runtime overrides Given we use the config "tiny.yaml" - When we run jrnl with -1 --config-override colors.body:blue + When we run jrnl with -1 --config-override colors.body blue Then the runtime config should have colors.body set to blue @skip_win Scenario: Apply multiple config overrides Given we use the config "tiny.yaml" - When we run jrnl with -1 --config-override colors.body:green,editor:"nano" + When we run jrnl with -1 --config-override colors.body green --config-override editor "nano" Then the runtime config should have colors.body set to green And the runtime config should have editor set to nano @@ -44,7 +44,7 @@ Feature: Implementing Runtime Overrides for Select Configuration Keys @skip_win Scenario Outline: Override configured editor Given we use the config "tiny.yaml" - When we run jrnl with --config-override editor:"" + When we run jrnl with --config-override editor "" Then the editor should have been called Examples: Editor Commands | editor | diff --git a/jrnl/args.py b/jrnl/args.py index 046d29a8..b3a04192 100644 --- a/jrnl/args.py +++ b/jrnl/args.py @@ -17,50 +17,46 @@ from .plugins import IMPORT_FORMATS from .plugins import util -def deserialize_config_args(input: str) -> dict: - """Convert a delimited list of configuration key-value pairs into a flat dict +def deserialize_config_args(input: list) -> dict: - Example: - An input of - `colors.title: blue, display_format: json, colors.date: green` - will return - ```json - { - 'colors.title': 'blue', - 'display_format': 'json', - 'colors.date': 'green' - } - ``` - - Args: - input (str): list of configuration keys in dot-notation and their respective values. - - Returns: - dict: A single level dict of the configuration keys in dot-notation and their respective desired values """ - slug_delimiter = "," - key_value_separator = ":" - _kvpairs = _split_at_delimiter( - input, slug_delimiter, " " - ) # Strip away all whitespace in input, not just leading + + Convert a two-element list of configuration key-value pair into a flat dict + + :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 + :rtype: dict + """ + + assert len(input) == 2 runtime_modifications = {} - for _p in _kvpairs: - l, r = _split_at_delimiter(_p, key_value_separator) - r = r.strip() - if r.isdigit(): - r = int(r) - elif r.lower() == "true": - r = True - elif r.lower() == "false": - r = False - runtime_modifications[l] = r + + cfg_key = input[0] + cfg_value = input[1] + cfg_value = cfg_value.strip() + + # Convert numbers and booleans + if cfg_value.isdigit(): + cfg_value = int(cfg_value) + elif cfg_value.lower() == "true": + cfg_value = True + elif cfg_value.lower() == "false": + cfg_value = False + + runtime_modifications[cfg_key] = cfg_value + return runtime_modifications -def _split_at_delimiter( - input: str, slug_delimiter: str, whitespace_to_strip=None -) -> list: - return input.strip(whitespace_to_strip).split(slug_delimiter) +class ConfigurationAction(argparse.Action): + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + def __call__(self, parser, namespace, values, option_strings=None) -> None: + cfg_overrides = getattr(namespace, self.dest, []) + cfg_overrides.append(deserialize_config_args(values)) + setattr(namespace, self.dest, cfg_overrides) class WrappingFormatter(argparse.RawTextHelpFormatter): @@ -361,25 +357,25 @@ def parse_args(args=[]): ) config_overrides = parser.add_argument_group( - "Config file overrides", - textwrap.dedent("These are one-off overrides of the config file options"), + "Config file override", + textwrap.dedent("Apply a one-off override of the config file option"), ) config_overrides.add_argument( "--config-override", dest="config_override", - action="store", - type=deserialize_config_args, - nargs="?", - default=None, + action=ConfigurationAction, + type=str, + nargs=2, + default=[], metavar="CONFIG_KV_PAIR", help=""" - Override configured key-value pairs with CONFIG_KV_PAIR for this command invocation only. + Override configured key-value pair with CONFIG_KV_PAIR for this command invocation only. Examples: \n \t - Use a different editor for this jrnl entry, call: \n \t jrnl --config-override editor: "nano" \n \t - Override color selections\n - \t jrnl --config-override colors.body: blue, colors.title: green + \t jrnl --config-override colors.body blue --config-override colors.title green """, ) diff --git a/jrnl/override.py b/jrnl/override.py index 160bcbcf..82d6da93 100644 --- a/jrnl/override.py +++ b/jrnl/override.py @@ -1,22 +1,25 @@ # import logging -def apply_overrides(overrides: dict, base_config: dict) -> dict: - """Unpack parsed overrides in dot-notation and return the "patched" configuration +def apply_overrides(overrides: list, base_config: dict) -> dict: + """Unpack CLI provided overrides into the configuration tree. - Args: - overrides (dict): Single-level dict of config fields in dot-notation and their desired values - base_config (dict): The "saved" configuration, as read from YAML - - Returns: - dict: Updated configuration with applied overrides, in the format of the loaded configuration + :param overrides: List of configuration key-value pairs collected from the CLI + :type overrides: list + :param base_config: Configuration Loaded from the saved YAML + :type base_config: dict + :return: Configuration to be used during runtime with the overrides applied + :rtype: dict """ config = base_config.copy() - for k in overrides: - nodes = k.split(".") - config = _recursively_apply(config, nodes, overrides[k]) + for pairs in overrides: + + key_as_dots, override_value = list(pairs.items())[0] + keys = key_as_dots.split(".") + config = _recursively_apply(config, keys, override_value) + return config -def _recursively_apply(config: dict, nodes: list, override_value) -> dict: +def _recursively_apply(tree: dict, nodes: list, override_value) -> dict: """Recurse through configuration and apply overrides at the leaf of the config tree Credit to iJames on SO: https://stackoverflow.com/a/47276490 for algorithm @@ -28,12 +31,12 @@ def _recursively_apply(config: dict, nodes: list, override_value) -> dict: """ key = nodes[0] if len(nodes) == 1: - config[key] = override_value + tree[key] = override_value else: next_key = nodes[1:] - _recursively_apply(_get_config_node(config, key), next_key, override_value) + _recursively_apply(_get_config_node(tree, key), next_key, override_value) - return config + return tree def _get_config_node(config: dict, key: str): diff --git a/poetry.lock b/poetry.lock index 3d7f28f6..c30a0abd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -511,7 +511,7 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2020.5" +version = "2021.1" description = "World timezone definitions, modern and historical" category = "main" optional = false @@ -874,20 +874,39 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"}, {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"}, + {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] mkdocs = [ @@ -955,8 +974,8 @@ python-dateutil = [ {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, ] pytz = [ - {file = "pytz-2020.5-py2.py3-none-any.whl", hash = "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4"}, - {file = "pytz-2020.5.tar.gz", hash = "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"}, + {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, + {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, ] pywin32-ctypes = [ {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, diff --git a/tests/test_config.py b/tests/test_config.py index dc2650a8..aa1b01ee 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -43,10 +43,10 @@ def test_override_configured_editor( mock_load_or_install.return_value = minimal_config mock_isatty.return_value = True - cli_args = shlex.split('--config-override editor:"nano"') + cli_args = shlex.split('--config-override editor "nano"') parser = parse_args(cli_args) assert parser.config_override.__len__() == 1 - assert "editor" in parser.config_override.keys() + assert {"editor": "nano"} in parser.config_override def mock_editor_launch(editor): print("%s launched! Success!" % editor) @@ -54,7 +54,7 @@ def test_override_configured_editor( with mock.patch.object( jrnl, "_write_in_editor", - side_effect=mock_editor_launch(parser.config_override["editor"]), + side_effect=mock_editor_launch("TODO: replace"), return_value="note_contents", ) as mock_write_in_editor: run(parser) @@ -84,9 +84,9 @@ def test_override_configured_colors( ): mock_load_or_install.return_value = minimal_config - cli_args = shlex.split("--config-override colors.body:blue") + cli_args = shlex.split("--config-override colors.body blue") parser = parse_args(cli_args) - assert "colors.body" in parser.config_override.keys() + assert {"colors.body": "blue"} in parser.config_override with mock.patch.object( jrnl, "_write_in_editor", diff --git a/tests/test_override.py b/tests/test_override.py index b7bc0a4a..c673f304 100644 --- a/tests/test_override.py +++ b/tests/test_override.py @@ -16,24 +16,24 @@ def minimal_config(): def test_apply_override(minimal_config): config = minimal_config.copy() - overrides = {"editor": "nano"} + overrides = [{"editor": "nano"}] config = apply_overrides(overrides, config) assert config["editor"] == "nano" def test_override_dot_notation(minimal_config): cfg = minimal_config.copy() - overrides = {"colors.body": "blue"} + overrides = [{"colors.body": "blue"}] cfg = apply_overrides(overrides=overrides, base_config=cfg) assert cfg["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 = [ + {"colors.title": "magenta"}, + {"editor": "nano"}, + {"journals.burner": "/tmp/journals/burner.jrnl"}, + ] # as returned by parse_args, saved in parser.config_override cfg = apply_overrides(overrides, minimal_config.copy()) assert cfg["editor"] == "nano" diff --git a/tests/test_parse_args.py b/tests/test_parse_args.py index 5c469e47..feb31d07 100644 --- a/tests/test_parse_args.py +++ b/tests/test_parse_args.py @@ -36,7 +36,7 @@ def expected_args(**kwargs): "strict": False, "tags": False, "text": [], - "config_override": None, + "config_override": [], } return {**default_args, **kwargs} @@ -209,26 +209,26 @@ def test_version_alone(): def test_editor_override(): - parsed_args = cli_as_dict('--config-override editor:"nano"') - assert parsed_args == expected_args(config_override={"editor": "nano"}) + parsed_args = cli_as_dict('--config-override editor "nano"') + assert parsed_args == expected_args(config_override=[{"editor": "nano"}]) def test_color_override(): - assert cli_as_dict("--config-override colors.body:blue") == expected_args( - config_override={"colors.body": "blue"} + assert cli_as_dict("--config-override colors.body blue") == expected_args( + config_override=[{"colors.body": "blue"}] ) def test_multiple_overrides(): parsed_args = cli_as_dict( - '--config-override colors.title:green,editor:"nano",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={ - "colors.title": "green", - "journal.scratchpad": "/tmp/scratchpad", - "editor": "nano", - } + config_override=[ + {"colors.title": "green"}, + {"editor": "nano"}, + {"journal.scratchpad": "/tmp/scratchpad"}, + ] ) @@ -266,49 +266,27 @@ class TestDeserialization: @pytest.mark.parametrize( "input_str", [ - 'editor:"nano", colors.title:blue, default:"/tmp/egg.txt"', - 'editor:"vi -c startinsert", colors.title:blue, default:"/tmp/egg.txt"', - 'editor:"nano", colors.title:blue, default:"/tmp/eg\ g.txt"', + ["editor", '"nano"'], + ["colors.title", "blue"], + ["default", "/tmp/egg.txt"], ], ) def test_deserialize_multiword_strings(self, input_str): runtime_config = deserialize_config_args(input_str) assert runtime_config.__class__ == dict - assert "editor" in runtime_config.keys() - assert "colors.title" in runtime_config.keys() - assert "default" in runtime_config.keys() - - def test_deserialize_int(self): - input = "linewrap: 23, default_hour: 19" - runtime_config = deserialize_config_args(input) - assert runtime_config["linewrap"] == 23 - assert runtime_config["default_hour"] == 19 + assert input_str[0] in runtime_config.keys() + assert runtime_config[input_str[0]] == input_str[1] def test_deserialize_multiple_datatypes(self): - input = ( - 'linewrap: 23, encrypt: false, editor:"vi -c startinsert", highlight: true' - ) - cfg = deserialize_config_args(input) - assert cfg["encrypt"] == False - assert cfg["highlight"] == True + cfg = deserialize_config_args(["linewrap", "23"]) assert cfg["linewrap"] == 23 + + cfg = deserialize_config_args(["encrypt", "false"]) + assert cfg["encrypt"] == False + + cfg = deserialize_config_args(["editor", '"vi -c startinsert"']) assert cfg["editor"] == '"vi -c startinsert"' - @pytest.mark.parametrize( - "delimiter", - [ - ".", - ":", - ", ", # note the whitespaces - "-|-", # no reason not to handle multi-character delimiters - ], - ) - def test_split_at_delimiter(self, delimiter): - input = delimiter.join( - ["eggs ", "ba con", "ham"] - ) # The whitespaces are deliberate - from jrnl.args import _split_at_delimiter - - assert _split_at_delimiter(input, delimiter) == ["eggs ", "ba con", "ham"] - assert _split_at_delimiter(input, delimiter, " ") == ["eggs ", "ba con", "ham"] + cfg = deserialize_config_args(["highlight", "true"]) + assert cfg["highlight"] == True