From 131e5ba1126000143a386268debcc125e2307379 Mon Sep 17 00:00:00 2001 From: Peter Van Dyken Date: Wed, 20 Dec 2023 12:35:58 -0500 Subject: [PATCH] Add tests for spec docstrings --- snakebids/paths/_config.py | 1 + snakebids/paths/_templates/spec_func.py | 21 ++++--- snakebids/paths/specs.py | 2 + snakebids/tests/test_paths/test_specs.py | 71 +++++++++++++++++++++--- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/snakebids/paths/_config.py b/snakebids/paths/_config.py index 32e93660..2d2ff0a9 100644 --- a/snakebids/paths/_config.py +++ b/snakebids/paths/_config.py @@ -54,6 +54,7 @@ def get_bids_func() -> BidsFunction: def reset_bids_spec(): + specs.__getattr__.cache_clear() # type: ignore spec = specs.latest() _config["active_spec"] = spec _config["bids_func"] = bids_factory(spec, _implicit=True) diff --git a/snakebids/paths/_templates/spec_func.py b/snakebids/paths/_templates/spec_func.py index 2ab3deb5..a3d528f1 100644 --- a/snakebids/paths/_templates/spec_func.py +++ b/snakebids/paths/_templates/spec_func.py @@ -27,7 +27,7 @@ def {spec}(subject_dir: bool = True, session_dir: bool = True) -> BidsPathSpec: """ -DEFAULT_DESCRIPTION = """Bids Spec v{version} +DEFAULT_DESCRIPTION = """Bids Spec {version} Supply this to snakebids.bids_factory to construct a corresponding bids function """ @@ -65,17 +65,22 @@ def compile_example(spec: BidsPathSpec): return _wrap_template(template[:i] + "..._" + template[i:], 80) -def format_doc(spec: BidsPathSpecFile): - try: - import docstring_parser as docstr - except ImportError: - return DOCSTRING.format( - description=DEFAULT_DESCRIPTION.format(version=spec["version"]).strip() - ) +def _import_docstring_parser(): + """Isolated import function that can be mocked in tests.""" + import docstring_parser as docstr + + return docstr + +def format_doc(spec: BidsPathSpecFile): if (description := spec.get("description")) is None: description = DEFAULT_DESCRIPTION.format(version=spec["version"]) + try: + docstr = _import_docstring_parser() + except ImportError: + return description.strip() + doc = docstr.parse(DOCSTRING.format(description=description.strip())) if doc.long_description: doc.long_description = ( diff --git a/snakebids/paths/specs.py b/snakebids/paths/specs.py index 805f187a..d8b564f9 100644 --- a/snakebids/paths/specs.py +++ b/snakebids/paths/specs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import functools as ft from typing import TYPE_CHECKING from snakebids.paths._templates import spec_func @@ -33,6 +34,7 @@ def __dir__(): LATEST = "v0_0_0" +@ft.lru_cache def __getattr__(name: str): """Allow dynamic retrieval of latest spec.""" if name == "latest": diff --git a/snakebids/tests/test_paths/test_specs.py b/snakebids/tests/test_paths/test_specs.py index 1b409466..4aa61ec2 100644 --- a/snakebids/tests/test_paths/test_specs.py +++ b/snakebids/tests/test_paths/test_specs.py @@ -1,16 +1,37 @@ +from __future__ import annotations + import warnings from pathlib import Path import pytest from pytest_mock import MockerFixture +import snakebids.paths._templates.spec_func as spec_template from snakebids.paths import bids_factory, specs from snakebids.paths._config import reset_bids_spec, set_bids_spec from snakebids.paths._presets import bids -from snakebids.paths._utils import find_entity +from snakebids.paths._utils import find_entity, get_spec_path, load_spec from snakebids.paths.specs import v0_0_0 +@pytest.fixture(autouse=True) +def _spec_reset(): # type: ignore + reset_bids_spec() + + +def test_reset_bids_spec_clears_cache(mocker: MockerFixture): + load_spec_spy = mocker.spy(specs, "load_spec") + _ = specs.v0_0_0 + load_spec_spy.assert_called_once() + load_spec_spy.reset_mock() + _ = specs.v0_0_0 + load_spec_spy.assert_not_called() + reset_bids_spec() + load_spec_spy.reset_mock() + _ = specs.v0_0_0 + load_spec_spy.assert_called_once() + + def test_all_entries_define_entity(): spec = v0_0_0() for item in spec: @@ -44,7 +65,6 @@ def test_spec_can_be_set_with_obj(): def test_using_include_subject_dir_raises_warning(): - reset_bids_spec() with pytest.warns(UserWarning, match="include_session_dir and include_subject_dir"): bids(subject="001", include_subject_dir=False) with pytest.warns(UserWarning, match="include_session_dir and include_subject_dir"): @@ -52,34 +72,71 @@ def test_using_include_subject_dir_raises_warning(): def test_include_subject_dir_can_remove_dir(): - reset_bids_spec() with warnings.catch_warnings(): warnings.simplefilter("ignore") assert len(Path(bids(subject="001", include_subject_dir=False)).parents) == 1 assert len(Path(bids(session="001", include_session_dir=False)).parents) == 1 +class TestSpecDocstrings: + def clean(self, item: str): + return " ".join(item.split()) + + def specfuncs(self): + for attr in dir(specs): + print(attr) + if attr.startswith("v") or attr == "latest": + yield getattr(specs, attr) + + def test_all_specs_have_docstrings(self): + for spec in self.specfuncs(): + assert isinstance(spec.__doc__, str) + + def test_all_specs_have_format_example(self): + for spec in self.specfuncs(): + assert "Formatted as::" in spec.__doc__ + + def test_spec_has_correct_docstring(self): + spec = load_spec(get_spec_path("v0_0_0")) + assert ( + self.clean(specs.v0_0_0.__doc__).startswith(self.clean(spec["description"])) # type: ignore + ) + + def test_spec_with_description_gets_default_docstring(self, mocker: MockerFixture): + spec = load_spec(get_spec_path("v0_0_0")) + _ = spec.pop("description", None) + mocker.patch.object(specs, "load_spec", return_value=spec) + assert self.clean(specs.v0_0_0.__doc__).startswith( # type: ignore + self.clean(spec_template.DEFAULT_DESCRIPTION.format(version="v0.0.0")) + ) + + def test_no_format_example_when_no_docstring_parser(self, mocker: MockerFixture): + mocker.patch.object( + spec_template, "_import_docstring_parser", side_effect=ImportError() + ) + # Need to reset after mocking because the reset collects the latest spec + reset_bids_spec() + for spec in self.specfuncs(): + assert "Formatted as::" not in spec.__doc__, spec + + class TestCustomEntityWarnings: def test_using_custom_entities_with_default_bids_raises_warning(self): - reset_bids_spec() with pytest.warns(UserWarning, match="spec has not been explicitly declared"): bids(foo="bar") def test_no_warning_when_spec_declared(self): - reset_bids_spec() set_bids_spec("v0_0_0") with warnings.catch_warnings(): warnings.simplefilter("error") bids(foo="bar") def test_no_warning_when_bids_explicitly_generated(self): - reset_bids_spec() with warnings.catch_warnings(): warnings.simplefilter("error") bids_factory(specs.v0_0_0())(foo="bar") def test_no_warning_in_interactive_mode(self, mocker: MockerFixture): - reset_bids_spec() mocker.patch( "snakebids.paths._factory.in_interactive_session", return_value=True )