From fae2253cc4b23b95011fc87271bf3f181eea45cc Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 7 Jun 2023 14:41:48 -0400 Subject: [PATCH 1/7] Test addition of magic args to config --- snakebids/tests/test_app.py | 65 ++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/snakebids/tests/test_app.py b/snakebids/tests/test_app.py index a9fdea08..3bd02be0 100644 --- a/snakebids/tests/test_app.py +++ b/snakebids/tests/test_app.py @@ -1,8 +1,9 @@ -from __future__ import absolute_import +from __future__ import absolute_import, annotations import copy import json from pathlib import Path +from typing import Iterable, cast import hypothesis.strategies as st import pytest @@ -10,7 +11,11 @@ from hypothesis import HealthCheck, assume, example, given, settings from pytest_mock.plugin import MockerFixture +from snakebids.app import update_config from snakebids.cli import SnakebidsArgs +from snakebids.tests import strategies as sb_st +from snakebids.types import InputsConfig +from snakebids.utils.utils import BidsEntity from .. import app as sn_app from ..app import SnakeBidsApp @@ -33,6 +38,64 @@ def app(mocker: MockerFixture): return app +class TestUpdateConfig: + @given( + st.one_of( + st.none(), + st.tuples( + sb_st.bids_entity(), + st.one_of( + sb_st.bids_value(), + st.booleans(), + st.lists(sb_st.bids_value(), min_size=1), + ), + ), + ), + st.one_of(st.none(), st.lists(sb_st.bids_entity())), + st.one_of( + st.none(), + st.text( + alphabet=st.characters( + blacklist_categories=("Cs",), blacklist_characters=("\x00",) + ) + ), + ), + ) + def test_magic_args( + self, + filters: tuple[BidsEntity, Iterable[str]] | None, + wildcards: Iterable[BidsEntity] | None, + path: str | None, + ): + config_copy = copy.deepcopy(config) + config_copy["bids_dir"] = "root" # type: ignore + config_copy["output_dir"] = "app" # type: ignore + args = SnakebidsArgs( + force=False, + outputdir=Path("app"), + snakemake_args=[], + args_dict={ + "filter_bold": {filters[0].entity: filters[1]} if filters else None, + "wildcards_bold": [wildcard.entity for wildcard in wildcards] + if wildcards + else None, + "path_bold": path, + }, + ) + update_config(config_copy, args) + inputs_config: InputsConfig = cast(InputsConfig, config_copy["pybids_inputs"]) + if filters: + assert ( + inputs_config["bold"].get("filters", {}).get(filters[0].entity) + == filters[1] + ) + if wildcards: + for entity in [wildcard.entity for wildcard in wildcards]: + assert entity in inputs_config["bold"].get("wildcards", []) + if path: + assert inputs_config["bold"].get("custom_path", "") == Path(path).resolve() + + class TestRunSnakemake: valid_chars = st.characters( min_codepoint=48, max_codepoint=122, whitelist_categories=["Ll", "Lu"] From 465b377b4d8ce3e12b35c960c1206d1b3253c6cb Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Wed, 7 Jun 2023 16:36:10 -0400 Subject: [PATCH 2/7] Add test for generating and parsing magic args --- snakebids/cli.py | 1 - snakebids/tests/strategies.py | 38 ++++++++++++++++++++++++++++++++++- snakebids/tests/test_cli.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/snakebids/cli.py b/snakebids/cli.py index 3584f968..64666050 100644 --- a/snakebids/cli.py +++ b/snakebids/cli.py @@ -343,7 +343,6 @@ def _resolve_path(path_candidate: Any) -> Any: If os.Pathlike or list of os.Pathlike, the same paths resolved. Otherwise, the argument unchanged. """ - if isinstance(path_candidate, Sequence) and not isinstance(path_candidate, str): return [ _resolve_path(p) # type: ignore[reportUnknownArgumentType] diff --git a/snakebids/tests/strategies.py b/snakebids/tests/strategies.py index 96bae2c4..34a6245f 100644 --- a/snakebids/tests/strategies.py +++ b/snakebids/tests/strategies.py @@ -13,7 +13,7 @@ from snakebids.core.datasets import BidsComponent from snakebids.core.input_generation import BidsDataset from snakebids.tests import helpers -from snakebids.types import ZipList +from snakebids.types import InputConfig, InputsConfig, ZipList from snakebids.utils.utils import BidsEntity, MultiSelectDict _Ex_co = TypeVar("_Ex_co", bound=str, covariant=True) @@ -51,6 +51,42 @@ def bids_entity_lists( ).filter(lambda v: v != ["datatype"]) +@st.composite +def input_config(draw: st.DrawFn) -> InputConfig: + filtered_entities = draw(st.one_of(st.lists(bids_entity()), st.none())) + filters = ( + { + entity.entity: draw( + st.one_of(st.booleans(), bids_value(), st.lists(bids_value())) + ) + for entity in filtered_entities + } + if filtered_entities is not None + else None + ) + wildcard_entities = draw(st.one_of(st.lists(bids_entity()), st.none())) + + wildcards = ( + [entity.entity for entity in wildcard_entities] + if wildcard_entities is not None + else None + ) + custom_path = draw(st.one_of(st.text(), st.none())) + + pybids_inputs: InputConfig = {} + if wildcards is not None: + pybids_inputs.update({"wildcards": wildcards}) + if filters is not None: + pybids_inputs.update({"filters": filters}) + if custom_path is not None: + pybids_inputs.update({"custom_path": custom_path}) + return pybids_inputs + + +def inputs_config() -> st.SearchStrategy[InputsConfig]: + return st.dictionaries(st.text(min_size=1), input_config()) + + @st.composite def zip_lists( # noqa: PLR0913 draw: st.DrawFn, diff --git a/snakebids/tests/test_cli.py b/snakebids/tests/test_cli.py index d12b54f2..7e6aade5 100644 --- a/snakebids/tests/test_cli.py +++ b/snakebids/tests/test_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import itertools import sys from argparse import ArgumentParser, Namespace from collections.abc import Sequence @@ -9,6 +10,7 @@ from typing import Mapping import pytest +from hypothesis import HealthCheck, given, settings from pytest_mock.plugin import MockerFixture from snakebids.cli import ( @@ -17,6 +19,8 @@ create_parser, parse_snakebids_args, ) +from snakebids.tests import strategies as sb_st +from snakebids.types import InputsConfig from .mock.config import parse_args, pybids_inputs @@ -73,6 +77,38 @@ class TestAddDynamicArgs: ] mock_all_args = mock_basic_args + mock_args_special + @given(sb_st.inputs_config()) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_dynamic_inputs(self, mocker: MockerFixture, pybids_inputs: InputsConfig): + p = create_parser() + add_dynamic_args(p, copy.deepcopy(parse_args), pybids_inputs) + magic_filters = list( + itertools.chain.from_iterable( + [[f"--filter-{key}", "entity=value"] for key in pybids_inputs] + ) + ) + magic_wildcards = list( + itertools.chain.from_iterable( + [[f"--wildcards-{key}", "test"] for key in pybids_inputs] + ) + ) + magic_path = list( + itertools.chain.from_iterable( + [[f"--path-{key}", "test"] for key in pybids_inputs] + ) + ) + mocker.patch.object( + sys, + "argv", + self.mock_all_args + magic_filters + magic_wildcards + magic_path, + ) + args = parse_snakebids_args(p) + for key in pybids_inputs: + key_identifier = key.replace("-", "_") # argparse does this + assert isinstance(args.args_dict[f"path_{key_identifier}"], str) + assert isinstance(args.args_dict.get(f"filter_{key_identifier}"), dict) + assert isinstance(args.args_dict.get(f"wildcards_{key_identifier}"), list) + def test_fails_if_missing_arguments( self, parser: ArgumentParser, mocker: MockerFixture ): From 1c5a11d1d16b1630020a2095004d1a0855f23e44 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Mon, 12 Jun 2023 11:56:20 -0400 Subject: [PATCH 3/7] Rename input config strategies --- snakebids/tests/strategies.py | 6 +++--- snakebids/tests/test_cli.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/snakebids/tests/strategies.py b/snakebids/tests/strategies.py index 34a6245f..25b961a0 100644 --- a/snakebids/tests/strategies.py +++ b/snakebids/tests/strategies.py @@ -52,7 +52,7 @@ def bids_entity_lists( @st.composite -def input_config(draw: st.DrawFn) -> InputConfig: +def input_configs(draw: st.DrawFn) -> InputConfig: filtered_entities = draw(st.one_of(st.lists(bids_entity()), st.none())) filters = ( { @@ -83,8 +83,8 @@ def input_config(draw: st.DrawFn) -> InputConfig: return pybids_inputs -def inputs_config() -> st.SearchStrategy[InputsConfig]: - return st.dictionaries(st.text(min_size=1), input_config()) +def inputs_configs() -> st.SearchStrategy[InputsConfig]: + return st.dictionaries(st.text(min_size=1), input_configs()) @st.composite diff --git a/snakebids/tests/test_cli.py b/snakebids/tests/test_cli.py index 7e6aade5..9fefebdd 100644 --- a/snakebids/tests/test_cli.py +++ b/snakebids/tests/test_cli.py @@ -77,7 +77,7 @@ class TestAddDynamicArgs: ] mock_all_args = mock_basic_args + mock_args_special - @given(sb_st.inputs_config()) + @given(sb_st.inputs_configs()) @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) def test_dynamic_inputs(self, mocker: MockerFixture, pybids_inputs: InputsConfig): p = create_parser() From f7ac39a8ad8537dbcb2d57c8487323efaa22a5d7 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Mon, 12 Jun 2023 15:52:17 -0400 Subject: [PATCH 4/7] Make input_configs strategy more idiomatic --- snakebids/tests/strategies.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/snakebids/tests/strategies.py b/snakebids/tests/strategies.py index 25b961a0..d4c29463 100644 --- a/snakebids/tests/strategies.py +++ b/snakebids/tests/strategies.py @@ -53,24 +53,17 @@ def bids_entity_lists( @st.composite def input_configs(draw: st.DrawFn) -> InputConfig: - filtered_entities = draw(st.one_of(st.lists(bids_entity()), st.none())) - filters = ( - { - entity.entity: draw( - st.one_of(st.booleans(), bids_value(), st.lists(bids_value())) - ) - for entity in filtered_entities - } - if filtered_entities is not None - else None + filters = draw( + st.one_of( + st.dictionaries( + bids_entity().map(str), + st.one_of(st.booleans(), bids_value(), st.lists(bids_value())), + ), + st.none(), + ) ) - wildcard_entities = draw(st.one_of(st.lists(bids_entity()), st.none())) - wildcards = ( - [entity.entity for entity in wildcard_entities] - if wildcard_entities is not None - else None - ) + wildcards = draw(st.one_of(st.lists(bids_entity().map(str)), st.none())) custom_path = draw(st.one_of(st.text(), st.none())) pybids_inputs: InputConfig = {} From 11b4265af656e7849392d5cb8a7166b2496561f2 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Mon, 12 Jun 2023 16:18:08 -0400 Subject: [PATCH 5/7] Use input_configs in test_magic_args --- snakebids/tests/test_app.py | 62 ++++++++++++------------------------- 1 file changed, 20 insertions(+), 42 deletions(-) diff --git a/snakebids/tests/test_app.py b/snakebids/tests/test_app.py index 3bd02be0..54e50fe1 100644 --- a/snakebids/tests/test_app.py +++ b/snakebids/tests/test_app.py @@ -3,7 +3,7 @@ import copy import json from pathlib import Path -from typing import Iterable, cast +from typing import Any, cast import hypothesis.strategies as st import pytest @@ -14,8 +14,7 @@ from snakebids.app import update_config from snakebids.cli import SnakebidsArgs from snakebids.tests import strategies as sb_st -from snakebids.types import InputsConfig -from snakebids.utils.utils import BidsEntity +from snakebids.types import InputConfig, InputsConfig from .. import app as sn_app from ..app import SnakeBidsApp @@ -40,60 +39,39 @@ def app(mocker: MockerFixture): class TestUpdateConfig: @given( - st.one_of( - st.none(), - st.tuples( - sb_st.bids_entity(), - st.one_of( - sb_st.bids_value(), - st.booleans(), - st.lists(sb_st.bids_value(), min_size=1), - ), - ), - ), - st.one_of(st.none(), st.lists(sb_st.bids_entity())), - st.one_of( - st.none(), - st.text( - alphabet=st.characters( - blacklist_categories=("Cs",), blacklist_characters=("\x00",) - ) - ), - ), + input_config=sb_st.input_configs(), ) def test_magic_args( self, - filters: tuple[BidsEntity, Iterable[str]] | None, - wildcards: Iterable[BidsEntity] | None, - path: str | None, + input_config: InputConfig, ): - config_copy = copy.deepcopy(config) - config_copy["bids_dir"] = "root" # type: ignore - config_copy["output_dir"] = "app" # type: ignore + config_copy: dict[str, Any] = copy.deepcopy(config) + config_copy["bids_dir"] = "root" + config_copy["output_dir"] = "app" args = SnakebidsArgs( force=False, outputdir=Path("app"), snakemake_args=[], args_dict={ - "filter_bold": {filters[0].entity: filters[1]} if filters else None, - "wildcards_bold": [wildcard.entity for wildcard in wildcards] - if wildcards - else None, - "path_bold": path, + "filter_bold": input_config.get("filters"), + "wildcards_bold": input_config.get("wildcards"), + "path_bold": input_config.get("custom_path"), }, ) update_config(config_copy, args) inputs_config: InputsConfig = cast(InputsConfig, config_copy["pybids_inputs"]) - if filters: + if "filters" in input_config: + for key, value in input_config["filters"].items(): + assert inputs_config["bold"].get("filters", {}).get(key) == value + if "wildcards" in input_config: + assert set(input_config["wildcards"]) <= set( + inputs_config["bold"].get("wildcards", []) + ) + if "custom_path" in input_config: assert ( - inputs_config["bold"].get("filters", {}).get(filters[0].entity) - == filters[1] + inputs_config["bold"].get("custom_path", "") + == Path(input_config["custom_path"]).resolve() ) - if wildcards: - for entity in [wildcard.entity for wildcard in wildcards]: - assert entity in inputs_config["bold"].get("wildcards", []) - if path: - assert inputs_config["bold"].get("custom_path", "") == Path(path).resolve() class TestRunSnakemake: From bb2dce8350d71f568fbd2d02465c49ac2a89f2aa Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Mon, 12 Jun 2023 16:21:29 -0400 Subject: [PATCH 6/7] Standardize style in test_dynamic_inputs --- snakebids/tests/test_cli.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/snakebids/tests/test_cli.py b/snakebids/tests/test_cli.py index 9fefebdd..be7af693 100644 --- a/snakebids/tests/test_cli.py +++ b/snakebids/tests/test_cli.py @@ -1,7 +1,7 @@ from __future__ import annotations import copy -import itertools +import itertools as it import sys from argparse import ArgumentParser, Namespace from collections.abc import Sequence @@ -83,19 +83,17 @@ def test_dynamic_inputs(self, mocker: MockerFixture, pybids_inputs: InputsConfig p = create_parser() add_dynamic_args(p, copy.deepcopy(parse_args), pybids_inputs) magic_filters = list( - itertools.chain.from_iterable( + it.chain.from_iterable( [[f"--filter-{key}", "entity=value"] for key in pybids_inputs] ) ) magic_wildcards = list( - itertools.chain.from_iterable( + it.chain.from_iterable( [[f"--wildcards-{key}", "test"] for key in pybids_inputs] ) ) magic_path = list( - itertools.chain.from_iterable( - [[f"--path-{key}", "test"] for key in pybids_inputs] - ) + it.chain.from_iterable([[f"--path-{key}", "test"] for key in pybids_inputs]) ) mocker.patch.object( sys, @@ -106,8 +104,8 @@ def test_dynamic_inputs(self, mocker: MockerFixture, pybids_inputs: InputsConfig for key in pybids_inputs: key_identifier = key.replace("-", "_") # argparse does this assert isinstance(args.args_dict[f"path_{key_identifier}"], str) - assert isinstance(args.args_dict.get(f"filter_{key_identifier}"), dict) - assert isinstance(args.args_dict.get(f"wildcards_{key_identifier}"), list) + assert isinstance(args.args_dict[f"filter_{key_identifier}"], dict) + assert isinstance(args.args_dict[f"wildcards_{key_identifier}"], list) def test_fails_if_missing_arguments( self, parser: ArgumentParser, mocker: MockerFixture From 1688e0de46545582bcb0a763edae278a0edbd126 Mon Sep 17 00:00:00 2001 From: Tristan Kuehn Date: Mon, 12 Jun 2023 16:34:37 -0400 Subject: [PATCH 7/7] Exclude null byte from input_configs paths --- snakebids/tests/strategies.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/snakebids/tests/strategies.py b/snakebids/tests/strategies.py index d4c29463..512ef7c4 100644 --- a/snakebids/tests/strategies.py +++ b/snakebids/tests/strategies.py @@ -64,7 +64,16 @@ def input_configs(draw: st.DrawFn) -> InputConfig: ) wildcards = draw(st.one_of(st.lists(bids_entity().map(str)), st.none())) - custom_path = draw(st.one_of(st.text(), st.none())) + custom_path = draw( + st.one_of( + st.text( + alphabet=st.characters( + blacklist_categories=("Cs",), blacklist_characters=("\x00",) + ) + ), + st.none(), + ) + ) pybids_inputs: InputConfig = {} if wildcards is not None: