Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/example_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Runnable example: validate a dataset, or see each filename code demonstrated.

Two modes:

* No argument: build a small dataset in a temporary directory that contains one
deliberately broken file name per filename code, run only the name checks (of files
and folders) on it, and print the findings.
* With a path: run every check on your own dataset.

Usage
-----
python docs/example_validation.py # generated filename demo
python docs/example_validation.py /path/to/dataset # every check, your own data
"""

from __future__ import annotations

import json
import sys
import tempfile
from pathlib import Path

from bidsschematools.schema import load_schema

from bids_validator.checks import collect_issues
from bids_validator.checks.filenames import filename_issues, folder_issues
from bids_validator.issues import DatasetIssues, Severity
from bids_validator.types.files import FileTree

# Correctly named files. None of these produce a filename finding.
VALID_FILES = (
'README',
'sub-01/anat/sub-01_T1w.nii.gz',
'sub-01/func/sub-01_task-rest_bold.nii.gz',
)

# One broken file per filename code, with the code each one is expected to raise.
BROKEN_FILES = {
'sub-01/notes.txt': 'NOT_INCLUDED',
'sub-01/anat/sub-01_T1w.txt': 'EXTENSION_MISMATCH',
'sub-01/func/sub-01_bold.nii.gz': 'MISSING_REQUIRED_ENTITY',
'sub-01/anat/sub-01_acq-_T1w.nii.gz': 'ENTITY_WITH_NO_LABEL',
'sub-01/anat/sub-01_acq-a!b_T1w.nii.gz': 'INVALID_ENTITY_LABEL',
'sub-01/anat/sub-01_dir-AP_T1w.nii.gz': 'ENTITY_NOT_IN_RULE',
'sub-01/anat/acq-x_sub-01_T1w.nii.gz': 'FILENAME_MISMATCH',
'sub-01/func/sub-01_T1w.nii.gz': 'DATATYPE_MISMATCH',
'sub-01/sub-01_T1w.nii.gz': 'MISSING_DATATYPE',
'sub-02/anat/sub-01_T1w.nii.gz': 'INVALID_LOCATION',
'sub-01/sub-01_channels.tsv': 'ALL_FILENAME_RULES_HAVE_ISSUES',
}


def build_dataset(root: Path) -> Path:
"""Create the example dataset under ``root``."""
(root / 'dataset_description.json').write_text(
json.dumps({'Name': 'filename example', 'BIDSVersion': '1.11.1'})
)
for relpath in (*VALID_FILES, *BROKEN_FILES):
path = root / relpath
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b'')
return root


def report(root: Path, issues: DatasetIssues) -> None:
"""Print every finding of a validation."""
errors = issues.by_severity(Severity.ERROR)
warnings = issues.by_severity(Severity.WARNING)

print(f'dataset: {root}')
print(f'{len(issues)} finding(s): {len(errors)} error(s), {len(warnings)} warning(s)\n')
for issue in issues:
print(f'[{issue.severity.value}] {issue.code}')
print(f' file : {issue.location or "(dataset)"}')
print(f' detail : {issue.message}')
if issue.rule:
print(f' rule : {issue.rule}')
print(f'\nvalid: {"no, errors found" if issues.has_errors else "yes"}')


def run_demo() -> None:
"""Build the generated example dataset and run the filename checks on it."""
with tempfile.TemporaryDirectory() as tmp:
root = build_dataset(Path(tmp))
print('Generated example: one broken file name per filename code.\n')
for relpath, code in BROKEN_FILES.items():
print(f' {code:32} {relpath}')
print()
tree = FileTree.read_from_filesystem(str(root))
names_only = collect_issues(
tree,
load_schema(),
dataset_checks=(),
folder_checks=(folder_issues,),
file_checks=(filename_issues,),
)
report(root, names_only)


def main(argv: list[str]) -> int:
"""Run the demo, or validate the dataset given as the first argument."""
if argv:
root = Path(argv[0])
report(root, collect_issues(FileTree.read_from_filesystem(str(root)), load_schema()))
else:
run_demo()
return 0


if __name__ == '__main__':
raise SystemExit(main(sys.argv[1:]))
641 changes: 641 additions & 0 deletions docs/validation.md

Large diffs are not rendered by default.

90 changes: 49 additions & 41 deletions src/bids_validator/__main__.py
Original file line number Diff line number Diff line change
@@ -1,74 +1,70 @@
# ruff: noqa: D100
# ruff: noqa: D103
# (The two lines above tell the linter not to ask for a docstring on this module, D100,
# or on its functions, D103. This file is what runs for ``bids-validator <dataset>``.)

# typer is an optional dependency (``pip install bids_validator[cli]``): the library can
# be used without it, so its absence is reported in a sentence, not a traceback.
try:
import typer
except ImportError:
print('⚠️ CLI dependencies are not installed. Install "bids_validator[cli]"')
raise SystemExit(1) from None

import sys
from collections.abc import Iterator
from typing import Annotated

from bidsschematools.schema import load_schema
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
from bids_validator.checks import collect_issues
from bids_validator.issues import DatasetIssues, Severity
from bids_validator.types.files import FileTree

# The command-line application. ``@app.command()`` below turns ``main``'s parameters into
# the command's arguments and options; ``app()`` at the bottom runs it.
app = typer.Typer()


def is_subject_dir(tree: FileTree) -> bool:
return tree.name.startswith('sub-')
def validate(tree: FileTree, schema: Namespace, verbose: bool = False) -> DatasetIssues:
"""Run every check on the dataset and report what is wrong.


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
BIDS compliant.

Parameters
----------
tree : FileTree
FileTree object to iterate over
dataset: Dataset
Object containing properties for entire dataset
subject: Subject
object containing subject and session info

"""
if subject is None and is_subject_dir(tree):
subject = Subject(Sessions(tree))

for child in tree.children.values():
if child.is_dir:
yield from walk(child, dataset, subject)
else:
yield Context(child, dataset, subject)


def validate(tree: FileTree, schema: Namespace) -> None:
"""Check if the file path is BIDS compliant.
The walk and every check come from :func:`~bids_validator.checks.collect_issues`
(reading, inheritance, names and places, the schema's rules), so the CLI and any
library caller run exactly the same checks.

Parameters
----------
tree : FileTree
Full FileTree object to iterate over and check
schema : Namespace
Schema object to validate dataset against
verbose : bool
Also print the schema rule each finding came from

Returns
-------
DatasetIssues
Every finding, so the caller can set an exit code.

"""
validator = BIDSValidator()
dataset = Dataset(tree, schema)
issues = collect_issues(tree, schema)

for file in walk(tree, dataset):
if not validator.is_bids(file.path):
print(f'{file.path} is not a valid bids filename')
for issue in issues:
print(f'{issue.severity.value}: {issue.code}: {issue.location or "(dataset)"}')
if issue.message:
print(f' {issue.message}')
if verbose and issue.rule:
print(f' rule: {issue.rule}')

errors = len(issues.by_severity(Severity.ERROR))
warnings = len(issues.by_severity(Severity.WARNING))
if issues:
print(f'\n{errors} error(s), {warnings} warning(s)')
else:
print('No problems found')

return issues


def show_version() -> None:
Expand Down Expand Up @@ -97,6 +93,11 @@ def version_callback(value: bool) -> None:
raise typer.Exit()


# ``@app.command()`` registers ``main`` as the command. typer reads its parameters:
# ``bids_path`` (no default) becomes the required argument, ``schema_path`` the option
# ``--schema-path``, and ``Annotated[bool, typer.Option(...)]`` attaches the flag names and
# help text to ``verbose`` and ``version``. ``is_eager`` runs ``--version`` before anything
# else, and its callback exits.
@app.command()
def main(
bids_path: str,
Expand All @@ -119,8 +120,15 @@ def main(

schema = load_schema(schema_path)

validate(root_path, schema)
issues = validate(root_path, schema, verbose=verbose)

# A validator is normally run from a script or CI job, so the outcome has to be
# readable from the exit code, not only from the printed text.
if issues.has_errors:
raise typer.Exit(code=1)


# True only when the file is run as a program (``python -m bids_validator``), not when it
# is imported by a test.
if __name__ == '__main__':
app()
145 changes: 145 additions & 0 deletions src/bids_validator/checks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Run every check on a dataset: one walk, every check, one list of findings.

This is the only place that says WHICH checks run and IN WHAT ORDER. Each check is a
plain function in its own module, and there are three kinds:

========================================= ===========================================
check question it answers
========================================= ===========================================
**the dataset** (``Dataset -> list``)
:func:`.dataset_integrity_issues` is there a ``dataset_description.json``?
**each folder** (``Context -> list``)
:func:`.folder_issues` is the folder's name legal?
**each file** (``Context -> list``)
:func:`.integrity_issues` can the file be read at all?
:func:`.inheritance_issues` does a deeper sidecar change a value?
:func:`.filename_issues` is the name, and the place, legal?
:func:`.rule_issues` do the schema's rules hold (checks, fields,
columns)?
========================================= ===========================================

The dataset is checked first. Then :mod:`.walk` visits every file and
folder, and each gets the checks of its kind, in the reference validator's order
(``validators/bids.ts``): problems reading the file first, then its name, then the
schema's rules. A directory recording (``sub-01_task-x_meg.ds/``) is a folder on disk
but a file to BIDS, so it gets the file checks.

A file's context is built once and shared by every check, so whatever it has loaded
(its sidecar, its header) is loaded once.

The modules of this package
---------------------------
Grouped by how a check works, one module per family of checks:

========================= =========================================================
module what it holds
========================= =========================================================
:mod:`.walk` which files and folders a validation visits
:mod:`.integrity` files that cannot be read the way their type needs
:mod:`.inheritance` a deeper sidecar changing a value set above it
:mod:`.filenames` names and places of files and folders
:mod:`.filename_rules` which of the schema's filename rules a name matches
:mod:`.rules` the rule engine: which schema rules apply to a file, and
which module checks each part of a rule
:mod:`.expressions` the ``checks`` part of a rule: expressions that must hold
:mod:`.fields` the ``fields`` part: keys of sidecars and JSON files
:mod:`.tables` the ``columns`` parts: the columns of TSV tables
:mod:`.values` one value against its schema definition (for fields and
tables)
:mod:`.schema_cache` reading something out of the schema once per schema
========================= =========================================================

What they build on lives outside the package: :mod:`bids_validator.context` (what is
known about one file), :mod:`bids_validator.expression_language` (evaluating the
schema's expressions) and :mod:`bids_validator.issues` (what a finding is).
"""

# Lets type hints name classes that are only imported for type checkers (below).
from __future__ import annotations

from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING

# Each import below is one check, from the module that owns it.
from ..context import Dataset
from ..issues import DatasetIssues, Issue
from .filenames import filename_issues, folder_issues
from .inheritance import inheritance_issues
from .integrity import dataset_integrity_issues, integrity_issues
from .rules import rule_issues
from .walk import is_plain_folder, iter_contexts

# Imported only for type hints; never imported when the program runs.
if TYPE_CHECKING:
from bidsschematools.types.namespace import Namespace

from ..context import Context
from ..types.files import FileTree

# The names other modules are meant to import from this one.
__all__ = ['DATASET_CHECKS', 'FILE_CHECKS', 'FOLDER_CHECKS', 'collect_issues']

# Two type aliases: names for "the shape of a check function", used in the hints below.
# ``Callable[[Dataset], list[Issue]]`` means "a function that takes a Dataset and returns
# a list of Issue". A dataset check gets the whole dataset; a context check gets one file
# or one folder (its Context).
DatasetCheck = Callable[[Dataset], list[Issue]]
ContextCheck = Callable[['Context'], list[Issue]]

# The checks themselves, in the order they run. Each is a tuple of functions (not of
# function calls: no brackets), so ``collect_issues`` can call each one on each item.
# ``tuple[DatasetCheck, ...]`` means "a tuple of any length whose items are dataset
# checks". To add a check, write the function and add it to the right tuple.

# Run once, before the walk.
DATASET_CHECKS: tuple[DatasetCheck, ...] = (dataset_integrity_issues,)

# Run on every plain folder the walk visits.
FOLDER_CHECKS: tuple[ContextCheck, ...] = (folder_issues,)

# Run on every file (and directory recording) the walk visits, in this order.
FILE_CHECKS: tuple[ContextCheck, ...] = (
integrity_issues, # can it be read? integrity.py
inheritance_issues, # inherited values changed? inheritance.py
filename_issues, # legal name and place? filenames.py
rule_issues, # the schema's rules rules.py
)


def collect_issues(
tree: FileTree,
schema: Namespace,
*,
dataset_checks: Iterable[DatasetCheck] = DATASET_CHECKS,
folder_checks: Iterable[ContextCheck] = FOLDER_CHECKS,
file_checks: Iterable[ContextCheck] = FILE_CHECKS,
) -> DatasetIssues:
"""Validate a dataset: every check, on everything it applies to, in order.

Parameters
----------
tree : FileTree
The dataset root, from ``FileTree.read_from_filesystem(root)``.
schema : Namespace
The BIDS schema to validate against, from ``bidsschematools.schema.load_schema()``.
dataset_checks, folder_checks, file_checks : iterables of callables, optional
The checks to run; all of them by default. Pass fewer to run only some, for
example ``dataset_checks=(), folder_checks=(), file_checks=(filename_issues,)``.
(The ``*`` in the signature makes these keyword-only: they must be given by name.)

Returns
-------
DatasetIssues
The dataset's findings first, then each file's and folder's, in walk order.

"""
dataset = Dataset(tree, schema)
# Iterables can only be read once; tuples can be read for every file.
folder_checks, file_checks = tuple(folder_checks), tuple(file_checks)
issues = DatasetIssues()
for dataset_check in dataset_checks:
issues.extend(dataset_check(dataset))
for context in iter_contexts(dataset):
for check in folder_checks if is_plain_folder(context) else file_checks:
issues.extend(check(context))
return issues
Loading
Loading