diff --git a/.gitignore b/.gitignore index 1e64e01..d86cd1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .ruff_cache +.vscode # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/pyproject.toml b/pyproject.toml index 496463e..10743c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,19 +28,14 @@ docs = ["pdoc"] styx = "styx.main:main" [tool.pytest.ini_options] -pythonpath = [ - "src" -] +pythonpath = ["src"] [tool.mypy] ignore_missing_imports = true [tool.ruff] preview = true -extend-exclude = [ - "examples", - "src/styx/boutiques/model.py" -] +extend-exclude = ["examples", "src/styx/boutiques/model.py"] line-length = 120 indent-width = 4 src = ["src"] @@ -62,7 +57,9 @@ unfixable = [] convention = "google" [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = [] +"tests/**/*.py" = [ + "ANN401" # Dynamic type expression ('Any') +] [build-system] requires = ["poetry-core>=1.2.0"] diff --git a/src/styx/ir/pretty_print.py b/src/styx/ir/pretty_print.py index 1194f2c..9030fcd 100644 --- a/src/styx/ir/pretty_print.py +++ b/src/styx/ir/pretty_print.py @@ -68,6 +68,17 @@ def field_is_default(obj: Any, field_: dataclasses.Field) -> bool: # noqa: ANN4 ), ")", ]) + if hasattr(obj, "__dict__"): + return f"\n{_indentation(ind)}".join([ + f"{obj.__class__.__name__}(", + *_expand( + ",\n".join([ + f" {field_name}={_pretty_print(field_value, 1)}" + for field_name, field_value in obj.__dict__.items() + ]) + ), + ")", + ]) else: return str(obj) diff --git a/tests/frontend/boutiques/test_command_line.py b/tests/frontend/boutiques/test_command_line.py new file mode 100644 index 0000000..1dfbb0e --- /dev/null +++ b/tests/frontend/boutiques/test_command_line.py @@ -0,0 +1,37 @@ +import styx.ir.core as ir +from styx.frontend.boutiques.core import from_boutiques + + +class TestCommandLine: + package_name = "My package" + descriptor_name = "My descriptor" + + def test_basic(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": "hello world", + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + assert isinstance(groups[0].cargs[0].tokens[0], str) + assert groups[0].cargs[0].tokens[0] == "hello" + assert isinstance(groups[1].cargs[0].tokens[0], str) + assert groups[1].cargs[0].tokens[0] == "world" + + def test_quoting(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": 'hello "string with whitespace"', + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + assert isinstance(groups[0].cargs[0].tokens[0], str) + assert groups[0].cargs[0].tokens[0] == "hello" + assert isinstance(groups[1].cargs[0].tokens[0], str) + assert groups[1].cargs[0].tokens[0] == "string with whitespace" diff --git a/tests/frontend/boutiques/test_descriptor.py b/tests/frontend/boutiques/test_descriptor.py new file mode 100644 index 0000000..f410935 --- /dev/null +++ b/tests/frontend/boutiques/test_descriptor.py @@ -0,0 +1,27 @@ +from typing import Any + +import pytest + +import styx.ir.core as ir +from styx.frontend.boutiques.core import from_boutiques + + +class TestDescriptor: + package_name = "My package" + descriptor_name = "My descriptor" + + def test_descriptor(self) -> None: + bt = {"name": self.descriptor_name} + out = from_boutiques(bt, self.package_name) + + assert isinstance(out, ir.Interface) + assert out.package.name == self.package_name + assert isinstance(out.command.body, ir.Param.Struct) + assert out.command.base.name == self.descriptor_name + + @pytest.mark.parametrize("descriptor_name", (123, ["list of str"])) + @pytest.mark.skip + def test_invalid_descriptor(self, descriptor_name: Any) -> None: + bt = {"name": descriptor_name} + with pytest.raises(TypeError): + from_boutiques(bt, self.package_name) diff --git a/tests/frontend/boutiques/test_docs.py b/tests/frontend/boutiques/test_docs.py new file mode 100644 index 0000000..7794960 --- /dev/null +++ b/tests/frontend/boutiques/test_docs.py @@ -0,0 +1,60 @@ +from typing import Any + +import pytest + +import styx.ir.core as ir +from styx.frontend.boutiques.core import from_boutiques + + +class TestDocumentation: + # NOTE: 'literature' unable to be tested + package_name = "My package" + descriptor_name = "My descriptor" + + @pytest.mark.parametrize("desc", ("A short description", None)) + def test_valid_description(self, desc: str | None) -> None: + bt = {"name": self.descriptor_name, "description": desc} + out = from_boutiques(bt, self.package_name) + assert isinstance(out.command.base.docs, ir.Documentation) + assert out.command.base.docs.description == desc + + @pytest.mark.parametrize("desc", (["A short description"], 123)) + @pytest.mark.skip + def test_invalid_description_type(self, desc: Any) -> None: + bt = {"name": self.descriptor_name, "description": desc} + with pytest.raises(TypeError): + from_boutiques(bt, self.package_name) + + # NOTE: Can only pass a single string of author(s) via boutiques + def test_valid_authors(self) -> None: + authors = "Author One" + bt = {"name": self.descriptor_name, "author": authors} + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.base.docs.authors, list) + assert out.command.base.docs.authors == [authors] + + @pytest.mark.parametrize("authors", (["Author One"], 123)) + @pytest.mark.skip + def test_invalid_author_type(self, authors: Any) -> None: + bt = {"name": self.descriptor_name, "author": authors} + + with pytest.raises(TypeError): + from_boutiques(bt, self.package_name) + + # NOTE: Can only pass a single string of url(s) via boutiques + def test_valid_urls(self) -> None: + urls = "https://url.com" + bt = {"name": self.descriptor_name, "url": urls} + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.base.docs.urls, list) + assert out.command.base.docs.urls == [urls] + + @pytest.mark.parametrize("urls", (["https://url.com"], 123)) + @pytest.mark.skip + def test_invalid_urls_type(self, urls: Any) -> None: + bt = {"name": self.descriptor_name, "url": urls} + + with pytest.raises(TypeError): + from_boutiques(bt, self.package_name) diff --git a/tests/frontend/boutiques/test_inputs.py b/tests/frontend/boutiques/test_inputs.py new file mode 100644 index 0000000..330e364 --- /dev/null +++ b/tests/frontend/boutiques/test_inputs.py @@ -0,0 +1,127 @@ +import styx.ir.core as ir +from styx.frontend.boutiques.core import from_boutiques + + +class TestPrimitiveParams: + """Test primitive param types. + + Main difference in primitive types between boutiques and IR are: + + - IR has separate int and float types, boutiques just "Number" + - Boutiques "Flag" type maps to IR bool which is encoded differently. + """ + + package_name = "My package" + descriptor_name = "My descriptor" + + def test_int(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "value-key": "[X]", + "type": "Number", + "integer": True, + } + ], + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + param = groups[1].cargs[0].tokens[0] + assert isinstance(param, ir.Param) + assert isinstance(param.body, ir.Param.Int) + + def test_float(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "value-key": "[X]", + "type": "Number", + # "integer": False, # Default is float + } + ], + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + param = groups[1].cargs[0].tokens[0] + assert isinstance(param, ir.Param) + assert isinstance(param.body, ir.Param.Float) + + def test_file(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "value-key": "[X]", + "type": "File", + } + ], + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + param = groups[1].cargs[0].tokens[0] + assert isinstance(param, ir.Param) + assert isinstance(param.body, ir.Param.File) + + def test_string(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "value-key": "[X]", + "type": "String", + } + ], + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + param = groups[1].cargs[0].tokens[0] + assert isinstance(param, ir.Param) + assert isinstance(param.body, ir.Param.String) + + def test_bool(self) -> None: + bt = { + "name": self.descriptor_name, + "command-line": "dummy [X]", + "inputs": [ + { + "id": "x", + "value-key": "[X]", + "type": "Flag", + "command-line-flag": "--x", + } + ], + } + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.command.body, ir.Param.Struct) + groups = out.command.body.groups + assert len(groups) == 2 + param = groups[1].cargs[0].tokens[0] + assert isinstance(param, ir.Param) + assert isinstance(param.body, ir.Param.Bool) + assert param.body.value_true == ["--x"] + assert param.body.value_false == [] + assert not param.nullable + assert param.default_value is False # check identity to ensure it's False and not None diff --git a/tests/frontend/boutiques/test_metadata.py b/tests/frontend/boutiques/test_metadata.py new file mode 100644 index 0000000..e925251 --- /dev/null +++ b/tests/frontend/boutiques/test_metadata.py @@ -0,0 +1,44 @@ +from typing import Any + +import pytest + +import styx.ir.core as ir +from styx.frontend.boutiques.core import from_boutiques + + +class TestMetadata: + package_name = "My package" + descriptor_name = "My descriptor" + + @pytest.mark.parametrize("version", ("0.0.0", None)) + def test_valid_version(self, version: str | None) -> None: + bt = {"name": self.descriptor_name, "tool-version": version} + out = from_boutiques(bt, self.package_name) + + assert isinstance(out.package, ir.Package) + assert out.package.name == self.package_name + assert out.package.version == version + + @pytest.mark.parametrize("version", (1.23, ["version"])) + @pytest.mark.skip + def test_invalid_version_type(self, version: Any) -> None: + bt = {"name": self.descriptor_name, "tool-version": version} + + with pytest.raises(TypeError): + from_boutiques(bt, self.package_name) + + @pytest.mark.parametrize("image", ("container:version", None)) + def test_valid_docker(self, image: str | None) -> None: + bt = {"name": self.descriptor_name, "container-image": {"image": image}} + out = from_boutiques(bt, self.package_name) + assert isinstance(out.package, ir.Package) + assert out.package.name == self.package_name + assert out.package.docker == image + + @pytest.mark.parametrize("image", (123, ["list of str"])) + @pytest.mark.skip + def test_invalid_docker_type(self, image: Any) -> None: + bt = {"name": self.descriptor_name, "container-image": {"image": image}} + + with pytest.raises(TypeError): + from_boutiques(bt, self.package_name) diff --git a/tests/frontend/boutiques/test_outputs.py b/tests/frontend/boutiques/test_outputs.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/legacy/__init__.py b/tests/legacy/__init__.py new file mode 100644 index 0000000..0ecdab8 --- /dev/null +++ b/tests/legacy/__init__.py @@ -0,0 +1 @@ +"""Legacy tests.""" diff --git a/tests/test_carg_building.py b/tests/legacy/test_carg_building.py similarity index 86% rename from tests/test_carg_building.py rename to tests/legacy/test_carg_building.py index 109bde7..0797722 100644 --- a/tests/test_carg_building.py +++ b/tests/legacy/test_carg_building.py @@ -1,8 +1,8 @@ """Test command line argument building.""" -import tests.utils.dummy_runner -from tests.utils.compile_boutiques import boutiques2python -from tests.utils.dynmodule import ( +import tests.legacy.utils.dummy_runner +from tests.legacy.utils.compile_boutiques import boutiques2python +from tests.legacy.utils.dynmodule import ( BT_TYPE_FILE, BT_TYPE_FLAG, BT_TYPE_NUMBER, @@ -29,7 +29,7 @@ def test_positional_string_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") assert dummy_runner.last_cargs is not None @@ -53,7 +53,7 @@ def test_positional_number_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x="123") assert dummy_runner.last_cargs is not None @@ -77,7 +77,7 @@ def test_positional_file_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x="/my/file.txt") assert dummy_runner.last_cargs is not None @@ -102,7 +102,7 @@ def test_flag_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") assert dummy_runner.last_cargs is not None @@ -127,7 +127,7 @@ def test_named_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") assert dummy_runner.last_cargs is not None @@ -161,11 +161,20 @@ def test_list_of_strings_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() - test_module.dummy(runner=dummy_runner, x=["my_string1", "my_string2"], y=["my_string3", "my_string4"]) + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() + test_module.dummy( + runner=dummy_runner, + x=["my_string1", "my_string2"], + y=["my_string3", "my_string4"], + ) assert dummy_runner.last_cargs is not None - assert dummy_runner.last_cargs == ["dummy", "my_string1", "my_string2", "my_string3 my_string4"] + assert dummy_runner.last_cargs == [ + "dummy", + "my_string1", + "my_string2", + "my_string3 my_string4", + ] def test_list_of_numbers_arg() -> None: @@ -195,7 +204,7 @@ def test_list_of_numbers_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x=[1, 2], y=[3, 4]) assert dummy_runner.last_cargs is not None @@ -220,7 +229,7 @@ def test_static_args() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner, x="my_string") assert dummy_runner.last_cargs is not None @@ -265,7 +274,7 @@ def test_arg_order() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(a="aaa", b="bbb", runner=dummy_runner) assert dummy_runner.last_cargs is not None diff --git a/tests/test_default_values.py b/tests/legacy/test_default_values.py similarity index 78% rename from tests/test_default_values.py rename to tests/legacy/test_default_values.py index 5e85f35..23cbe92 100644 --- a/tests/test_default_values.py +++ b/tests/legacy/test_default_values.py @@ -1,8 +1,8 @@ """Input argument default value tests.""" -import tests.utils.dummy_runner -from tests.utils.compile_boutiques import boutiques2python -from tests.utils.dynmodule import ( +import tests.legacy.utils.dummy_runner +from tests.legacy.utils.compile_boutiques import boutiques2python +from tests.legacy.utils.dynmodule import ( BT_TYPE_STRING, boutiques_dummy, dynamic_module, @@ -27,7 +27,7 @@ def test_default_string_arg() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() test_module.dummy(runner=dummy_runner) assert dummy_runner.last_cargs is not None diff --git a/tests/test_numeric_ranges.py b/tests/legacy/test_numeric_ranges.py similarity index 87% rename from tests/test_numeric_ranges.py rename to tests/legacy/test_numeric_ranges.py index a331244..1c87476 100644 --- a/tests/test_numeric_ranges.py +++ b/tests/legacy/test_numeric_ranges.py @@ -2,9 +2,9 @@ import pytest -import tests.utils.dummy_runner -from tests.utils.compile_boutiques import boutiques2python -from tests.utils.dynmodule import ( +import tests.legacy.utils.dummy_runner +from tests.legacy.utils.compile_boutiques import boutiques2python +from tests.legacy.utils.dynmodule import ( BT_TYPE_NUMBER, boutiques_dummy, dynamic_module, @@ -30,7 +30,7 @@ def test_below_range_minimum_inclusive() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() with pytest.raises(ValueError): test_module.dummy(runner=dummy_runner, x=4) @@ -54,7 +54,7 @@ def test_above_range_maximum_inclusive() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() with pytest.raises(ValueError): test_module.dummy(runner=dummy_runner, x=6) @@ -79,7 +79,7 @@ def test_above_range_maximum_exclusive() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() with pytest.raises(ValueError): test_module.dummy(runner=dummy_runner, x=5) @@ -104,7 +104,7 @@ def test_below_range_minimum_exclusive() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() with pytest.raises(ValueError): test_module.dummy(runner=dummy_runner, x=5) @@ -129,7 +129,7 @@ def test_outside_range() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() with pytest.raises(ValueError): test_module.dummy(runner=dummy_runner, x=11) diff --git a/tests/test_output_files.py b/tests/legacy/test_output_files.py similarity index 89% rename from tests/test_output_files.py rename to tests/legacy/test_output_files.py index 7123dab..cde64ed 100644 --- a/tests/test_output_files.py +++ b/tests/legacy/test_output_files.py @@ -1,8 +1,8 @@ """Test output file paths.""" -import tests.utils.dummy_runner -from tests.utils.compile_boutiques import boutiques2python -from tests.utils.dynmodule import ( +import tests.legacy.utils.dummy_runner +from tests.legacy.utils.compile_boutiques import boutiques2python +from tests.legacy.utils.dynmodule import ( BT_TYPE_FILE, BT_TYPE_NUMBER, boutiques_dummy, @@ -34,7 +34,7 @@ def test_output_file() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() out = test_module.dummy(runner=dummy_runner, x=5) assert dummy_runner.last_cargs is not None @@ -67,7 +67,7 @@ def test_output_file_with_template() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() out = test_module.dummy(runner=dummy_runner, x=5) assert dummy_runner.last_cargs is not None @@ -101,7 +101,7 @@ def test_output_file_with_template_and_stripped_extensions() -> None: compiled_module = boutiques2python(model) test_module = dynamic_module(compiled_module, "test_module") - dummy_runner = tests.utils.dummy_runner.DummyRunner() + dummy_runner = tests.legacy.utils.dummy_runner.DummyRunner() out = test_module.dummy(runner=dummy_runner, x="in.txt") assert dummy_runner.last_cargs is not None diff --git a/tests/utils/__init__.py b/tests/legacy/utils/__init__.py similarity index 100% rename from tests/utils/__init__.py rename to tests/legacy/utils/__init__.py diff --git a/tests/utils/compile_boutiques.py b/tests/legacy/utils/compile_boutiques.py similarity index 100% rename from tests/utils/compile_boutiques.py rename to tests/legacy/utils/compile_boutiques.py diff --git a/tests/utils/dummy_runner.py b/tests/legacy/utils/dummy_runner.py similarity index 100% rename from tests/utils/dummy_runner.py rename to tests/legacy/utils/dummy_runner.py diff --git a/tests/utils/dynmodule.py b/tests/legacy/utils/dynmodule.py similarity index 100% rename from tests/utils/dynmodule.py rename to tests/legacy/utils/dynmodule.py