diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6fe6dcb7..4c8da0fe7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: include: - python-version: "3.12" is-dev-version: true - run_expensive_tests: true + run-expensive-tests: true steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -53,7 +53,7 @@ jobs: - run: pytest env: BIOIMAGEIO_CACHE_PATH: bioimageio_cache - SKIP_EXPENSIVE_TESTS: ${{ matrix.run_expensive_tests && 'false' || 'true' }} + RUN_EXPENSIVE_TESTS: ${{ matrix.run-expensive-tests && 'true' || 'false' }} - uses: actions/cache/save@v4 # explicit restore/save instead of cache action to cache even if coverage fails with: diff --git a/README.md b/README.md index c8ed40eb5..fdacc9194 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,10 @@ To keep the bioimageio.spec Python package version in sync with the (model) desc ### bioimageio.spec Python package +#### bioimageio.spec 0.5.3.6 + +* fix URL validation (checking with actual http requests was erroneously skipped) + #### bioimageio.spec 0.5.3.5 * fix loading tifffile in python 3.8 (pin tifffile) diff --git a/bioimageio/spec/VERSION b/bioimageio/spec/VERSION index aa7631d22..01f946340 100644 --- a/bioimageio/spec/VERSION +++ b/bioimageio/spec/VERSION @@ -1,3 +1,3 @@ { - "version": "0.5.3.5" + "version": "0.5.3.6" } diff --git a/bioimageio/spec/_internal/common_nodes.py b/bioimageio/spec/_internal/common_nodes.py index 4a2588597..09d1a616d 100644 --- a/bioimageio/spec/_internal/common_nodes.py +++ b/bioimageio/spec/_internal/common_nodes.py @@ -60,6 +60,7 @@ from .io_utils import write_content_to_zip from .node import Node from .packaging_context import PackagingContext +from .root_url import RootHttpUrl from .url import HttpUrl from .utils import ( assert_all_params_set_explicitly, @@ -343,12 +344,12 @@ def validation_summary(self) -> ValidationSummary: assert self._validation_summary is not None, "access only after initialization" return self._validation_summary - _root: Union[HttpUrl, DirectoryPath] = PrivateAttr( + _root: Union[RootHttpUrl, DirectoryPath, ZipPath] = PrivateAttr( default_factory=lambda: validation_context_var.get().root ) @property - def root(self) -> Union[HttpUrl, DirectoryPath]: + def root(self) -> Union[RootHttpUrl, DirectoryPath, ZipPath]: return self._root @classmethod diff --git a/bioimageio/spec/_internal/type_guards.py b/bioimageio/spec/_internal/type_guards.py index 8a0c6a28e..7296a70c7 100644 --- a/bioimageio/spec/_internal/type_guards.py +++ b/bioimageio/spec/_internal/type_guards.py @@ -1,6 +1,8 @@ import collections.abc -from typing import Any, Dict, Mapping, Sequence, Tuple +from typing import Any, Dict, List, Mapping, Sequence, Tuple +import numpy as np +from numpy.typing import NDArray from typing_extensions import TypeGuard @@ -25,6 +27,15 @@ def is_sequence(v: Any) -> TypeGuard[Sequence[Any]]: return isinstance(v, collections.abc.Sequence) -def is_tuple(v: Any) -> TypeGuard[Tuple[Any]]: - """to avoid Tuple[Unknown]""" +def is_tuple(v: Any) -> TypeGuard[Tuple[Any, ...]]: + """to avoid Tuple[Unknown, ...]""" return isinstance(v, tuple) + + +def is_list(v: Any) -> TypeGuard[List[Any]]: + """to avoid List[Unknown]""" + return isinstance(v, list) + + +def is_ndarray(v: Any) -> TypeGuard[NDArray[Any]]: + return isinstance(v, np.ndarray) diff --git a/bioimageio/spec/_internal/url.py b/bioimageio/spec/_internal/url.py index 603e3d5f8..de998d96a 100644 --- a/bioimageio/spec/_internal/url.py +++ b/bioimageio/spec/_internal/url.py @@ -4,7 +4,7 @@ import requests import requests.exceptions from loguru import logger -from pydantic import RootModel, model_validator +from pydantic import RootModel from typing_extensions import Literal, assert_never from .field_warning import issue_warning @@ -12,7 +12,7 @@ from .validation_context import validation_context_var -def _validate_url(url: Union[str, pydantic.HttpUrl]) -> pydantic.AnyUrl: +def _validate_url(url: Union[str, pydantic.HttpUrl]) -> pydantic.HttpUrl: return _validate_url_impl(url, request_mode="head") @@ -20,7 +20,7 @@ def _validate_url_impl( url: Union[str, pydantic.HttpUrl], request_mode: Literal["head", "get_stream", "get"], timeout: int = 3, -) -> pydantic.AnyUrl: +) -> pydantic.HttpUrl: url = str(url) val_url = url @@ -76,7 +76,9 @@ def _validate_url_impl( msg_context={"error": str(e)}, ) else: - if response.status_code == 302: # found + if response.status_code == 200: # ok + pass + elif response.status_code == 302: # found pass elif response.status_code in (301, 303, 308): issue_warning( @@ -88,17 +90,10 @@ def _validate_url_impl( "location": response.headers.get("location"), }, ) - elif response.status_code == 403: # forbidden - if request_mode == "head": - return _validate_url_impl( - url, request_mode="get_stream", timeout=timeout - ) - elif request_mode == "get_stream": - return _validate_url_impl(url, request_mode="get", timeout=timeout) - elif request_mode == "get": - raise ValueError(f"{response.status_code}: {response.reason} {url}") - else: - assert_never(request_mode) + elif request_mode == "head": + return _validate_url_impl(url, request_mode="get_stream", timeout=timeout) + elif request_mode == "get_stream": + return _validate_url_impl(url, request_mode="get", timeout=timeout) elif response.status_code == 405: issue_warning( "{status_code}: {reason} {value}", @@ -108,10 +103,15 @@ def _validate_url_impl( "reason": response.reason, }, ) - elif response.status_code != 200: + elif request_mode == "get": raise ValueError(f"{response.status_code}: {response.reason} {url}") + else: + assert_never(request_mode) - return pydantic.AnyUrl(url) + return ( # pyright: ignore[reportUnknownVariableType] + # TODO: remove pyright ignore for pydantic > 2.9 + pydantic.HttpUrl(url) # pyright: ignore[reportCallIssue] + ) class HttpUrl(RootHttpUrl): @@ -120,12 +120,14 @@ class HttpUrl(RootHttpUrl): root_model: ClassVar[Type[RootModel[Any]]] = RootModel[pydantic.HttpUrl] _exists: Optional[bool] = None - @model_validator(mode="after") - def _validate_url(self): - url = self._validated + def _after_validator(self): + self = super()._after_validator() context = validation_context_var.get() - if context.perform_io_checks and str(url) not in context.known_files: - self._validated = _validate_url(url) + if ( + context.perform_io_checks + and str(self._validated) not in context.known_files + ): + self._validated = _validate_url(self._validated) self._exists = True return self diff --git a/bioimageio/spec/_internal/validated_string.py b/bioimageio/spec/_internal/validated_string.py index 94a05264d..b195f60ad 100644 --- a/bioimageio/spec/_internal/validated_string.py +++ b/bioimageio/spec/_internal/validated_string.py @@ -6,6 +6,7 @@ CoreSchema, no_info_after_validator_function, ) +from typing_extensions import Self class ValidatedString(str): @@ -18,6 +19,10 @@ class ValidatedString(str): def __new__(cls, object: object): self = super().__new__(cls, object) self._validated = cls.root_model.model_validate(str(self)).root + return self._after_validator() + + def _after_validator(self) -> Self: + """add validation after the `root_model`""" return self @classmethod diff --git a/bioimageio/spec/_internal/validation_context.py b/bioimageio/spec/_internal/validation_context.py index 7195c0b7a..43caa2069 100644 --- a/bioimageio/spec/_internal/validation_context.py +++ b/bioimageio/spec/_internal/validation_context.py @@ -10,7 +10,7 @@ from pydantic import DirectoryPath from ._settings import settings -from .io_basics import AbsoluteDirectory, FileName, Sha256 +from .io_basics import FileName, Sha256 from .root_url import RootHttpUrl from .warning_levels import WarningLevel @@ -21,7 +21,7 @@ class ValidationContext: init=False, default_factory=list ) - root: Union[RootHttpUrl, AbsoluteDirectory, ZipFile] = Path() + root: Union[RootHttpUrl, DirectoryPath, ZipFile] = Path() """url/directory serving as base to resolve any relative file paths""" warning_level: WarningLevel = 50 diff --git a/bioimageio/spec/_io.py b/bioimageio/spec/_io.py index 4473b7d40..d5fe11287 100644 --- a/bioimageio/spec/_io.py +++ b/bioimageio/spec/_io.py @@ -32,6 +32,7 @@ def load_description( format_version: Union[Literal["discover"], Literal["latest"], str] = DISCOVER, perform_io_checks: bool = settings.perform_io_checks, known_files: Optional[Dict[str, Sha256]] = None, + sha256: Optional[Sha256] = None, ) -> Union[ResourceDescr, InvalidDescr]: """load a bioimage.io resource description @@ -45,6 +46,7 @@ def load_description( absolute file paths is still being checked. known_files: Allows to bypass download and hashing of referenced files (even if perform_io_checks is True). + sha256: Optional SHA-256 value of **source** Returns: An object holding all metadata of the bioimage.io resource @@ -55,7 +57,7 @@ def load_description( logger.warning("returning already loaded description '{}' as is", name) return source # pyright: ignore[reportReturnType] - opened = open_bioimageio_yaml(source) + opened = open_bioimageio_yaml(source, sha256=sha256) context = validation_context_var.get().replace( root=opened.original_root, @@ -78,6 +80,7 @@ def load_model_description( format_version: Union[Literal["discover"], Literal["latest"], str] = DISCOVER, perform_io_checks: bool = settings.perform_io_checks, known_files: Optional[Dict[str, Sha256]] = None, + sha256: Optional[Sha256] = None, ) -> AnyModelDescr: """same as `load_description`, but addtionally ensures that the loaded description is valid and of type 'model'. @@ -90,6 +93,7 @@ def load_model_description( format_version=format_version, perform_io_checks=perform_io_checks, known_files=known_files, + sha256=sha256, ) return ensure_description_is_model(rd) @@ -101,6 +105,7 @@ def load_dataset_description( format_version: Union[Literal["discover"], Literal["latest"], str] = DISCOVER, perform_io_checks: bool = settings.perform_io_checks, known_files: Optional[Dict[str, Sha256]] = None, + sha256: Optional[Sha256] = None, ) -> AnyDatasetDescr: """same as `load_description`, but addtionally ensures that the loaded description is valid and of type 'dataset'. @@ -110,6 +115,7 @@ def load_dataset_description( format_version=format_version, perform_io_checks=perform_io_checks, known_files=known_files, + sha256=sha256, ) return ensure_description_is_dataset(rd) @@ -140,19 +146,9 @@ def load_description_and_validate_format_only( format_version: Union[Literal["discover"], Literal["latest"], str] = DISCOVER, perform_io_checks: bool = settings.perform_io_checks, known_files: Optional[Dict[str, Sha256]] = None, + sha256: Optional[Sha256] = None, ) -> ValidationSummary: - """load a bioimage.io resource description - - Args: - source: Path or URL to an rdf.yaml or a bioimage.io package - (zip-file with rdf.yaml in it). - format_version: (optional) Use this argument to load the resource and - convert its metadata to a higher format_version. - perform_io_checks: Wether or not to perform validation that requires file io, - e.g. downloading a remote files. The existence of local - absolute file paths is still being checked. - known_files: Allows to bypass download and hashing of referenced files - (even if perform_io_checks is True). + """same as `load_description`, but only return the validation summary. Returns: Validation summary of the bioimage.io resource found at `source`. @@ -163,6 +159,7 @@ def load_description_and_validate_format_only( format_version=format_version, perform_io_checks=perform_io_checks, known_files=known_files, + sha256=sha256, ) assert rd.validation_summary is not None return rd.validation_summary diff --git a/bioimageio/spec/application/v0_3.py b/bioimageio/spec/application/v0_3.py index 5901cd157..f0f6724ac 100644 --- a/bioimageio/spec/application/v0_3.py +++ b/bioimageio/spec/application/v0_3.py @@ -14,7 +14,7 @@ from ..generic.v0_3 import CiteEntry as CiteEntry from ..generic.v0_3 import DeprecatedLicenseId as DeprecatedLicenseId from ..generic.v0_3 import Doi as Doi -from ..generic.v0_3 import GenericDescrBase, LinkedResourceNode, ResourceId +from ..generic.v0_3 import GenericDescrBase, LinkedResourceBase, ResourceId from ..generic.v0_3 import LicenseId as LicenseId from ..generic.v0_3 import LinkedResource as LinkedResource from ..generic.v0_3 import Maintainer as Maintainer @@ -47,7 +47,7 @@ class ApplicationDescr(GenericDescrBase): """The primary source of the application""" -class LinkedApplication(LinkedResourceNode): +class LinkedApplication(LinkedResourceBase): """Reference to a bioimage.io application.""" id: ApplicationId diff --git a/bioimageio/spec/common.py b/bioimageio/spec/common.py index 470d2edaf..c4100f88f 100644 --- a/bioimageio/spec/common.py +++ b/bioimageio/spec/common.py @@ -7,7 +7,13 @@ FileDescr, YamlValue, ) -from ._internal.io_basics import AbsoluteDirectory, AbsoluteFilePath, FileName, Sha256 +from ._internal.io_basics import ( + AbsoluteDirectory, + AbsoluteFilePath, + FileName, + Sha256, + ZipPath, +) from ._internal.root_url import RootHttpUrl from ._internal.types import ( FilePath, @@ -34,4 +40,5 @@ "Sha256", "ValidationError", "YamlValue", + "ZipPath", ] diff --git a/bioimageio/spec/dataset/v0_3.py b/bioimageio/spec/dataset/v0_3.py index f1ae9298d..abdc0cf3f 100644 --- a/bioimageio/spec/dataset/v0_3.py +++ b/bioimageio/spec/dataset/v0_3.py @@ -15,7 +15,7 @@ from ..generic.v0_3 import ( DocumentationSource, GenericDescrBase, - LinkedResourceNode, + LinkedResourceBase, _author_conv, # pyright: ignore[reportPrivateUsage] _maintainer_conv, # pyright: ignore[reportPrivateUsage] ) @@ -105,7 +105,7 @@ def _convert(cls, data: Dict[str, Any], /) -> Dict[str, Any]: return data -class LinkedDataset(LinkedResourceNode): +class LinkedDataset(LinkedResourceBase): """Reference to a bioimage.io dataset.""" id: DatasetId diff --git a/bioimageio/spec/generic/v0_3.py b/bioimageio/spec/generic/v0_3.py index 81b9787f6..bafe37c95 100644 --- a/bioimageio/spec/generic/v0_3.py +++ b/bioimageio/spec/generic/v0_3.py @@ -25,45 +25,55 @@ Node, ResourceDescrBase, ) -from .._internal.constants import ( - TAG_CATEGORIES, -) +from .._internal.constants import TAG_CATEGORIES from .._internal.field_validation import validate_gh_user from .._internal.field_warning import as_warning, warn from .._internal.io import ( BioimageioYamlContent, + FileDescr, V_suffix, YamlValue, include_in_package_serializer, validate_suffix, ) -from .._internal.io import FileDescr as FileDescr -from .._internal.io_basics import AbsoluteFilePath -from .._internal.io_basics import Sha256 as Sha256 -from .._internal.license_id import DeprecatedLicenseId as DeprecatedLicenseId -from .._internal.license_id import LicenseId as LicenseId -from .._internal.types import ( - ImportantFileSource, - NotEmpty, -) -from .._internal.types import RelativeFilePath as RelativeFilePath -from .._internal.url import HttpUrl as HttpUrl +from .._internal.io_basics import AbsoluteFilePath, Sha256 +from .._internal.license_id import DeprecatedLicenseId, LicenseId +from .._internal.types import ImportantFileSource, NotEmpty, RelativeFilePath +from .._internal.url import HttpUrl from .._internal.validated_string import ValidatedString from .._internal.validator_annotations import ( AfterValidator, Predicate, RestrictCharacters, ) -from .._internal.version_type import Version as Version +from .._internal.version_type import Version from .._internal.warning_levels import ALERT, INFO from ._v0_3_converter import convert_from_older_format from .v0_2 import Author as _Author_v0_2 -from .v0_2 import BadgeDescr as BadgeDescr -from .v0_2 import CoverImageSource -from .v0_2 import Doi as Doi +from .v0_2 import BadgeDescr, CoverImageSource, Doi, OrcidId, Uploader from .v0_2 import Maintainer as _Maintainer_v0_2 -from .v0_2 import OrcidId as OrcidId -from .v0_2 import Uploader as Uploader + +__all__ = [ + "Author", + "BadgeDescr", + "CiteEntry", + "DeprecatedLicenseId", + "Doi", + "FileDescr", + "GenericDescr", + "HttpUrl", + "KNOWN_SPECIFIC_RESOURCE_TYPES", + "LicenseId", + "LinkedResource", + "Maintainer", + "OrcidId", + "RelativeFilePath", + "ResourceId", + "Sha256", + "Uploader", + "VALID_COVER_IMAGE_EXTENSIONS", + "Version", +] KNOWN_SPECIFIC_RESOURCE_TYPES = ( "application", @@ -181,7 +191,24 @@ def _check_doi_or_url(self): return self -class LinkedResource(Node): +class LinkedResourceBase(Node): + + @model_validator(mode="before") + def _remove_version_number( # pyright: ignore[reportUnknownParameterType] + cls, value: Union[Any, Dict[Any, Any]] + ): + if isinstance(value, dict): + vn: Any = value.pop("version_number", None) + if vn is not None and value.get("version") is None: + value["version"] = vn + + return value # pyright: ignore[reportUnknownVariableType] + + version: Optional[Version] = None + """The version of the linked resource following SemVer 2.0.""" + + +class LinkedResource(LinkedResourceBase): """Reference to a bioimage.io resource""" id: ResourceId @@ -441,20 +468,3 @@ def check_specific_types(cls, value: str) -> str: ) return value - - -class LinkedResourceNode(Node): - - @model_validator(mode="before") - def _remove_version_number( # pyright: ignore[reportUnknownParameterType] - cls, value: Union[Any, Dict[Any, Any]] - ): - if isinstance(value, dict): - vn: Any = value.pop("version_number", None) - if vn is not None and value.get("version") is None: - value["version"] = vn - - return value # pyright: ignore[reportUnknownVariableType] - - version: Optional[Version] = None - """The version of the linked resource following SemVer 2.0.""" diff --git a/bioimageio/spec/model/v0_5.py b/bioimageio/spec/model/v0_5.py index 98e811c9f..be31c62be 100644 --- a/bioimageio/spec/model/v0_5.py +++ b/bioimageio/spec/model/v0_5.py @@ -93,7 +93,7 @@ from ..generic.v0_3 import ( DocumentationSource, GenericModelDescrBase, - LinkedResourceNode, + LinkedResourceBase, _author_conv, # pyright: ignore[reportPrivateUsage] _maintainer_conv, # pyright: ignore[reportPrivateUsage] ) @@ -2031,7 +2031,7 @@ class ModelId(ResourceId): pass -class LinkedModel(LinkedResourceNode): +class LinkedModel(LinkedResourceBase): """Reference to a bioimage.io model.""" id: ModelId diff --git a/bioimageio/spec/notebook/v0_3.py b/bioimageio/spec/notebook/v0_3.py index c5df26d81..f95f2f02d 100644 --- a/bioimageio/spec/notebook/v0_3.py +++ b/bioimageio/spec/notebook/v0_3.py @@ -10,7 +10,7 @@ from ..generic.v0_3 import CiteEntry as CiteEntry from ..generic.v0_3 import DeprecatedLicenseId as DeprecatedLicenseId from ..generic.v0_3 import Doi as Doi -from ..generic.v0_3 import GenericDescrBase, LinkedResourceNode +from ..generic.v0_3 import GenericDescrBase, LinkedResourceBase from ..generic.v0_3 import LicenseId as LicenseId from ..generic.v0_3 import LinkedResource as LinkedResource from ..generic.v0_3 import Maintainer as Maintainer @@ -42,7 +42,7 @@ class NotebookDescr(GenericDescrBase): """The Jupyter notebook""" -class LinkedNotebook(LinkedResourceNode): +class LinkedNotebook(LinkedResourceBase): """Reference to a bioimage.io notebook.""" id: NotebookId diff --git a/dev/env.yaml b/dev/env.yaml index 57caa0814..ab4c6ad33 100644 --- a/dev/env.yaml +++ b/dev/env.yaml @@ -29,6 +29,7 @@ dependencies: - python-dateutil - python=3.12 - requests + - requests-mock - rich - ruff - ruyaml diff --git a/setup.py b/setup.py index 3a1847391..b1b750af6 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ "packaging>=17.0", "pooch>=1.5,<2", "pydantic-settings>=2.5,<3", - "pydantic>=2.7.0,<2.10", + "pydantic>=2.7.0,<2.10", # TODO: check and update pin after https://github.com/pydantic/pydantic/pull/11008 is released "python-dateutil", "requests", "rich", @@ -57,6 +57,7 @@ "pytest-cov", "pytest-xdist", # parallel pytest "pytest", + "requests-mock", ] ), "dev": test_extras diff --git a/tests/test_bioimageio_collection.py b/tests/test_bioimageio_collection.py index 3089c7558..4458f76d0 100644 --- a/tests/test_bioimageio_collection.py +++ b/tests/test_bioimageio_collection.py @@ -1,13 +1,10 @@ -import json -from pathlib import Path from typing import Any, Collection, Dict, Iterable, Mapping, Tuple -import pooch # pyright: ignore [reportMissingTypeStubs] import pytest +import requests -from bioimageio.spec import settings from bioimageio.spec.common import HttpUrl, Sha256 -from tests.utils import ParameterSet, check_bioimageio_yaml, skip_expensive +from tests.utils import ParameterSet, check_bioimageio_yaml, expensive_test BASE_URL = "https://uk1s3.embassy.ebi.ac.uk/public-datasets/bioimage.io/" @@ -27,12 +24,7 @@ def _get_rdf_sources(): - all_versions_path: Any = pooch.retrieve( - BASE_URL + "all_versions.json", None, path=settings.cache_path - ) - with Path(all_versions_path).open(encoding="utf-8") as f: - entries = json.load(f)["entries"] - + entries: Any = requests.get(BASE_URL + "all_versions.json").json()["entries"] ret: Dict[str, Tuple[HttpUrl, Sha256]] = {} for entry in entries: for version in entry["versions"]: @@ -57,10 +49,10 @@ def yield_bioimageio_yaml_urls() -> Iterable[ParameterSet]: yield pytest.param(descr_url, sha, key, id=key) -@skip_expensive +@expensive_test @pytest.mark.parametrize("descr_url,sha,key", list(yield_bioimageio_yaml_urls())) def test_rdf( - descr_url: Path, + descr_url: HttpUrl, sha: Sha256, key: str, bioimageio_json_schema: Mapping[Any, Any], @@ -80,7 +72,7 @@ def test_rdf( ) -@skip_expensive +@expensive_test @pytest.mark.parametrize( "rdf_id", [ diff --git a/tests/test_internal/test_url.py b/tests/test_internal/test_url.py new file mode 100644 index 000000000..73f3027d1 --- /dev/null +++ b/tests/test_internal/test_url.py @@ -0,0 +1,111 @@ +from typing import Type + +import pytest +import requests.exceptions +from requests_mock import Mocker as RequestsMocker + +from bioimageio.spec._internal.validation_context import ValidationContext + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com", + "https://colab.research.google.com/github/bioimage-io/spec-bioimage-io/blob/main/example/load_model_and_create_your_own.ipynb", + "https://www.kaggle.com/c/data-science-bowl-2018", + ], +) +def test_httpurl_valid(url: str): + from bioimageio.spec._internal.url import HttpUrl + + with ValidationContext(perform_io_checks=True): + assert HttpUrl(url).exists(), url + + +@pytest.mark.parametrize( + "text,status_code", + [ + ("OK", 200), + ("found", 302), + ("redirected", 301), + ("redirected", 303), + ("redirected", 308), + ("let's ignore this I guess??", 405), + ], +) +def test_httpurl_mock_valid(text: str, status_code: int, requests_mock: RequestsMocker): + from bioimageio.spec._internal.url import HttpUrl + + url = "https://example.com" + _ = requests_mock.get(url, text=text, status_code=status_code) + assert HttpUrl(url).exists() + + +@pytest.mark.parametrize( + "text,status_code", + [ + ("forbidden", 403), + ("Not found", 404), + ("just wrong", 199), + ], +) +def test_httpurl_mock_invalid( + text: str, status_code: int, requests_mock: RequestsMocker +): + from bioimageio.spec._internal.url import HttpUrl + + url = "https://example.com" + _ = requests_mock.head(url, text=text, status_code=status_code) + _ = requests_mock.get(url, text=text, status_code=status_code) + with ValidationContext(perform_io_checks=True): + with pytest.raises(ValueError): + _ = HttpUrl(url) + + with ValidationContext(perform_io_checks=False): + assert not HttpUrl(url).exists() + + +@pytest.mark.parametrize( + "exc", + [ + requests.exceptions.InvalidURL, + ], +) +def test_httpurl_mock_exc(exc: Type[Exception], requests_mock: RequestsMocker): + from bioimageio.spec._internal.url import HttpUrl + + url = "https://example.com" + _ = requests_mock.head(url, exc=exc) + with ValidationContext(perform_io_checks=True): + with pytest.raises(ValueError): + _ = HttpUrl(url) + + with ValidationContext(perform_io_checks=False): + assert not HttpUrl(url).exists() + + +@pytest.mark.parametrize( + "url,io_check", + [ + ("https://example.invalid", True), + ("https://example.invalid", False), + ], +) +def test_httpurl_nonexisting(url: str, io_check: bool): + from bioimageio.spec._internal.url import HttpUrl + + with ValidationContext(perform_io_checks=io_check): + assert HttpUrl(url).exists(), url + + +@pytest.mark.parametrize( + "url", + [ + "invalid-url", + ], +) +def test_httpurl_invalid(url: str): + from bioimageio.spec._internal.url import HttpUrl + + with pytest.raises(ValueError), ValidationContext(perform_io_checks=False): + _ = HttpUrl(url) diff --git a/tests/test_specific_reexports_generics.py b/tests/test_specific_reexports_generics.py index 208c4e0fb..3e6ad19fc 100644 --- a/tests/test_specific_reexports_generics.py +++ b/tests/test_specific_reexports_generics.py @@ -92,7 +92,7 @@ def get_members(m: ModuleType): "GenericDescrBase", "GenericModelDescrBase", "KNOWN_SPECIFIC_RESOURCE_TYPES", - "LinkedResourceNode", + "LinkedResourceBase", "ResourceDescrBase", "ResourceDescrType", } diff --git a/tests/utils.py b/tests/utils.py index 14fd71e85..3fd9fc24e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -46,9 +46,9 @@ unset = object() -skip_expensive = pytest.mark.skipif( - (run := os.getenv("SKIP_EXPENSIVE_TESTS")) == "true", - reason=f"Skipping expensive test (SKIP_EXPENSIVE_TESTS {run}!=true )", +expensive_test = pytest.mark.skipif( + (run := os.getenv("RUN_EXPENSIVE_TESTS")) != "true", + reason="Skipping expensive test (enable by RUN_EXPENSIVE_TESTS='true')", )