Skip to content

Commit 1100de5

Browse files
yarikopticclaude
andcommitted
feat: add extended grouping options to dandi validate
Add severity, id, validator, standard, and dandiset as --grouping options. Uses section headers with counts (e.g. "=== ERROR (5 issues) ===") for human output. Structured output is unaffected (always flat). Co-Authored-By: Claude Code 2.1.63 / Claude Opus 4.6 <noreply@anthropic.com>
1 parent adacc06 commit 1100de5

2 files changed

Lines changed: 105 additions & 12 deletions

File tree

dandi/cli/cmd_validate.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ def validate_bids(
146146
"--grouping",
147147
"-g",
148148
help="How to group error/warning reporting.",
149-
type=click.Choice(["none", "path"], case_sensitive=False),
149+
type=click.Choice(
150+
["none", "path", "severity", "id", "validator", "standard", "dandiset"],
151+
case_sensitive=False,
152+
),
150153
default="none",
151154
)
152155
@click.option("--ignore", metavar="REGEX", help="Regex matching error IDs to ignore")
@@ -209,13 +212,14 @@ def validate(
209212
"""
210213
# Auto-detect format from output file extension when --format not given
211214
if output_file is not None and output_format == "human":
212-
output_format = _format_from_ext(output_file)
213-
if output_format is None:
215+
detected = _format_from_ext(output_file)
216+
if detected is None:
214217
raise click.UsageError(
215218
"--output requires --format to be set to a structured format "
216219
"(json, json_pp, json_lines, yaml), or use a recognized "
217220
"extension (.json, .jsonl, .yaml, .yml)."
218221
)
222+
output_format = detected
219223

220224
if load and paths:
221225
raise click.UsageError("--load and positional paths are mutually exclusive.")
@@ -326,23 +330,40 @@ def _exit_if_errors(results: list[ValidationResult]) -> None:
326330
raise SystemExit(1)
327331

328332

333+
def _group_key(issue: ValidationResult, grouping: str) -> str:
334+
"""Extract the grouping key from a ValidationResult."""
335+
if grouping == "path":
336+
return issue.purview or "(no path)"
337+
elif grouping == "severity":
338+
return issue.severity.name if issue.severity is not None else "NONE"
339+
elif grouping == "id":
340+
return issue.id
341+
elif grouping == "validator":
342+
return issue.origin.validator.value
343+
elif grouping == "standard":
344+
return issue.origin.standard.value if issue.origin.standard else "N/A"
345+
elif grouping == "dandiset":
346+
return str(issue.dandiset_path) if issue.dandiset_path else "(no dandiset)"
347+
else:
348+
raise NotImplementedError(f"Unsupported grouping: {grouping}")
349+
350+
329351
def _render_human(
330352
issues: list[ValidationResult],
331353
grouping: str,
332354
) -> None:
333355
"""Render validation results in human-readable colored format."""
334-
purviews = [i.purview for i in issues]
335356
if grouping == "none":
357+
purviews = [i.purview for i in issues]
336358
display_errors(
337359
purviews,
338360
[i.id for i in issues],
339361
cast("list[Severity]", [i.severity for i in issues]),
340362
[i.message for i in issues],
341363
)
342364
elif grouping == "path":
343-
# The purviews are the paths, if we group by path, we need to de-duplicate.
344-
# typing complains if we just take the set, though the code works otherwise.
345-
purviews = list(set(purviews))
365+
# Legacy path grouping: de-duplicate purviews, show per-path
366+
purviews = list(set(i.purview for i in issues))
346367
for purview in purviews:
347368
applies_to = [i for i in issues if purview == i.purview]
348369
display_errors(
@@ -352,10 +373,29 @@ def _render_human(
352373
[i.message for i in applies_to],
353374
)
354375
else:
355-
raise NotImplementedError(
356-
"The `grouping` parameter values currently supported are 'path' and"
357-
" 'none'."
358-
)
376+
# Generic grouped rendering with section headers
377+
from collections import OrderedDict
378+
379+
groups: OrderedDict[str, list[ValidationResult]] = OrderedDict()
380+
for issue in issues:
381+
key = _group_key(issue, grouping)
382+
groups.setdefault(key, []).append(issue)
383+
384+
for key, group_issues in groups.items():
385+
header = f"=== {key} ({pluralize(len(group_issues), 'issue')}) ==="
386+
fg = _get_severity_color(
387+
cast(
388+
"list[Severity]",
389+
[i.severity for i in group_issues if i.severity is not None],
390+
)
391+
)
392+
click.secho(header, fg=fg, bold=True)
393+
for issue in group_issues:
394+
msg = f" [{issue.id}] {issue.purview}{issue.message}"
395+
ifg = _get_severity_color(
396+
[issue.severity] if issue.severity is not None else []
397+
)
398+
click.secho(msg, fg=ifg)
359399

360400
if not any(r.severity is not None and r.severity >= Severity.ERROR for r in issues):
361401
click.secho("No errors found.", fg="green")

dandi/cli/tests/test_cmd_validate.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
import ruamel.yaml
77

8-
from ..cmd_validate import _process_issues, validate
8+
from ..cmd_validate import _process_issues, _render_human, validate
99
from ...tests.xfail import mark_xfail_windows_python313_posixsubprocess
1010
from ...validate.types import (
1111
Origin,
@@ -323,3 +323,56 @@ def test_validate_load_mutual_exclusivity(simple2_nwb: Path, tmp_path: Path) ->
323323
r = CliRunner().invoke(validate, ["--load", str(outfile), str(simple2_nwb)])
324324
assert r.exit_code != 0
325325
assert "mutually exclusive" in r.output
326+
327+
328+
@pytest.mark.ai_generated
329+
@pytest.mark.parametrize(
330+
"grouping",
331+
["severity", "id", "validator", "standard", "dandiset"],
332+
)
333+
def test_render_human_grouping(grouping: str, capsys: pytest.CaptureFixture) -> None:
334+
"""Test extended grouping renders section headers with counts."""
335+
origin = Origin(
336+
type=OriginType.VALIDATION,
337+
validator=Validator.nwbinspector,
338+
validator_version="",
339+
)
340+
issues = [
341+
ValidationResult(
342+
id="NWBI.check_data_orientation",
343+
origin=origin,
344+
scope=Scope.FILE,
345+
message="Data may be in the wrong orientation.",
346+
path=Path("sub-01/sub-01.nwb"),
347+
severity=Severity.WARNING,
348+
dandiset_path=Path("/data/ds001"),
349+
),
350+
ValidationResult(
351+
id="NWBI.check_missing_unit",
352+
origin=origin,
353+
scope=Scope.FILE,
354+
message="Missing text for attribute 'unit'.",
355+
path=Path("sub-02/sub-02.nwb"),
356+
severity=Severity.WARNING,
357+
dandiset_path=Path("/data/ds001"),
358+
),
359+
]
360+
_render_human(issues, grouping=grouping)
361+
captured = capsys.readouterr().out
362+
363+
# Section headers with "===" must appear
364+
assert "===" in captured
365+
# Both issues should be rendered
366+
assert "NWBI.check_data_orientation" in captured
367+
assert "NWBI.check_missing_unit" in captured
368+
# Issue counts in headers
369+
assert "issue" in captured
370+
371+
372+
@pytest.mark.ai_generated
373+
def test_validate_grouping_severity_cli(simple2_nwb: Path) -> None:
374+
"""Test --grouping=severity via CLI."""
375+
r = CliRunner().invoke(validate, ["--grouping=severity", str(simple2_nwb)])
376+
assert r.exit_code != 0
377+
assert "===" in r.output
378+
assert "ERROR" in r.output

0 commit comments

Comments
 (0)