Skip to content

Commit 87c1ae3

Browse files
authored
Merge pull request #19 from ubdbra001/issue_5/add_cli
feat: Add CLI
2 parents eacb851 + 202d6df commit 87c1ae3

File tree

12 files changed

+301
-4
lines changed

12 files changed

+301
-4
lines changed

.github/workflows/build-test-deploy.yml

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
- uses: actions/checkout@v4
3737
with:
3838
fetch-depth: 0
39+
submodules: true
3940
- uses: hynek/build-and-inspect-python-package@v2
4041
with:
4142
# Use attestation only if the action is triggered inside the repo
@@ -65,6 +66,9 @@ jobs:
6566

6667
steps:
6768
- uses: actions/checkout@v4
69+
with:
70+
submodules: true
71+
6872
- name: Set git name/email
6973
run: |
7074
git config --global user.email "[email protected]"

.gitmodules

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "tests/data/bids-examples"]
2+
path = tests/data/bids-examples
3+
url = https://github.com/bids-standard/bids-examples

pyproject.toml

+11-1
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,17 @@ test = [
3232
"coverage[toml] >=7.2",
3333
"datalad >=1.1",
3434
]
35+
cli = [
36+
"typer >=0.15",
37+
]
3538

3639
[project.urls]
3740
Homepage = "https://github.com/bids-standard/python-validator"
3841

42+
[project.scripts]
43+
bids-validator = "bids_validator.__main__:app"
44+
bids-validator-python = "bids_validator.__main__:app"
45+
3946
[tool.setuptools]
4047
package-dir = {"" = "src"}
4148

@@ -55,12 +62,14 @@ tag_prefix = ""
5562
parentdir_prefix = ""
5663

5764
[tool.pytest.ini_options]
65+
norecursedirs = ["data"]
5866
doctest_optionflags = ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"]
5967

68+
6069
[tool.coverage.run]
6170
branch = true
6271
parallel = true
63-
source = ["bids_validator"]
72+
source = ["bids_validator", "tests"]
6473
omit = [
6574
"setup.py",
6675
"*/_version.py",
@@ -77,6 +86,7 @@ exclude_lines = [
7786
"no cov",
7887
"if __name__ == .__main__.:",
7988
"if TYPE_CHECKING:",
89+
"pytest.skip",
8090
]
8191

8292
# Disable black

src/bids_validator/__main__.py

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# ruff: noqa: D100
2+
# ruff: noqa: D103
3+
4+
try:
5+
import typer
6+
except ImportError:
7+
print('⚠️ CLI dependencies are not installed. Install "bids_validator[cli]"')
8+
raise SystemExit(1) from None
9+
10+
import sys
11+
from typing import Annotated
12+
13+
from bids_validator import BIDSValidator
14+
from bids_validator.types.files import FileTree
15+
16+
app = typer.Typer()
17+
18+
19+
def walk(tree: FileTree):
20+
"""Iterate over children of a FileTree and check if they are a directory or file.
21+
22+
If it's a directory then run again recursively, if it's a file file check the file name is
23+
BIDS compliant.
24+
25+
Parameters
26+
----------
27+
tree : FileTree
28+
FileTree object to iterate over
29+
30+
"""
31+
for child in tree.children.values():
32+
if child.is_dir:
33+
yield from walk(child)
34+
else:
35+
yield child
36+
37+
38+
def validate(tree: FileTree):
39+
"""Check if the file path is BIDS compliant.
40+
41+
Parameters
42+
----------
43+
tree : FileTree
44+
Full FileTree object to iterate over and check
45+
46+
"""
47+
validator = BIDSValidator()
48+
49+
for file in walk(tree):
50+
# The output of the FileTree.relative_path method always drops the initial for the path
51+
# which makes it fail the validator.is_bids check. THis may be a Windows specific thing.
52+
# This line adds it back.
53+
path = f'/{file.relative_path}'
54+
55+
if not validator.is_bids(path):
56+
print(f'{path} is not a valid bids filename')
57+
58+
59+
def show_version():
60+
"""Show bids-validator version."""
61+
from . import __version__
62+
63+
print(f'bids-validator {__version__} (Python {sys.version.split()[0]})')
64+
65+
66+
def version_callback(value: bool):
67+
"""Run the callback for CLI version flag.
68+
69+
Parameters
70+
----------
71+
value : bool
72+
value received from --version flag
73+
74+
Raises
75+
------
76+
typer.Exit
77+
Exit without any errors
78+
79+
"""
80+
if value:
81+
show_version()
82+
raise typer.Exit()
83+
84+
85+
@app.command()
86+
def main(
87+
bids_path: str,
88+
verbose: Annotated[bool, typer.Option('--verbose', '-v', help='Show verbose output')] = False,
89+
version: Annotated[
90+
bool,
91+
typer.Option(
92+
'--version',
93+
help='Show version',
94+
callback=version_callback,
95+
is_eager=True,
96+
),
97+
] = False,
98+
) -> None:
99+
if verbose:
100+
show_version()
101+
102+
root_path = FileTree.read_from_filesystem(bids_path)
103+
104+
validate(root_path)
105+
106+
107+
if __name__ == '__main__':
108+
app()

src/bids_validator/types/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Modules for providing types."""

src/bids_validator/types/files.py

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Types for working with file trees."""
2+
3+
import os
4+
import posixpath
5+
import stat
6+
from functools import cached_property
7+
from pathlib import Path
8+
from typing import Union
9+
10+
import attrs
11+
from typing_extensions import Self # PY310
12+
13+
__all__ = ('FileTree',)
14+
15+
16+
@attrs.define
17+
class UserDirEntry:
18+
"""Partial reimplementation of :class:`os.DirEntry`.
19+
20+
:class:`os.DirEntry` can't be instantiated from Python, but this can.
21+
"""
22+
23+
path: str = attrs.field(repr=False, converter=os.fspath)
24+
name: str = attrs.field(init=False)
25+
_stat: os.stat_result = attrs.field(init=False, repr=False, default=None)
26+
_lstat: os.stat_result = attrs.field(init=False, repr=False, default=None)
27+
28+
def __attrs_post_init__(self) -> None:
29+
self.name = os.path.basename(self.path)
30+
31+
def __fspath__(self) -> str:
32+
return self.path
33+
34+
def stat(self, *, follow_symlinks: bool = True) -> os.stat_result:
35+
"""Return stat_result object for the entry; cached per entry."""
36+
if follow_symlinks:
37+
if self._stat is None:
38+
self._stat = os.stat(self.path, follow_symlinks=True)
39+
return self._stat
40+
else:
41+
if self._lstat is None:
42+
self._lstat = os.stat(self.path, follow_symlinks=False)
43+
return self._lstat
44+
45+
def is_dir(self, *, follow_symlinks: bool = True) -> bool:
46+
"""Return True if the entry is a directory; cached per entry."""
47+
_stat = self.stat(follow_symlinks=follow_symlinks)
48+
return stat.S_ISDIR(_stat.st_mode)
49+
50+
def is_file(self, *, follow_symlinks: bool = True) -> bool:
51+
"""Return True if the entry is a file; cached per entry."""
52+
_stat = self.stat(follow_symlinks=follow_symlinks)
53+
return stat.S_ISREG(_stat.st_mode)
54+
55+
def is_symlink(self) -> bool:
56+
"""Return True if the entry is a symlink; cached per entry."""
57+
_stat = self.stat(follow_symlinks=False)
58+
return stat.S_ISLNK(_stat.st_mode)
59+
60+
61+
def as_direntry(obj: os.PathLike) -> Union[os.DirEntry, UserDirEntry]:
62+
"""Convert PathLike into DirEntry-like object."""
63+
if isinstance(obj, os.DirEntry):
64+
return obj
65+
return UserDirEntry(obj)
66+
67+
68+
@attrs.define
69+
class FileTree:
70+
"""Represent a FileTree with cached metadata."""
71+
72+
direntry: Union[os.DirEntry, UserDirEntry] = attrs.field(repr=False, converter=as_direntry)
73+
parent: Union['FileTree', None] = attrs.field(repr=False, default=None)
74+
is_dir: bool = attrs.field(default=False)
75+
children: dict[str, 'FileTree'] = attrs.field(repr=False, factory=dict)
76+
name: str = attrs.field(init=False)
77+
78+
def __attrs_post_init__(self):
79+
self.name = self.direntry.name
80+
self.children = {
81+
name: attrs.evolve(child, parent=self) for name, child in self.children.items()
82+
}
83+
84+
@classmethod
85+
def read_from_filesystem(
86+
cls,
87+
direntry: os.PathLike,
88+
parent: Union['FileTree', None] = None,
89+
) -> Self:
90+
"""Read a FileTree from the filesystem.
91+
92+
Uses :func:`os.scandir` to walk the directory tree.
93+
"""
94+
self = cls(direntry, parent=parent)
95+
if self.direntry.is_dir():
96+
self.is_dir = True
97+
self.children = {
98+
entry.name: FileTree.read_from_filesystem(entry, parent=self)
99+
for entry in os.scandir(self.direntry)
100+
}
101+
return self
102+
103+
def __contains__(self, relpath: os.PathLike) -> bool:
104+
parts = Path(relpath).parts
105+
if len(parts) == 0:
106+
return False
107+
child = self.children.get(parts[0], False)
108+
return child and (len(parts) == 1 or posixpath.join(*parts[1:]) in child)
109+
110+
def __fspath__(self):
111+
return self.direntry.path
112+
113+
@cached_property
114+
def relative_path(self) -> str:
115+
"""The path of the current FileTree, relative to the root.
116+
117+
Follows parents up to the root and joins with POSIX separators (/).
118+
Directories include trailing slashes for simpler matching.
119+
"""
120+
if self.parent is None:
121+
return ''
122+
123+
return posixpath.join(
124+
self.parent.relative_path,
125+
f'{self.name}/' if self.is_dir else self.name,
126+
)

tests/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""bids_validator tests."""

tests/conftest.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Pytest configuration."""
2+
3+
import importlib.resources
4+
import os
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
10+
@pytest.fixture(scope='session')
11+
def examples() -> Path:
12+
"""Get bids-examples from submodule, allow environment variable override."""
13+
ret = os.getenv('BIDS_EXAMPLES')
14+
if not ret:
15+
ret = importlib.resources.files(__spec__.parent) / 'data' / 'bids-examples'
16+
if not any(ret.iterdir()):
17+
pytest.skip('bids-examples submodule is not checked out')
18+
return Path(ret)

tests/data/bids-examples

Submodule bids-examples added at 789cc7a

tests/types/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for bids_validator.types."""

tests/types/test_files.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# ruff: noqa: D100
2+
3+
import attrs
4+
5+
from bids_validator.types.files import FileTree
6+
7+
8+
def test_FileTree(examples):
9+
"""Test the FileTree class."""
10+
ds000117 = FileTree.read_from_filesystem(examples / 'ds000117')
11+
assert 'sub-01/ses-mri/anat/sub-01_ses-mri_acq-mprage_T1w.nii.gz' in ds000117
12+
assert ds000117.children['sub-01'].parent is ds000117
13+
14+
# Verify that evolving FileTrees creates consistent structures
15+
evolved = attrs.evolve(ds000117)
16+
assert evolved.children['sub-01'].parent is not ds000117
17+
assert evolved.children['sub-01'].parent is evolved
18+
assert evolved.children['sub-01'].children['ses-mri'].parent is not ds000117.children['sub-01']
19+
assert evolved.children['sub-01'].children['ses-mri'].parent is evolved.children['sub-01']

tox.ini

+8-3
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,20 @@ pass_env =
4444
NO_COLOR
4545
CLICOLOR
4646
CLICOLOR_FORCE
47-
extras = test
47+
extras =
48+
test
49+
cli
4850
uv_resolution =
4951
min: lowest-direct
5052
deps =
5153
pre: git+https://github.com/bids-standard/bids-specification.git\#subdirectory=tools/schemacode
5254

5355
commands =
54-
python -m pytest --doctest-modules --cov . --cov-report xml --cov-report term \
55-
--junitxml=test-results.xml -v {posargs}
56+
coverage erase
57+
coverage run -p -m bids_validator tests/data/bids-examples/ds000117
58+
python -m pytest --doctest-modules --cov . --cov-append --cov-report term \
59+
--junitxml=test-results.xml {posargs}
60+
coverage xml
5661

5762
[testenv:docs]
5863
description = Build documentation site

0 commit comments

Comments
 (0)