From e4586e1684043924c75d91924748651105a0e194 Mon Sep 17 00:00:00 2001 From: Peter Van Dyken Date: Wed, 24 Jan 2024 11:42:56 -0500 Subject: [PATCH] Encode ordered dicts as regular dicts with ruamel Older snakemake versions read config files as ordered dicts. Ruamel would then render these back to yaml with an !!omap type, which would then in turn be interpreted by pyyaml as lists of tuple pairs. Fix by handling all OrderedDicts as regular dicts Additionally, test a template dry run using the current repository state (where previously we used only the latest pip release) --- snakebids/io/yaml.py | 9 ++++++++- snakebids/tests/helpers.py | 6 +++--- snakebids/tests/test_template.py | 33 ++++++++++++++++++++++++++++++++ snakebids/tests/test_yaml.py | 25 +++++++++++++++++++++++- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/snakebids/io/yaml.py b/snakebids/io/yaml.py index 8ecacfcb..51c8958c 100644 --- a/snakebids/io/yaml.py +++ b/snakebids/io/yaml.py @@ -1,4 +1,6 @@ +import collections from pathlib import Path, PosixPath, WindowsPath +from typing import Any, OrderedDict from ruamel.yaml import YAML, Dumper @@ -12,8 +14,13 @@ def path2str(dumper: Dumper, data: Path): return dumper.represent_scalar( "tag:yaml.org,2002:str", str(data), - ) + ) # type: ignore + + def to_dict(dumper: Dumper, data: OrderedDict[Any, Any]): + return dumper.represent_dict(dict(data)) yaml.representer.add_representer(PosixPath, path2str) yaml.representer.add_representer(WindowsPath, path2str) + yaml.representer.add_representer(collections.OrderedDict, to_dict) + yaml.representer.add_representer(OrderedDict, to_dict) return yaml diff --git a/snakebids/tests/helpers.py b/snakebids/tests/helpers.py index c7996464..b67f02c8 100644 --- a/snakebids/tests/helpers.py +++ b/snakebids/tests/helpers.py @@ -298,7 +298,7 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs): sp.run(["docker", "image", "inspect", container], check=True) except sp.CalledProcessError as err: if not ( - match := re.match(r"snakebids/([\w\-])+\:[a-zA-Z0-9\-]+", container) + match := re.match(r"snakebids/([\w\-]+)\:[a-zA-Z0-9\-]+", container) ): pytest.fail( f"Unrecognized docker image: {container}. Should be " @@ -307,8 +307,8 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs): container_id = match[1] pytest.fail( f"{container} is not built on this machine. To build container, " - f"run `poetry run poe build-container {container_id}`. To skip " - f"docker tests, use '-m \"not docker\"'. (got error {err})" + f"run:\n\n\t`poetry run poe build-container {container_id}`\n\nTo " + f"skip docker tests, use '-m \"not docker\"'.\n\n(error:) {err}" ) return func(*args, **kwargs) diff --git a/snakebids/tests/test_template.py b/snakebids/tests/test_template.py index 0ca33f23..1c64a67a 100644 --- a/snakebids/tests/test_template.py +++ b/snakebids/tests/test_template.py @@ -304,3 +304,36 @@ def test_template_dry_runs_successfully(tmp_path: Path, build: BuildBackend, ven print(cmd.stderr, file=sys.stderr) raise assert "All set" in cmd.stdout.decode() + + +def test_template_dry_runs_with_current_repository(tmp_path: Path): + app_name = "snakebids_app" + data = get_empty_data(app_name, "setuptools") + + copier.run_copy( + str(Path(itx.first(snakebids.__path__), "project_template")), + tmp_path / app_name, + data=data, + unsafe=True, + ) + app_path = tmp_path / app_name + cmd = sp.run( + [ + sys.executable, + app_path / app_name / "run.py", + app_path / "tests/data", + app_path / "tests/results", + "participant", + "-c1", + "--skip-bids-validation", + ], + capture_output=True, + check=False, + ) + try: + cmd.check_returncode() + except Exception: + print(cmd.stdout.decode()) + print(cmd.stderr.decode(), file=sys.stderr) + raise + assert "All set" in cmd.stdout.decode() diff --git a/snakebids/tests/test_yaml.py b/snakebids/tests/test_yaml.py index 8dee796b..cb93b82f 100644 --- a/snakebids/tests/test_yaml.py +++ b/snakebids/tests/test_yaml.py @@ -1,7 +1,9 @@ from __future__ import annotations +import io from io import StringIO from pathlib import Path +from typing import Any, OrderedDict import pytest from hypothesis import given @@ -14,8 +16,10 @@ import snakebids.io.yaml as yamlio from snakebids.tests.helpers import allow_function_scoped +YAML_SAFE_CHARS = st.characters(blacklist_characters=["\x85"]) -@given(path=st.text(st.characters(blacklist_characters=["\x85"]), min_size=1).map(Path)) + +@given(path=st.text(YAML_SAFE_CHARS, min_size=1).map(Path)) def test_paths_formatted_as_str(path: Path): string = StringIO() yaml = yamlio.get_yaml_io() @@ -71,3 +75,22 @@ def test_overwrites_file_if_forced( mocks = self.io_mocks(mocker) configio.write_config(path, {}, force_overwrite=True) assert mocks["jsondump"].call_count ^ mocks["yamldump"].call_count + + @given( + data=st.recursive( + st.text(YAML_SAFE_CHARS), + lambda children: st.dictionaries(st.text(YAML_SAFE_CHARS), children), + ) + ) + def test_ordered_dict_roundtrips_as_dict(self, data: dict[str, Any]): + def to_odict(data: dict[str, Any] | str) -> OrderedDict[str, Any] | str: + if isinstance(data, str): + return data + return OrderedDict({key: to_odict(value) for key, value in data.items()}) + + stream = io.StringIO() + yaml = yamlio.get_yaml_io() + odict = to_odict(data) + yaml.dump(odict, stream) + stream.seek(0) + assert yaml.load(stream) == data