From 13b132ce0e56551903bbee6baa33f75103acfc4e Mon Sep 17 00:00:00 2001 From: Robrecht Cannoodt Date: Thu, 13 Aug 2026 11:42:26 +0200 Subject: [PATCH 1/6] check the required links when there are none `check_links()` returned early on an empty `links`, before asserting that the required ones are present. A method without any links passed the check, while a method with only a `documentation` link was correctly told that `.links.repository` is missing. --- .../openproblems/project/component_tests/check_config.py | 3 +-- .../openproblems/tests/test_project_check_config.py | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py b/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py index 7bae60a..5c9628e 100644 --- a/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py +++ b/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py @@ -60,8 +60,7 @@ def check_references(references: Dict[str, Union[str, List[str]]]) -> None: def check_links( links: Dict[str, Union[str, List[str]]], required: List[str] = [] ) -> None: - if not links: - return + links = links or {} for expected_link in required: assert expected_link in links, f"Link .links.{expected_link} is not defined" diff --git a/packages/python/openproblems/tests/test_project_check_config.py b/packages/python/openproblems/tests/test_project_check_config.py index 67da399..b14479c 100644 --- a/packages/python/openproblems/tests/test_project_check_config.py +++ b/packages/python/openproblems/tests/test_project_check_config.py @@ -30,6 +30,14 @@ def test_check_config_accepts_resource_labels(): check_config(_config()) +def test_check_links_requires_the_expected_links(): + from openproblems.project.component_tests.check_config import check_links + + for links in [{}, None, {"documentation": "https://example.com"}]: + with pytest.raises(AssertionError, match="Link .links.repository"): + check_links(links, ["repository"]) + + def test_check_config_requires_a_nextflow_runner(): with pytest.raises(AssertionError, match="does not contain a nextflow runner"): check_config(_config(runners=[{"type": "executable"}])) From ec252ba2036ed0d703e1c13457dfd290a53636f3 Mon Sep 17 00:00:00 2001 From: Robrecht Cannoodt Date: Thu, 13 Aug 2026 11:49:10 +0200 Subject: [PATCH 2/6] skip output files that have no value The format validation loop did not filter on `required` the way the existence check above it does, so an optional output without a default or example -- which never gets a value assigned -- crashed the component test with a bare `KeyError: 'value'`. --- .../component_tests/run_and_check_output.py | 2 +- .../test_project_run_and_check_output.py | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/python/openproblems/tests/test_project_run_and_check_output.py diff --git a/packages/python/openproblems/src/openproblems/project/component_tests/run_and_check_output.py b/packages/python/openproblems/src/openproblems/project/component_tests/run_and_check_output.py index 227d7c9..68b8f76 100644 --- a/packages/python/openproblems/src/openproblems/project/component_tests/run_and_check_output.py +++ b/packages/python/openproblems/src/openproblems/project/component_tests/run_and_check_output.py @@ -47,7 +47,7 @@ def check_output_files(arguments: list) -> None: def check_format(arg: dict) -> None: """Read an output file and validate its contents against the format spec.""" arg_info = arg.get("info") or {} - if arg["type"] == "file": + if arg["type"] == "file" and arg.get("value") is not None: arg_format = arg_info.get("format", {}) file_type = arg_format.get("type") or arg_info.get("file_type") diff --git a/packages/python/openproblems/tests/test_project_run_and_check_output.py b/packages/python/openproblems/tests/test_project_run_and_check_output.py new file mode 100644 index 0000000..3d058ac --- /dev/null +++ b/packages/python/openproblems/tests/test_project_run_and_check_output.py @@ -0,0 +1,46 @@ +import pytest + +from openproblems.project.component_tests.run_and_check_output import ( + check_output_files, + generate_cmd_args, + get_argument_sets, +) + + +def _arg(**kwargs): + arg = { + "name": "--output", + "clean_name": "output", + "type": "file", + "direction": "output", + "required": False, + "must_exist": True, + "multiple": False, + "multiple_sep": ";", + } + arg.update(kwargs) + return arg + + +def test_check_output_files_skips_arguments_without_a_value(): + # an optional output without a default or example never gets a value, so + # there is no file to read + arg = _arg(info={"format": {"type": "h5ad", "obs": [{"name": "label"}]}}) + check_output_files([arg]) + + +def test_check_output_files_requires_required_outputs(): + arg = _arg(required=True, info={}) + with pytest.raises(AssertionError, match="is missing a value"): + check_output_files([arg]) + + +def test_get_argument_sets_leaves_valueless_arguments_alone(): + config = { + "argument_groups": [{"name": "Arguments", "arguments": [_arg(info={})]}], + "all_arguments": [_arg(info={})], + } + argument_sets = get_argument_sets(config, "resources") + + assert "value" not in argument_sets["run"][0] + assert generate_cmd_args(argument_sets["run"]) == [] From 1db38d0a244ca8965ba67637dabf9922a43fc733 Mon Sep 17 00:00:00 2001 From: Robrecht Cannoodt Date: Thu, 13 Aug 2026 11:51:56 +0200 Subject: [PATCH 3/6] add a timeout to check_url Every component test checks the links and dois in its config, and `head()` without a timeout waits forever on an unresponsive host. A request that fails outright now reports the link as unreachable instead of letting a `ConnectionError` escape. --- .../project/component_tests/check_config.py | 15 +++++++++------ .../tests/test_project_check_config.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py b/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py index 5c9628e..f5f521f 100644 --- a/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py +++ b/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py @@ -9,12 +9,15 @@ SUMMARY_MAXLEN = 400 DESCRIPTION_MAXLEN = 5000 +# seconds to wait for a link or doi to respond +URL_TIMEOUT = 30 + TIME_LABELS = ["lowtime", "midtime", "hightime", "veryhightime"] MEM_LABELS = ["lowmem", "midmem", "highmem", "veryhighmem"] CPU_LABELS = ["lowcpu", "midcpu", "highcpu", "veryhighcpu"] -def check_url(url: str) -> bool: +def check_url(url: str, timeout: int = URL_TIMEOUT) -> bool: import requests from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter @@ -25,13 +28,13 @@ def check_url(url: str) -> bool: session.mount("http://", adapter) session.mount("https://", adapter) - get = session.head(url) - - if get.ok or get.status_code == 429: # 429 rejected, too many requests - return True - else: + try: + get = session.head(url, timeout=timeout) + except requests.exceptions.RequestException: return False + return get.ok or get.status_code == 429 # 429 rejected, too many requests + def check_references(references: Dict[str, Union[str, List[str]]]) -> None: doi = references.get("doi") diff --git a/packages/python/openproblems/tests/test_project_check_config.py b/packages/python/openproblems/tests/test_project_check_config.py index b14479c..bd11696 100644 --- a/packages/python/openproblems/tests/test_project_check_config.py +++ b/packages/python/openproblems/tests/test_project_check_config.py @@ -30,6 +30,23 @@ def test_check_config_accepts_resource_labels(): check_config(_config()) +def test_check_url_passes_a_timeout_and_survives_a_failure(): + import requests + from unittest import mock + from openproblems.project.component_tests.check_config import ( + URL_TIMEOUT, + check_url, + ) + + with mock.patch.object(requests.Session, "head") as head: + head.return_value = mock.Mock(ok=True, status_code=200) + assert check_url("https://example.com") + assert head.call_args.kwargs["timeout"] == URL_TIMEOUT + + head.side_effect = requests.exceptions.ConnectTimeout() + assert not check_url("https://example.com") + + def test_check_links_requires_the_expected_links(): from openproblems.project.component_tests.check_config import check_links From c7a5fb88ada787fe120c4ac4773867807b9f601d Mon Sep 17 00:00:00 2001 From: Robrecht Cannoodt Date: Thu, 13 Aug 2026 11:57:50 +0200 Subject: [PATCH 4/6] escape the dot in the doi regex `^10.\d{4,9}/` also matched a prefix like `10X1038`. --- .../project/component_tests/check_config.py | 2 +- .../tests/test_project_check_config.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py b/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py index f5f521f..9bfbf67 100644 --- a/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py +++ b/packages/python/openproblems/src/openproblems/project/component_tests/check_config.py @@ -49,7 +49,7 @@ def check_references(references: Dict[str, Union[str, List[str]]]) -> None: doi = [doi] for d in doi: assert re.match( - r"^10.\d{4,9}/[-._;()/:A-Za-z0-9]+$", d + r"^10\.\d{4,9}/[-._;()/:A-Za-z0-9]+$", d ), f"Invalid DOI format: {doi}" assert check_url(f"https://doi.org/{d}"), f"DOI '{d}' is not reachable" diff --git a/packages/python/openproblems/tests/test_project_check_config.py b/packages/python/openproblems/tests/test_project_check_config.py index bd11696..dce2772 100644 --- a/packages/python/openproblems/tests/test_project_check_config.py +++ b/packages/python/openproblems/tests/test_project_check_config.py @@ -47,6 +47,26 @@ def test_check_url_passes_a_timeout_and_survives_a_failure(): assert not check_url("https://example.com") +def test_check_references_rejects_a_malformed_doi(): + from openproblems.project.component_tests.check_config import check_references + + with pytest.raises(AssertionError, match="Invalid DOI format"): + check_references({"doi": "10X1038/s41592-024-02189-7"}) + + +def test_check_references_accepts_a_bibtex_entry(): + from openproblems.project.component_tests.check_config import check_references + + check_references({"bibtex": "@article{key, title={A title}}"}) + + +def test_check_references_requires_a_doi_or_bibtex(): + from openproblems.project.component_tests.check_config import check_references + + with pytest.raises(AssertionError, match="should be defined"): + check_references({}) + + def test_check_links_requires_the_expected_links(): from openproblems.project.component_tests.check_config import check_links From 858c618a066198f9396378fe2233ea8edb370162 Mon Sep 17 00:00:00 2001 From: Robrecht Cannoodt Date: Thu, 13 Aug 2026 11:58:10 +0200 Subject: [PATCH 5/6] update changelog --- packages/python/openproblems/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/python/openproblems/CHANGELOG.md b/packages/python/openproblems/CHANGELOG.md index 4354b69..a18c594 100644 --- a/packages/python/openproblems/CHANGELOG.md +++ b/packages/python/openproblems/CHANGELOG.md @@ -37,6 +37,18 @@ Viash does. `os.path.join()` treats such a path as absolute and silently dropped the project root, so a config containing e.g. `__merge__: /src/api/file_dataset.yaml` failed to read. +* `check_config`: Check the required links even when a component defines no links at all. + `check_links` returned early on an empty `links`, so a method without any links passed, + while a method with only a `documentation` link was told that `.links.repository` is missing. + +* `check_config`: Give `check_url` a 30 second timeout, and report a request that fails outright + as an unreachable link rather than letting the exception escape. + +* `check_config`: Escape the dot in the DOI regex, which also matched a prefix like `10X1038`. + +* `run_and_check_output`: Skip the format validation of an output file argument that has no + value. An optional output without a default or example crashed with a `KeyError: 'value'`. + # openproblems core Python v0.1.1 ## NEW FUNCTIONALITY From 9de56df82dd63ff68fb8d15d2b313c2d907eb19f Mon Sep 17 00:00:00 2001 From: Robrecht Cannoodt Date: Thu, 13 Aug 2026 12:06:14 +0200 Subject: [PATCH 6/6] add requests to the test dependencies The new `check_url()` test needs it. `check_url()` imports it inside the function, so it stays out of the runtime dependencies of the package. --- packages/python/openproblems/pyproject.toml | 4 +++- packages/python/openproblems/tox.ini | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/python/openproblems/pyproject.toml b/packages/python/openproblems/pyproject.toml index a31f416..ef68eb7 100644 --- a/packages/python/openproblems/pyproject.toml +++ b/packages/python/openproblems/pyproject.toml @@ -33,7 +33,9 @@ dependencies = [ [project.optional-dependencies] test = [ - "pytest>=8.0" + "pytest>=8.0", + # imported inside check_url(), so not a runtime dependency of the package + "requests" ] [project.urls] diff --git a/packages/python/openproblems/tox.ini b/packages/python/openproblems/tox.ini index fb08826..cc9316f 100644 --- a/packages/python/openproblems/tox.ini +++ b/packages/python/openproblems/tox.ini @@ -8,6 +8,7 @@ description = run unit tests deps = pytest>=7 pytest-sugar + requests commands = pytest {posargs:tests}