Skip to content

Commit

Permalink
Complete test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
pvandyken committed Feb 21, 2024
1 parent 62242b7 commit 20797a5
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 44 deletions.
12 changes: 3 additions & 9 deletions snakebids/plugins/cli_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from snakebids import bidsapp
from snakebids.exceptions import ConfigError
from snakebids.io.yaml import get_yaml_io
from snakebids.plugins.base import PluginBase


def _make_underscore_dash_aliases(name: str) -> set[str]:
Expand Down Expand Up @@ -60,7 +59,7 @@ def _find_type(name: str, *, yamlsafe: bool = True) -> type[Any]:


@attrs.define
class CliConfig(PluginBase):
class CliConfig:
"""Configure CLI arguments directly in config.
Arguments are provided in config in a dictionary stored under ``cli_key``. Each
Expand Down Expand Up @@ -124,15 +123,10 @@ def add_cli_arguments(
# a str to allow the edge case where it's already
# been converted
if "type" in arg:
try:
arg_dict = {**arg, "type": _find_type(str(arg["type"]))}
except KeyError as err:
msg = f"{arg['type']} is not available as a type for {name}"
raise TypeError(msg) from err
arg_dict = {**arg, "type": _find_type(str(arg["type"]))}
else:
arg_dict = arg
self.add_argument(
parser,
parser.add_argument(
*_make_underscore_dash_aliases(name),
**arg_dict, # type: ignore
)
18 changes: 10 additions & 8 deletions snakebids/plugins/component_edit.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import math
from pathlib import Path
from typing import Any, Sequence

Expand Down Expand Up @@ -30,12 +31,15 @@ def __call__(
return

for pair in values:
if "=" in pair:
# split it into key and value
key, value = pair.split("=", 1)
elif ":" in pair:
key, spec = pair.split(":", 1)
spec = spec.lower()
eq = pair.find("=")
col = pair.find(":")
delim = min(eq if eq >= 0 else math.inf, col if col >= 0 else math.inf)
if delim is math.inf:
raise MisspecifiedCliFilterError(pair)
key = pair[:delim]
value = pair[delim + 1 :]
if delim == col:
spec = value.lower()
if spec == "optional":
value = OptionalFilter
elif spec in ["required", "any"]:
Expand All @@ -45,8 +49,6 @@ def __call__(
else:
# The flag isn't recognized
raise MisspecifiedCliFilterError(pair)
else:
raise MisspecifiedCliFilterError(pair)

# assign into dictionary
getattr(namespace, self.dest)[key] = value
Expand Down
2 changes: 1 addition & 1 deletion snakebids/plugins/snakemake.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ def finalize_config(self, config: dict[str, Any]):
try:
prepare_bidsapp_output(config["output_dir"], self.force_output)
except RunError as err:
print(err.msg)
print(err.msg, file=sys.stderr)
sys.exit(1)
self.cwd = config["output_dir"]
root = Path()
Expand Down
44 changes: 24 additions & 20 deletions snakebids/tests/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,32 +41,33 @@ def nothing() -> Any:
return st.nothing() # type: ignore


class PathStrategy(st.SearchStrategy[Path]):
def resolve(self) -> st.SearchStrategy[Path]:
...


def paths(*, absolute: bool | None = None) -> PathStrategy:
valid_chars = st.characters(
min_codepoint=48, max_codepoint=122, whitelist_categories=["Ll", "Lu"]
)
paths = st.lists(st.text(valid_chars)).map(lambda x: Path(*x))
def paths(
*,
min_segments: int = 0,
max_segments: int | None = None,
absolute: bool | None = None,
resolve: bool = False,
) -> st.SearchStrategy[Path]:
valid_chars = st.characters(blacklist_characters=["/", "\x00"])
paths = st.lists(
st.text(valid_chars), min_size=min_segments, max_size=max_segments
).map(lambda x: Path(*x))

def relative_paths():
return paths.filter(lambda p: not p.is_absolute())
relative_paths = paths.filter(lambda p: not p.is_absolute())

def absolute_paths():
return paths.map(lambda p: Path("/", p))
absolute_paths = paths.map(lambda p: Path("/", p))

if absolute:
result = absolute_paths()
result = absolute_paths
elif absolute is False:
result = relative_paths()
result = relative_paths
else:
result = absolute_paths() | relative_paths()
result = absolute_paths | relative_paths

result.resolve = lambda: result.map(lambda p: p.resolve()) # type: ignore
return result # type: ignore
if resolve:
return result.map(lambda p: p.resolve())

return result


def bids_entity(
Expand Down Expand Up @@ -241,14 +242,17 @@ def input_configs(
def inputs_configs(
*,
keys: st.SearchStrategy[str] | None = None,
min_size: int = 0,
max_size: int | None = 5,
filters: bool = True,
wildcards: bool = True,
paths: bool = True,
) -> st.SearchStrategy[InputsConfig]:
return st.dictionaries(
keys if keys is not None else st.text(min_size=1),
input_configs(filters=filters, wildcards=wildcards, paths=paths),
max_size=5,
min_size=min_size,
max_size=max_size,
)


Expand Down
67 changes: 67 additions & 0 deletions snakebids/tests/test_plugins/test_bidsargs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

import itertools as it
from argparse import ArgumentParser
from typing import Iterable

import pytest
from hypothesis import given
from hypothesis import strategies as st

from snakebids.exceptions import ConfigError
from snakebids.plugins.bidsargs import BidsArgs


class _St:
positional_args = st.text().filter(lambda s: not s.startswith("-"))
analysis_level_choices = positional_args | st.iterables(positional_args)


class TestAddCliArguments:
@given(
analysis_level_choices=_St.positional_args,
analysis_level_choices_config=st.text(),
)
def test_analysis_level_attributes_are_mutually_exclusive(
self,
analysis_level_choices: str | Iterable[str],
analysis_level_choices_config: str,
):
with pytest.raises(ConfigError, match="cannot be simultaneously defined"):
BidsArgs(
analysis_level=True,
analysis_level_choices=analysis_level_choices,
analysis_level_choices_config=analysis_level_choices_config,
).add_cli_arguments(ArgumentParser(), {}, {})

@given(analysis_level_choices=_St.positional_args)
def test_analysis_levels_can_be_directly_defined(
self, analysis_level_choices: str | Iterable[str]
):
parser = ArgumentParser()
a1, a2 = it.tee(analysis_level_choices)
bidsargs = BidsArgs(analysis_level=True, analysis_level_choices=a1)
bidsargs.add_cli_arguments(parser, {}, {})
for arg in a2:
nspc = parser.parse_args(["...", "...", arg])
assert nspc.analysis_level == arg

@given(
analysis_level_choices=_St.positional_args,
analysis_level_choices_config=st.text(),
)
def test_analysis_levels_can_be_defined_in_config(
self,
analysis_level_choices: str | Iterable[str],
analysis_level_choices_config: str,
):
parser = ArgumentParser()
choices = list(analysis_level_choices)
bidsargs = BidsArgs(
analysis_level=True,
analysis_level_choices_config=analysis_level_choices_config,
)
bidsargs.add_cli_arguments(parser, {}, {analysis_level_choices_config: choices})
for arg in choices:
nspc = parser.parse_args(["...", "...", arg])
assert nspc.analysis_level == arg
25 changes: 25 additions & 0 deletions snakebids/tests/test_plugins/test_cli_config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import argparse
from pathlib import Path
from typing import Literal

import more_itertools as itx
import pytest
from hypothesis import given
from hypothesis import strategies as st
Expand Down Expand Up @@ -35,6 +37,15 @@ def test_underscores_to_dashes(
assert all(nm.__dict__.values())
assert len(nm.__dict__) == len(args)

@given(arg=st.text().filter(lambda s: not s.startswith("-") and len(s)))
def test_positional_args_added_without_conversion(self, arg: str):
cli = CliConfig()
parser = argparse.ArgumentParser()
config = {"parse_args": {arg: {"help": "..."}}}
cli.add_cli_arguments(parser, config)
nm = parser.parse_args(["..."])
assert itx.one(nm.__dict__.values()) == "..."

def test_fails_if_undefined_type_given(self):
cli = CliConfig()
config = {"parse_args": {"--new-param": {"type": "UnheardClass"}}}
Expand Down Expand Up @@ -64,6 +75,20 @@ def test_convert_arg_to_builtin():
assert isinstance(args.new_param, int)


def test_path_works_as_builtin():
cli = CliConfig()
parser = argparse.ArgumentParser()
new_args = {
"--new-param": {
"help": "Generic Help Message",
"type": "Path",
}
}
cli.add_cli_arguments(parser, {"parse_args": new_args})
args = parser.parse_args(["--new-param", "foo"])
assert args.new_param == Path("foo")


def test_non_serialiable_type_raises_error():
cli = CliConfig()
parser = argparse.ArgumentParser()
Expand Down
46 changes: 46 additions & 0 deletions snakebids/tests/test_plugins/test_component_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from pathlib import Path
from typing import Any, ClassVar

import pytest
from hypothesis import given
from hypothesis import strategies as st

import snakebids.tests.strategies as sb_st
from snakebids.exceptions import MisspecifiedCliFilterError
from snakebids.plugins.component_edit import ComponentEdit
from snakebids.tests.helpers import allow_function_scoped
from snakebids.types import InputsConfig, OptionalFilter
Expand Down Expand Up @@ -132,6 +134,50 @@ def test_none_filters(self, pybids_inputs: InputsConfig, flag: str):
for key in pybids_inputs:
assert args.__dict__[f"{comp_edit.PREFIX}.filter.{key}"]["entity"] is False

@given(
pybids_inputs=sb_st.inputs_configs(min_size=1, max_size=1),
flag=st.text().filter(
lambda s: s.lower() not in {"none", "any", "optional", "required"}
),
)
def test_filter_with_bad_flag_errors(self, pybids_inputs: InputsConfig, flag: str):
p = argparse.ArgumentParser()
comp_edit = ComponentEdit()
comp_edit.add_cli_arguments(p, {"pybids_inputs": pybids_inputs})
argv = list(
it.chain.from_iterable(
[[f"--filter-{key}", f"entity:{flag}"] for key in pybids_inputs]
)
)

with pytest.raises(
MisspecifiedCliFilterError,
match=re.compile(rf"following filter provided.*entity:{re.escape(flag)}"),
):
p.parse_args(argv)

@given(
pybids_inputs=sb_st.inputs_configs(min_size=1, max_size=1),
filt=st.text().filter(
lambda s: "=" not in s and ":" not in s and not s.startswith("-")
),
)
def test_filters_with_no_delimiter_errors(
self, pybids_inputs: InputsConfig, filt: str
):
p = argparse.ArgumentParser()
comp_edit = ComponentEdit()
comp_edit.add_cli_arguments(p, {"pybids_inputs": pybids_inputs})
argv = list(
it.chain.from_iterable([[f"--filter-{key}", filt] for key in pybids_inputs])
)

with pytest.raises(
MisspecifiedCliFilterError,
match=re.compile(rf"following filter provided.*{re.escape(filt)}"),
):
p.parse_args(argv)


@st.composite
def two_configs(
Expand Down
Loading

0 comments on commit 20797a5

Please sign in to comment.