diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 49a1f6f..7a07d3a 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - check: ['style', 'spellcheck'] + check: ['style', 'spellcheck', 'type'] steps: - uses: actions/checkout@v5 diff --git a/pyproject.toml b/pyproject.toml index 5e77976..f3a551b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ + "acres >=0.5", "attrs >=24.1", "bidsschematools >=1.1", "orjson>=3.11.3", @@ -152,3 +153,28 @@ test = [ "datalad >=1.1", "cattrs>=24.1.3", ] +types = [ + "mypy>=1.18.2", +] + +[tool.mypy] +strict = true +exclude = [ + "^tests/data", +] + +[[tool.mypy.overrides]] +module = 'bids_validator._version' +ignore_errors = true + +[[tool.mypy.overrides]] +module = 'datalad.*' +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = 'fsspec' +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = 'upath' +ignore_missing_imports = true diff --git a/src/bids_validator/__main__.py b/src/bids_validator/__main__.py index e9ffd88..befcc7c 100644 --- a/src/bids_validator/__main__.py +++ b/src/bids_validator/__main__.py @@ -12,8 +12,8 @@ from typing import Annotated from bidsschematools.schema import load_schema -from bidsschematools.types import Namespace from bidsschematools.types.context import Subject +from bidsschematools.types.namespace import Namespace from bids_validator import BIDSValidator from bids_validator.context import Context, Dataset, Sessions @@ -22,11 +22,11 @@ app = typer.Typer() -def is_subject_dir(tree): +def is_subject_dir(tree: FileTree) -> bool: return tree.name.startswith('sub-') -def walk(tree: FileTree, dataset: Dataset, subject: Subject = None) -> Iterator[Context]: +def walk(tree: FileTree, dataset: Dataset, subject: Subject | None = None) -> Iterator[Context]: """Iterate over children of a FileTree and check if they are a directory or file. If it's a directory then run again recursively, if it's a file file check the file name is @@ -52,7 +52,7 @@ def walk(tree: FileTree, dataset: Dataset, subject: Subject = None) -> Iterator[ yield Context(child, dataset, subject) -def validate(tree: FileTree, schema: Namespace): +def validate(tree: FileTree, schema: Namespace) -> None: """Check if the file path is BIDS compliant. Parameters @@ -71,14 +71,14 @@ def validate(tree: FileTree, schema: Namespace): print(f'{file.path} is not a valid bids filename') -def show_version(): +def show_version() -> None: """Show bids-validator version.""" from . import __version__ print(f'bids-validator {__version__} (Python {sys.version.split()[0]})') -def version_callback(value: bool): +def version_callback(value: bool) -> None: """Run the callback for CLI version flag. Parameters diff --git a/src/bids_validator/bids_validator.py b/src/bids_validator/bids_validator.py index b48f9ae..5497da3 100644 --- a/src/bids_validator/bids_validator.py +++ b/src/bids_validator/bids_validator.py @@ -12,6 +12,8 @@ import bidsschematools.utils import bidsschematools.validator +from .types import _typings as t + class LoggingContext: # From logging cookbook (CC0): @@ -35,20 +37,31 @@ class LoggingContext: """ - def __init__(self, logger, level=None, handler=None, close=True): + def __init__( + self, + logger: logging.Logger, + level: int | None = None, + handler: logging.Handler | None = None, + close: bool = True, + ) -> None: self.logger = logger self.level = level self.handler = handler self.close = close - def __enter__(self): + def __enter__(self) -> None: if self.level is not None: self.old_level = self.logger.level self.logger.setLevel(self.level) if self.handler: self.logger.addHandler(self.handler) - def __exit__(self, et, ev, tb): + def __exit__( + self, + et: type[BaseException] | None, + ev: BaseException | None, + tb: t.TracebackType, + ) -> None: if self.level is not None: self.logger.setLevel(self.old_level) if self.handler: @@ -66,7 +79,7 @@ class BIDSValidator: regexes = None - def __init__(self, index_associated=True): + def __init__(self, index_associated: bool = True) -> None: """Initialize BIDSValidator object. Parameters @@ -81,9 +94,9 @@ def __init__(self, index_associated=True): self.index_associated = index_associated @classmethod - def _init_regexes(cls): + def _init_regexes(cls) -> None: if cls.regexes is None: - with LoggingContext(bst.utils.get_logger(), level=logging.WARNING): + with LoggingContext(bst.utils.get_logger(), level=logging.WARNING): # type: ignore[no-untyped-call] schema = bst.schema.load_schema() all_rules = chain.from_iterable( @@ -93,7 +106,7 @@ def _init_regexes(cls): cls.regexes = [rule['regex'] for rule in all_rules] @classmethod - def parse(cls, path): + def parse(cls, path: str) -> dict[str, str]: """Parse a file path into a dictionary of BIDS entities. Parameters @@ -136,6 +149,7 @@ def parse(cls, path): """ if cls.regexes is None: cls._init_regexes() + assert cls.regexes is not None # noqa: S101 if path.startswith(os.sep): path = path.replace(os.sep, '/') @@ -155,7 +169,7 @@ def parse(cls, path): @classmethod @lru_cache - def is_bids(cls, path): + def is_bids(cls, path: str) -> bool: """Check if file path adheres to BIDS. Main method of the validator. Uses other class methods for checking @@ -199,14 +213,14 @@ def is_bids(cls, path): return False @classmethod - def is_top_level(cls, path): + def is_top_level(cls, path: str) -> bool: """Check if the file has appropriate name for a top-level file.""" parts = cls.parse(path) if not parts: return False return parts.get('subject') is None - def is_associated_data(self, path): + def is_associated_data(self, path: str) -> bool: """Check if file is appropriate associated data.""" if not self.index_associated: return False @@ -217,7 +231,7 @@ def is_associated_data(self, path): return parts.get('path') in ('code', 'derivatives', 'stimuli', 'sourcedata') @classmethod - def is_session_level(cls, path): + def is_session_level(cls, path: str) -> bool: """Check if the file has appropriate name for a session level.""" parts = cls.parse(path) if not parts: @@ -225,7 +239,7 @@ def is_session_level(cls, path): return parts.get('datatype') is None and parts.get('suffix') != 'sessions' @classmethod - def is_subject_level(cls, path): + def is_subject_level(cls, path: str) -> bool: """Check if the file has appropriate name for a subject level.""" parts = cls.parse(path) if not parts: @@ -233,7 +247,7 @@ def is_subject_level(cls, path): return parts.get('suffix') == 'sessions' @classmethod - def is_phenotypic(cls, path): + def is_phenotypic(cls, path: str) -> bool: """Check if file is phenotypic data.""" parts = cls.parse(path) if not parts: @@ -241,7 +255,7 @@ def is_phenotypic(cls, path): return parts.get('datatype') == 'phenotype' @classmethod - def is_file(cls, path): + def is_file(cls, path: str) -> bool: """Check if file is a data file or non-inherited metadata file.""" parts = cls.parse(path) if not parts: diff --git a/src/bids_validator/bidsignore.py b/src/bids_validator/bidsignore.py index 2f7cdb1..385931e 100644 --- a/src/bids_validator/bidsignore.py +++ b/src/bids_validator/bidsignore.py @@ -84,7 +84,7 @@ def from_file(cls, pathlike: os.PathLike): def match(self, relpath: str) -> bool: """Match a relative path against a collection of ignore patterns.""" - if any(compile_pat(pattern).match(relpath) for pattern in self.patterns if pattern): + if any(regex.match(relpath) for pat in self.patterns if (regex := compile_pat(pat))): self.history.append(relpath) return True return False diff --git a/src/bids_validator/context.py b/src/bids_validator/context.py index cdbc01b..43c3626 100644 --- a/src/bids_validator/context.py +++ b/src/bids_validator/context.py @@ -8,8 +8,8 @@ import attrs import orjson -from bidsschematools.types import Namespace from bidsschematools.types import context as ctx +from bidsschematools.types.namespace import Namespace from upath import UPath from .types import _typings as t @@ -47,7 +47,7 @@ class ValidationError(Exception): """TODO: Add issue structure.""" -_DATATYPE_MAP = {} +_DATATYPE_MAP: dict[str, str] = {} def datatype_to_modality(datatype: str, schema: Namespace) -> str: @@ -62,6 +62,7 @@ def datatype_to_modality(datatype: str, schema: Namespace) -> str: @cache def load_tsv(file: FileTree, *, max_rows=0) -> Namespace: """Load TSV contents into a Namespace.""" + fobj: t.Iterable[str] with file.path_obj.open() as fobj: if max_rows > 0: fobj = itertools.islice(fobj, max_rows) @@ -74,7 +75,7 @@ def load_tsv(file: FileTree, *, max_rows=0) -> Namespace: def load_tsv_gz(file: FileTree, headers: tuple[str], *, max_rows=0) -> Namespace: """Load TSVGZ contents into a Namespace.""" with file.path_obj.open('rb') as fobj: - gzobj = gzip.GzipFile(fileobj=fobj, mode='r') + gzobj: t.Iterable[bytes] = gzip.GzipFile(fileobj=fobj, mode='r') if max_rows > 0: gzobj = itertools.islice(gzobj, max_rows) contents = (line.decode().rstrip('\r\n').split('\t') for line in gzobj) @@ -82,7 +83,7 @@ def load_tsv_gz(file: FileTree, headers: tuple[str], *, max_rows=0) -> Namespace @cache -def load_json(file: FileTree) -> dict[str]: +def load_json(file: FileTree) -> dict[str, t.Any]: """Load JSON file contents.""" return orjson.loads(file.path_obj.read_bytes()) @@ -116,10 +117,10 @@ def phenotype(self) -> list[str] | None: if 'phenotype' not in self._tree.children: return None - subjects = set() - for phenotype_file in self._tree.children['phenotype'].children: + subjects: set[str] = set() + for phenotype_file in self._tree.children['phenotype'].children.values(): if phenotype_file.name.endswith('.tsv'): - subjects.update(self._get_participant_id(phenotype_file)) + subjects.update(self._get_participant_id(phenotype_file) or []) return sorted(subjects) @@ -195,13 +196,12 @@ def path(self): return self._file.relative_path -def load_file(file: FileTree, dataset: proto.Dataset) -> ctx.Context: +def load_file(file: FileTree, dataset: proto.Dataset) -> None: # -> ctx.Context: """Load a full context for a given file.""" - associations = load_associations(file, dataset) - _ = associations + # associations = load_associations(file, dataset) -def load_associations(file: FileTree, dataset: proto.Dataset) -> ctx.Associations: +def load_associations(file: FileTree, dataset: proto.Dataset) -> None: # -> ctx.Associations: """Load all associations for a given file.""" # If something fails, return None. # Uses walk back algorithm @@ -209,7 +209,7 @@ def load_associations(file: FileTree, dataset: proto.Dataset) -> ctx.Association # Stops on first success -def load_events(file: FileTree) -> ctx.Events: +def load_events(file: FileTree) -> None: # -> ctx.Events: """Load events.tsv file.""" @@ -218,10 +218,11 @@ def load_sidecar(file: FileTree) -> dict[str, t.Any]: # Uses walk back algorithm # https://bids-validator.readthedocs.io/en/latest/validation-model/inheritance-principle.html # Accumulates all sidecars - metadata = {} + metadata: dict[str, t.Any] = {} for json in walk_back(file, inherit=True): - metadata = load_json(json) | metadata + # May need to overload walk_back + metadata = load_json(json) | metadata # type: ignore[arg-type] return metadata @@ -232,7 +233,7 @@ def walk_back( target_extensions: tuple[str, ...] = ('.json',), target_suffix: str | None = None, target_entities: tuple[str, ...] = (), -) -> Generator[FileTree] | Generator[list[FileTree, ...]]: +) -> Generator[FileTree] | Generator[list[FileTree]]: """Walk up the file tree to find associated files.""" for file_group in _walk_back( source, inherit, target_extensions, target_suffix, target_entities @@ -251,7 +252,7 @@ def _walk_back( target_extensions: tuple[str, ...], target_suffix: str | None, target_entities: tuple[str, ...], -) -> Generator[list[FileTree, ...]]: +) -> Generator[list[FileTree]]: file_parts = FileParts.from_file(source) if target_suffix is None: @@ -345,7 +346,7 @@ def path(self) -> str: return self.file_parts.path @property - def entities(self) -> dict[str, str] | None: + def entities(self) -> dict[str, str | None]: """Entities parsed from the current filename.""" return self.file_parts.entities @@ -367,7 +368,9 @@ def extension(self) -> str | None: @property def modality(self) -> str | None: """Modality of current file, for examples, MRI.""" - return datatype_to_modality(self.file_parts.datatype, self.schema) + if (datatype := self.file_parts.datatype) is not None: + return datatype_to_modality(datatype, self.schema) + return None @property def size(self) -> int: @@ -380,12 +383,14 @@ def associations(self) -> ctx.Associations: return ctx.Associations() @property - def columns(self) -> None: + def columns(self) -> Namespace | None: """TSV columns, indexed by column header, values are arrays with column contents.""" if self.extension == '.tsv': return load_tsv(self.file) elif self.extension == '.tsv.gz': - return load_tsv_gz(self.file, tuple(self.sidecar.Columns)) + columns = tuple(self.sidecar.Columns) if self.sidecar else () + return load_tsv_gz(self.file, columns) + return None @property def json(self) -> Namespace | None: diff --git a/src/bids_validator/types/_typings.py b/src/bids_validator/types/_typings.py index dd42a78..359485a 100644 --- a/src/bids_validator/types/_typings.py +++ b/src/bids_validator/types/_typings.py @@ -2,18 +2,25 @@ 'Self', 'TYPE_CHECKING', 'Any', + 'Iterable', + 'TracebackType', ) TYPE_CHECKING = False if TYPE_CHECKING: + from collections.abc import Iterable + from types import TracebackType from typing import Any, Self else: def __getattr__(name: str): - if name in __all__: - import typing - - return getattr(typing, name) + match name: + case 'Iterable': + return __import__('collections.abc').Iterable + case 'TracebackType': + return __import__('types').TracebackType + case _: + return getattr(__import__('typing'), name) msg = f'Module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) diff --git a/src/bids_validator/types/files.py b/src/bids_validator/types/files.py index 40db627..969dd0a 100644 --- a/src/bids_validator/types/files.py +++ b/src/bids_validator/types/files.py @@ -24,7 +24,7 @@ class FileTree: parent: FileTree | None = attrs.field(repr=False, default=None, eq=False) children: dict[str, FileTree] = attrs.field(repr=False, factory=dict, eq=False) - def __attrs_post_init__(self): + def __attrs_post_init__(self) -> None: if self.is_dir is None: object.__setattr__(self, 'is_dir', self.path_obj.is_dir()) object.__setattr__( @@ -34,32 +34,32 @@ def __attrs_post_init__(self): ) @classmethod - def read_from_filesystem(cls, path_obj: os.PathLike) -> t.Self: + def read_from_filesystem(cls, path_obj: str | os.PathLike[str] | UPath) -> t.Self: """Read a FileTree from the filesystem.""" - path_obj = UPath(path_obj) + upath_obj = UPath(path_obj) children = {} - if is_dir := path_obj.is_dir(): + if is_dir := upath_obj.is_dir(): children = { - entry.name: FileTree.read_from_filesystem(entry) for entry in path_obj.iterdir() + entry.name: FileTree.read_from_filesystem(entry) for entry in upath_obj.iterdir() } - return cls(path_obj, is_dir=is_dir, children=children) + return cls(upath_obj, is_dir=is_dir, children=children) @property - def name(self) -> bool: + def name(self) -> str: """The name of the current FileTree node.""" return self.path_obj.name - def __contains__(self, relpath: os.PathLike) -> bool: + def __contains__(self, relpath: str | os.PathLike[str]) -> bool: parts = Path(relpath).parts if len(parts) == 0: return False - child = self.children.get(parts[0], False) - return child and (len(parts) == 1 or posixpath.join(*parts[1:]) in child) + child = self.children.get(parts[0]) + return bool(child and (len(parts) == 1 or posixpath.join(*parts[1:]) in child)) def __fspath__(self): return self.path_obj.__fspath__() - def __truediv__(self, relpath: str | os.PathLike) -> t.Self: + def __truediv__(self, relpath: str | os.PathLike) -> FileTree: parts = Path(relpath).parts child = self for part in parts: diff --git a/tests/conftest.py b/tests/conftest.py index 12fa33f..8110e7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Pytest configuration.""" -import importlib.resources import os from pathlib import Path @@ -8,17 +7,18 @@ from bidsschematools.schema import load_schema from bidsschematools.types import Namespace +from .data import load_data + @pytest.fixture(scope='session') def examples() -> Path: """Get bids-examples from submodule, allow environment variable override.""" ret = os.getenv('BIDS_EXAMPLES') if not ret: - ret = importlib.resources.files(__spec__.parent) / 'data' / 'bids-examples' - if not any(ret.iterdir()): + examples = load_data('bids-examples') + if not any(examples.iterdir()): pytest.skip('bids-examples submodule is not checked out') - else: # pragma: no cover - pass + return examples return Path(ret) @@ -27,11 +27,10 @@ def gitignore_test() -> Path: """Get bids-examples from submodule, allow environment variable override.""" ret = os.getenv('GITIGNORE_TEST_DIR') if not ret: - ret = importlib.resources.files(__spec__.parent) / 'data' / 'gitignore-test' - if not any(ret.iterdir()): + test_data = load_data('gitignore-test') + if not any(test_data.iterdir()): pytest.skip('gitignore-test submodule is not checked out') - else: # pragma: no cover - pass + return test_data return Path(ret) diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..461c978 --- /dev/null +++ b/tests/data/__init__.py @@ -0,0 +1,3 @@ +from acres import Loader + +load_data = Loader(__spec__.name) diff --git a/tests/test_bidsignore.py b/tests/test_bidsignore.py index a282cff..afe9142 100644 --- a/tests/test_bidsignore.py +++ b/tests/test_bidsignore.py @@ -1,5 +1,6 @@ """Test bids_validator.bidsignore.""" +from collections.abc import Iterator from pathlib import Path import pytest @@ -43,16 +44,17 @@ (r'\!*', ['!', '!foo'], ['foo', 'bar!']), ], ) -def test_patterns(pattern, hits, misses): +def test_patterns(pattern: str, hits: list[str], misses: list[str]) -> None: """Test expected hits and misses of ignore patterns.""" regex = compile_pat(pattern) + assert regex is not None for fname in hits: assert regex.match(fname), f'"{fname}" should match "{pattern}"' for fname in misses: assert not regex.match(fname), f'"{fname}" should not match "{pattern}"' -def test_skipped_patterns(): +def test_skipped_patterns() -> None: """Test ignore patterns that should match nothing.""" assert compile_pat('') is None assert compile_pat('# commented line') is None @@ -61,7 +63,7 @@ def test_skipped_patterns(): compile_pat('!inverted pattern') -def test_Ignore_ds000117(examples): +def test_Ignore_ds000117(examples: Path) -> None: """Test that we can load a .bidsignore file and match a file.""" ds000117 = FileTree.read_from_filesystem(examples / 'ds000117') ignore = Ignore.from_file(ds000117.children['.bidsignore']) @@ -78,7 +80,7 @@ def test_Ignore_ds000117(examples): assert ignore.match(flash_file.relative_path) -def test_filter_file_tree(examples): +def test_filter_file_tree(examples: Path) -> None: """Test file tree filtering with .bidsignore.""" ds000117 = FileTree.read_from_filesystem(examples / 'ds000117') assert '.bidsignore' in ds000117 @@ -95,7 +97,7 @@ def test_filter_file_tree(examples): assert filtered is ds000247 -def _walk(tree: FileTree): +def _walk(tree: FileTree) -> Iterator[FileTree]: for child in tree.children.values(): if child.is_dir: yield from _walk(child) @@ -103,7 +105,7 @@ def _walk(tree: FileTree): yield child -def test_gitignore_battery(gitignore_test): +def test_gitignore_battery(gitignore_test: Path) -> None: """Test our implementation against a gitignore battery.""" filetree = FileTree.read_from_filesystem(gitignore_test) ignore = Ignore.from_file(filetree.children['.gitignore']) diff --git a/tests/test_context.py b/tests/test_context.py index 85b41db..a9db9d6 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,27 +1,30 @@ import json +from collections.abc import Generator +from pathlib import Path import fsspec import pytest from bidsschematools.types.context import Subject +from bidsschematools.types.namespace import Namespace from bids_validator import context from bids_validator.types.files import FileTree @pytest.fixture -def synthetic_dataset(examples): +def synthetic_dataset(examples: Path) -> FileTree: return FileTree.read_from_filesystem(examples / 'synthetic') @pytest.fixture -def memfs(): +def memfs() -> Generator[fsspec.AbstractFileSystem, None, None]: mem = fsspec.filesystem('memory') mem.store.clear() yield mem mem.store.clear() -def test_load(synthetic_dataset, schema): +def test_load(synthetic_dataset: FileTree, schema: Namespace) -> None: ds = context.Dataset(synthetic_dataset, schema) assert ds.dataset_description.Name.startswith('Synthetic dataset') @@ -32,7 +35,12 @@ def test_load(synthetic_dataset, schema): @pytest.mark.parametrize(('depth', 'expected'), [(2, {'anat', 'beh', 'func'}), (1, set())]) -def test_find_datatypes(synthetic_dataset, schema, depth, expected): +def test_find_datatypes( + synthetic_dataset: FileTree, + schema: Namespace, + depth: int, + expected: set[str], +) -> None: datatypes = schema.objects.datatypes result = context.find_datatypes(synthetic_dataset, datatypes, max_depth=depth) @@ -40,7 +48,7 @@ def test_find_datatypes(synthetic_dataset, schema, depth, expected): assert result == expected -def test_fileparts(synthetic_dataset, schema): +def test_fileparts(synthetic_dataset: FileTree, schema: Namespace) -> None: T1w = synthetic_dataset / 'sub-01' / 'ses-01' / 'anat' / 'sub-01_ses-01_T1w.nii' parts = context.FileParts.from_file(T1w, schema) assert parts == context.FileParts( @@ -53,7 +61,7 @@ def test_fileparts(synthetic_dataset, schema): ) -def test_walkback(synthetic_dataset, schema): +def test_walkback(synthetic_dataset: FileTree) -> None: bold = ( synthetic_dataset / 'sub-01' @@ -66,7 +74,7 @@ def test_walkback(synthetic_dataset, schema): assert sidecars[0] is synthetic_dataset / 'task-nback_bold.json' -def test_context(synthetic_dataset, schema): +def test_context(synthetic_dataset: FileTree, schema: Namespace) -> None: sub01 = synthetic_dataset / 'sub-01' T1w = sub01 / 'ses-01' / 'anat' / 'sub-01_ses-01_T1w.nii' bold = sub01 / 'ses-01' / 'func' / 'sub-01_ses-01_task-nback_run-01_bold.nii' @@ -84,14 +92,18 @@ def test_context(synthetic_dataset, schema): assert T1w_context.extension == '.nii' assert T1w_context.modality == 'mri' assert T1w_context.size == 352 + assert T1w_context.subject is not None assert isinstance(T1w_context.subject.sessions, context.Sessions) assert sorted(T1w_context.subject.sessions.ses_dirs) == ['ses-01', 'ses-02'] + assert T1w_context.subject.sessions.session_id is not None assert sorted(T1w_context.subject.sessions.session_id) == ['ses-01', 'ses-02'] + assert T1w_context.sidecar is not None assert T1w_context.sidecar == {} assert T1w_context.json is None bold_context = context.Context(bold, ds, subject) + assert bold_context.sidecar is not None assert bold_context.sidecar.to_dict() == {'TaskName': 'N-Back', 'RepetitionTime': 2.5} assert bold_context.json is None @@ -104,17 +116,18 @@ def test_context(synthetic_dataset, schema): # tiff -def test_context_json(examples, schema): +def test_context_json(examples: Path, schema: Namespace) -> None: dataset = FileTree.read_from_filesystem(examples / 'qmri_vfa') file = dataset / 'sub-01' / 'anat' / 'sub-01_flip-1_VFA.json' ds = context.Dataset(dataset, schema) file_context = context.Context(file, ds, subject=None) + assert file_context.json is not None assert file_context.json.to_dict() == {'FlipAngle': 3, 'RepetitionTimeExcitation': 0.0150} -def test_sidecar_inheritance(examples): +def test_sidecar_inheritance(examples: Path) -> None: """Test to ensure inheritance principle is executed correctly""" dataset = FileTree.read_from_filesystem(examples / 'qmri_mp2rage') file = dataset / 'sub-1' / 'anat' / 'sub-1_inv-2_part-mag_MP2RAGE.nii' @@ -126,7 +139,7 @@ def test_sidecar_inheritance(examples): assert sidecar['RepetitionTimePreparation'] == 5.5 -def test_sidecar_order(memfs): +def test_sidecar_order(memfs: fsspec.AbstractFileSystem) -> None: """Test to ensure inheritance principle is skipped when inherit=False""" root_json = {'rootOverwriteA': 'root', 'rootOverwriteB': 'root', 'rootValue': 'root'} subject_json = {'rootOverwriteA': 'subject', 'subOverwrite': 'subject', 'subValue': 'subject'} @@ -153,16 +166,17 @@ def test_sidecar_order(memfs): } -def test_sessions(synthetic_dataset): +def test_sessions(synthetic_dataset: FileTree) -> None: sub01 = synthetic_dataset / 'sub-01' sessions = context.Sessions(sub01) assert sorted(sessions.ses_dirs) == ['ses-01', 'ses-02'] + assert sessions.session_id is not None assert sorted(sessions.session_id) == ['ses-01', 'ses-02'] -def test_load_tsv(synthetic_dataset): +def test_load_tsv(synthetic_dataset: FileTree) -> None: tsv_file_tree = synthetic_dataset / 'participants.tsv' tsv_file = context.load_tsv(tsv_file_tree) @@ -176,7 +190,7 @@ def test_load_tsv(synthetic_dataset): assert [tsv_file[key] == data_set[key] for key in tsv_file.keys()] -def test_load_tsv_gz(synthetic_dataset): +def test_load_tsv_gz(synthetic_dataset: FileTree) -> None: headers = ('respiratory', 'cardiac') tsvgz_file_tree = ( synthetic_dataset diff --git a/tox.ini b/tox.ini index 596c4ed..12a3b67 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ envlist = py310-min style spellcheck + type skip_missing_interpreters = true # Configuration that allows us to split tests across GitHub runners effectively @@ -27,6 +28,7 @@ DEPENDS = CHECK = style: style spellcheck: spellcheck + type: type [testenv] description = Pytest with coverage @@ -87,3 +89,13 @@ runner = uv-venv-runner commands = fix: codespell -w {posargs} !fix: codespell {posargs} + +[testenv:type] +description = Check type consistency +basepython = python3.13 +labels = check +dependency_groups = + test + types +commands = + mypy src tests diff --git a/uv.lock b/uv.lock index e92ebb4..798c407 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,7 @@ wheels = [ name = "bids-validator" source = { editable = "." } dependencies = [ + { name = "acres" }, { name = "attrs" }, { name = "bidsschematools" }, { name = "orjson" }, @@ -61,9 +62,13 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, ] +types = [ + { name = "mypy" }, +] [package.metadata] requires-dist = [ + { name = "acres", specifier = ">=0.5" }, { name = "attrs", specifier = ">=24.1" }, { name = "bidsschematools", specifier = ">=1.1" }, { name = "orjson", specifier = ">=3.11.3" }, @@ -80,6 +85,7 @@ test = [ { name = "pytest", specifier = ">=8" }, { name = "pytest-cov", specifier = ">=5" }, ] +types = [{ name = "mypy", specifier = ">=1.18.2" }] [[package]] name = "bidsschematools" @@ -748,6 +754,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "orjson" version = "3.11.3" @@ -843,6 +903,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/c6/ed8bbe16ed10a0461bafdedb54936faee4cc510b1709a6296f66421c5362/pathlib_abc-0.5.1-py3-none-any.whl", hash = "sha256:96bfbcc9828bc2d5f7d53e6c3e66314773dd6c119dad46ab6de20bb869dc6324", size = 20573, upload-time = "2025-09-12T00:31:39.168Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "patool" version = "4.0.1"