Skip to content

Commit b90ddb2

Browse files
committed
feat: add --include-path option to dandi validate command
This option allows filtering of issues in the validation results to only those associated with the given path(s)
1 parent c2cfe47 commit b90ddb2

File tree

2 files changed

+135
-50
lines changed

2 files changed

+135
-50
lines changed

dandi/cli/cmd_validate.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Iterable
44
import logging
55
import os
6+
from pathlib import Path
67
import re
78
from typing import Optional, cast
89
import warnings
@@ -15,7 +16,7 @@
1516
map_to_click_exceptions,
1617
parse_regexes,
1718
)
18-
from ..utils import filter_by_id_patterns, pluralize
19+
from ..utils import filter_by_id_patterns, filter_by_paths, pluralize
1920
from ..validate import validate as validate_
2021
from ..validate_types import Severity, ValidationResult
2122

@@ -96,6 +97,15 @@ def validate_bids(
9697
),
9798
callback=parse_regexes,
9899
)
100+
@click.option(
101+
"--include-path",
102+
multiple=True,
103+
type=click.Path(exists=True, resolve_path=True, path_type=Path),
104+
help=(
105+
"Filter issues in the validation results to only those associated with the "
106+
"given path(s). This option can be specified multiple times."
107+
),
108+
)
99109
@click.option(
100110
"--min-severity",
101111
help="Only display issues with severities above this level.",
@@ -109,6 +119,7 @@ def validate(
109119
paths: tuple[str, ...],
110120
ignore: str | None,
111121
match: Optional[set[re.Pattern]],
122+
include_path: tuple[Path, ...],
112123
grouping: str,
113124
min_severity: str,
114125
schema: str | None = None,
@@ -151,14 +162,15 @@ def validate(
151162
if i.severity is not None and i.severity.value >= min_severity_value
152163
]
153164

154-
_process_issues(filtered_results, grouping, ignore, match)
165+
_process_issues(filtered_results, grouping, ignore, match, include_path)
155166

156167

157168
def _process_issues(
158169
validator_result: Iterable[ValidationResult],
159170
grouping: str,
160171
ignore: str | None = None,
161172
match: Optional[set[re.Pattern]] = None,
173+
include_path: tuple[Path, ...] = (),
162174
) -> None:
163175
issues = [i for i in validator_result if i.severity is not None]
164176
if ignore is not None:
@@ -168,6 +180,10 @@ def _process_issues(
168180
if match is not None:
169181
issues = filter_by_id_patterns(issues, match)
170182

183+
# Filter issues by included paths if provided
184+
if include_path:
185+
issues = filter_by_paths(issues, include_path)
186+
171187
purviews = [i.purview for i in issues]
172188
if grouping == "none":
173189
display_errors(

dandi/cli/tests/test_cmd_validate.py

Lines changed: 117 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -152,54 +152,54 @@ def test_validate_bids_error_grouping_notification(
152152
assert notification_substring in r.output
153153

154154

155-
class TestValidateMatchOption:
156-
"""Test the --match option for filtering validation results."""
155+
def _mock_validate(*paths, **kwargs):
156+
"""Mock validation function that returns controlled ValidationResult objects."""
157+
origin = Origin(
158+
type=OriginType.VALIDATION,
159+
validator=Validator.dandi,
160+
validator_version="test",
161+
)
157162

158-
@staticmethod
159-
def _mock_validate(*paths, **kwargs):
160-
"""Mock validation function that returns controlled ValidationResult objects."""
161-
origin = Origin(
162-
type=OriginType.VALIDATION,
163-
validator=Validator.dandi,
164-
validator_version="test",
165-
)
163+
# Return a set of validation results with different IDs
164+
results = [
165+
ValidationResult(
166+
id="BIDS.DATATYPE_MISMATCH",
167+
origin=origin,
168+
severity=Severity.ERROR,
169+
scope=Scope.FILE,
170+
message="Datatype mismatch error",
171+
path=Path(paths[0]) / "file1.nii",
172+
),
173+
ValidationResult(
174+
id="BIDS.EXTENSION_MISMATCH",
175+
origin=origin,
176+
severity=Severity.ERROR,
177+
scope=Scope.FILE,
178+
message="Extension mismatch error",
179+
path=Path(paths[0]) / "file2.jpg",
180+
),
181+
ValidationResult(
182+
id="DANDI.NO_DANDISET_FOUND",
183+
origin=origin,
184+
severity=Severity.ERROR,
185+
scope=Scope.DANDISET,
186+
message="No dandiset found",
187+
path=Path(paths[0]),
188+
),
189+
ValidationResult(
190+
id="NWBI.check_data_orientation",
191+
origin=origin,
192+
severity=Severity.WARNING,
193+
scope=Scope.FILE,
194+
message="Data orientation warning",
195+
path=Path(paths[0]) / "file3.nwb",
196+
),
197+
]
198+
return iter(results)
166199

167-
# Return a set of validation results with different IDs
168-
results = [
169-
ValidationResult(
170-
id="BIDS.DATATYPE_MISMATCH",
171-
origin=origin,
172-
severity=Severity.ERROR,
173-
scope=Scope.FILE,
174-
message="Datatype mismatch error",
175-
path=Path(paths[0]) / "file1.nii",
176-
),
177-
ValidationResult(
178-
id="BIDS.EXTENSION_MISMATCH",
179-
origin=origin,
180-
severity=Severity.ERROR,
181-
scope=Scope.FILE,
182-
message="Extension mismatch error",
183-
path=Path(paths[0]) / "file2.jpg",
184-
),
185-
ValidationResult(
186-
id="DANDI.NO_DANDISET_FOUND",
187-
origin=origin,
188-
severity=Severity.ERROR,
189-
scope=Scope.DANDISET,
190-
message="No dandiset found",
191-
path=Path(paths[0]),
192-
),
193-
ValidationResult(
194-
id="NWBI.check_data_orientation",
195-
origin=origin,
196-
severity=Severity.WARNING,
197-
scope=Scope.FILE,
198-
message="Data orientation warning",
199-
path=Path(paths[0]) / "file3.nwb",
200-
),
201-
]
202-
return iter(results)
200+
201+
class TestValidateMatchOption:
202+
"""Test the --match option for filtering validation results."""
203203

204204
@pytest.mark.parametrize(
205205
"match_patterns,parsed_patterns,should_contain,should_not_contain",
@@ -265,7 +265,7 @@ def test_match_patterns(
265265
# Use to monitor what compiled patterns are passed by the CLI
266266
process_issues_spy = mocker.spy(cmd_validate, "_process_issues")
267267

268-
monkeypatch.setattr(cmd_validate, "validate_", self._mock_validate)
268+
monkeypatch.setattr(cmd_validate, "validate_", _mock_validate)
269269

270270
r = CliRunner().invoke(validate, [f"--match={match_patterns}", str(tmp_path)])
271271

@@ -304,7 +304,7 @@ def test_match_with_ignore_combination(
304304
"""Test --match and --ignore options used together."""
305305
from .. import cmd_validate
306306

307-
monkeypatch.setattr(cmd_validate, "validate_", self._mock_validate)
307+
monkeypatch.setattr(cmd_validate, "validate_", _mock_validate)
308308

309309
# Then use both match and ignore
310310
r = CliRunner().invoke(
@@ -318,3 +318,72 @@ def test_match_with_ignore_combination(
318318

319319
assert "BIDS.DATATYPE_MISMATCH" not in r.output
320320
assert "No errors found" in r.output
321+
322+
323+
class TestValidateIncludePathOption:
324+
"""Test the --include-path option for filtering validation results."""
325+
326+
def test_nonexistent_include_path(
327+
self,
328+
tmp_path: Path,
329+
) -> None:
330+
"""Test --include-path option with a non-existent path."""
331+
332+
non_existent_path = tmp_path / "nonexistent.nwb"
333+
334+
r = CliRunner().invoke(
335+
validate,
336+
[f"--include-path={non_existent_path}", str(tmp_path)],
337+
)
338+
339+
# Should exit with an error about `--include-path`
340+
assert r.exit_code != 0
341+
assert "--include-path" in r.output
342+
343+
@pytest.mark.parametrize(
344+
"include_paths",
345+
[
346+
["path1.nwb"],
347+
["path1.nwb", "path2.nwb"],
348+
],
349+
)
350+
def test_validated_option_args(
351+
self,
352+
include_paths: list[str],
353+
tmp_path: Path,
354+
monkeypatch: pytest.MonkeyPatch,
355+
mocker: pytest_mock.MockerFixture,
356+
) -> None:
357+
"""
358+
Test that the validated arguments for --include-path correct
359+
"""
360+
from .. import cmd_validate
361+
362+
# Use to monitor what compiled patterns are passed by the CLI
363+
process_issues_spy = mocker.spy(cmd_validate, "_process_issues")
364+
365+
# We actually don't care about validation results here.
366+
# We are only mocking to avoid running actual validation logic.
367+
monkeypatch.setattr(cmd_validate, "validate_", _mock_validate)
368+
369+
paths = [tmp_path / p for p in include_paths]
370+
for p in paths:
371+
p.touch()
372+
373+
cli_args = [f"--include-path={p}" for p in paths] + [str(tmp_path)]
374+
CliRunner().invoke(
375+
validate,
376+
cli_args,
377+
)
378+
379+
process_issues_spy.assert_called_once()
380+
call_args = process_issues_spy.call_args
381+
382+
# Ensure the paths are parsed and passed correctly
383+
passed_paths = call_args.kwargs.get(
384+
"include_path",
385+
call_args.args[4] if len(call_args.args) > 4 else None,
386+
)
387+
assert len(passed_paths) == len(include_paths)
388+
assert all(isinstance(p, Path) for p in passed_paths)
389+
assert all(p.is_absolute() for p in passed_paths)

0 commit comments

Comments
 (0)