Skip to content

ENH: Machine-readable validate output with store/reload#1822

Merged
yarikoptic merged 23 commits intomasterfrom
enh-validators
Apr 5, 2026
Merged

ENH: Machine-readable validate output with store/reload#1822
yarikoptic merged 23 commits intomasterfrom
enh-validators

Conversation

@yarikoptic
Copy link
Copy Markdown
Member

@yarikoptic yarikoptic commented Mar 19, 2026

Summary

Design plan for machine-readable validate output with store/reload capability. Adds structured output formats, automatic persistence of validation results alongside log files, and the ability to reload and re-render results with different grouping/filtering options.

Key design decisions:

TODO

  • Step 0a: Refactor into dandi/validate/ subpackage (git mv committed separately from import updates)
  • Step 0b: Refactor cmd_validate.py — extract _collect_results(), _filter_results(), _render_results()
  • Step 0c: Add _record_version to ValidationResult
  • Step 1a: Add --format (-f) option: human|json|json_pp|json_lines|yaml
  • Step 1b: Add --output (-o) + auto-save _validation.jsonl companion file
  • Step 1c: Add --summary flag
  • Step 2: Add --load (multiple paths, mutually exclusive with positional args)
  • Step 3: Upload validation companion — persist results from dandi upload
  • Step 4: Extended grouping options: severity, id, validator, standard, dandiset
  • Step 5: --max-per-group truncation — cap results per leaf group with placeholder notice
  • Step 6: Cross-dataset sweep support + VisiData integration (optional)

--max-per-group feature (Step 5)

Limits how many results are shown per leaf group (or in the flat list when no grouping). Excess results are replaced by a TruncationNotice placeholder — a distinct data structure (not a ValidationResult), so it won't be confused with real results if the output is saved/reloaded.

Examples (against 147k+ validation results from bids-examples)

Flat truncation--max-per-group 5 with no grouping:

[DANDI.NO_DANDISET_FOUND] .../2d_mb_pcasl — Path is not inside a Dandiset
[BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
[BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
[BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
[BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
... and 147581 more issues

Grouped truncation-g severity --max-per-group 3:

=== ERROR (9569 issues) ===
  [DANDI.NO_DANDISET_FOUND] .../2d_mb_pcasl — Path is not inside a Dandiset
  [BIDS.NIFTI_HEADER_UNREADABLE] .../sub-1_T1w.nii.gz — We were unable to parse header data ...
  [BIDS.NIFTI_HEADER_UNREADABLE] .../sub-1_dir-AP_epi.nii.gz — We were unable to parse header data ...
  ... and 9566 more issues
=== HINT (138017 issues) ===
  [BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
  [BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
  [BIDS.JSON_KEY_RECOMMENDED] .../dataset_description.json — A JSON file is missing a key ...
  ... and 138014 more issues

and actually those are colored if output is not redirected

image

Multi-level leaf-only truncation-g severity -g id --max-per-group 2:

=== ERROR (9569 issues) ===
  === DANDI.NO_DANDISET_FOUND (107 issues) ===
    [DANDI.NO_DANDISET_FOUND] .../2d_mb_pcasl — Path is not inside a Dandiset
    [DANDI.NO_DANDISET_FOUND] .../7t_trt — Path is not inside a Dandiset
    ... and 105 more issues
  === BIDS.NIFTI_HEADER_UNREADABLE (4336 issues) ===
    [BIDS.NIFTI_HEADER_UNREADABLE] .../sub-1_T1w.nii.gz — We were unable to parse header data ...
    [BIDS.NIFTI_HEADER_UNREADABLE] .../sub-1_dir-AP_epi.nii.gz — We were unable to parse header data ...
    ... and 4334 more issues
  === BIDS.EMPTY_FILE (4954 issues) ===
    ...

Structured output-g severity -f json_pp --max-per-group 2 emits _truncated placeholders:

{
  "ERROR": [
    { "id": "DANDI.NO_DANDISET_FOUND", "severity": "ERROR", ... },
    { "id": "BIDS.NIFTI_HEADER_UNREADABLE", "severity": "ERROR", ... },
    { "_truncated": true, "omitted_count": 9567 }
  ],
  "HINT": [
    { "id": "BIDS.JSON_KEY_RECOMMENDED", "severity": "HINT", ... },
    { "id": "BIDS.JSON_KEY_RECOMMENDED", "severity": "HINT", ... },
    { "_truncated": true, "omitted_count": 138015 }
  ]
}

Headers show original counts (e.g. "9569 issues") even when only a few are displayed. The _truncated sentinel follows the _record_version naming convention for metadata fields.

Test plan

  • CLI unit tests for each --format output via click.CliRunner
  • Round-trip serialization tests for ValidationResult JSONL
  • --load with multi-file concatenation, mutual exclusivity enforcement
  • Companion auto-save creation and suppression when --output is used
  • Upload companion integration test with Docker Compose fixture -- forgot what I meant
  • Extended grouping: section headers, counts, structured output unaffected
  • --max-per-group flat truncation, grouped truncation, multi-level, JSON placeholder, no-truncation when under limit
  • Unit test for _truncate_leaves() helper

Some demos

See also

TODOs

  • apparently running validate without -o does not store the _validation.jsonl

Generated with Claude Code

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 19, 2026

Codecov Report

❌ Patch coverage is 94.15808% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.27%. Comparing base (f42d958) to head (505de8f).
⚠️ Report is 24 commits behind head on master.

Files with missing lines Patch % Lines
dandi/validate/_io.py 69.56% 7 Missing ⚠️
dandi/validate/_types.py 12.50% 7 Missing ⚠️
dandi/cli/cmd_validate.py 95.23% 6 Missing ⚠️
dandi/upload.py 25.00% 3 Missing ⚠️
dandi/cli/formatter.py 91.30% 2 Missing ⚠️
dandi/validate/__init__.py 0.00% 2 Missing ⚠️
dandi/bids_validator_deno/_validator.py 0.00% 1 Missing ⚠️
dandi/cli/tests/test_cmd_validate.py 99.67% 1 Missing ⚠️
dandi/files/bases.py 0.00% 1 Missing ⚠️
dandi/files/bids.py 50.00% 1 Missing ⚠️
... and 3 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1822      +/-   ##
==========================================
+ Coverage   75.11%   76.27%   +1.16%     
==========================================
  Files          85       87       +2     
  Lines       11932    12482     +550     
==========================================
+ Hits         8963     9521     +558     
+ Misses       2969     2961       -8     
Flag Coverage Δ
unittests 76.27% <94.15%> (+1.16%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.


# First produce a JSONL to load
outfile = tmp_path / "input.jsonl"
r = CliRunner().invoke(

Check warning

Code scanning / CodeQL

Variable defined multiple times Warning test

This assignment to 'r' is unnecessary as it is
redefined
before this value is used.

Copilot Autofix

AI 3 days ago

In general, when a variable is assigned multiple times and the first assigned value is never read before being overwritten, the earlier assignment should be removed or changed so that the variable is not captured unnecessarily. This keeps the code clear and avoids confusion about which value is actually used.

Here, the best fix is to keep the first CliRunner().invoke(...) call (because we need to produce the JSONL file), but stop assigning its return value to r. The later assignment on line 884 is the only one whose value is actually used (checked via r.exit_code), so we leave that as is. Concretely, in dandi/cli/tests/test_cmd_validate.py, in test_validate_auto_companion_skipped_with_load, change line 874 from r = CliRunner().invoke(...) to just CliRunner().invoke(...). No imports, helper methods, or additional definitions are needed.

Suggested changeset 1
dandi/cli/tests/test_cmd_validate.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/dandi/cli/tests/test_cmd_validate.py b/dandi/cli/tests/test_cmd_validate.py
--- a/dandi/cli/tests/test_cmd_validate.py
+++ b/dandi/cli/tests/test_cmd_validate.py
@@ -871,7 +871,7 @@
     """--load suppresses auto-save companion."""
     # First produce a JSONL to load
     outfile = tmp_path / "input.jsonl"
-    r = CliRunner().invoke(
+    CliRunner().invoke(
         main, ["validate", "-f", "json_lines", "-o", str(outfile), str(simple2_nwb)]
     )
     assert outfile.exists()
EOF
@@ -871,7 +871,7 @@
"""--load suppresses auto-save companion."""
# First produce a JSONL to load
outfile = tmp_path / "input.jsonl"
r = CliRunner().invoke(
CliRunner().invoke(
main, ["validate", "-f", "json_lines", "-o", str(outfile), str(simple2_nwb)]
)
assert outfile.exists()
Copilot is powered by AI and may make mistakes. Always verify output.
"""
# Avoid heavy imports by importing with function:
from ..upload import upload
from ..upload import upload as upload_
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is better of course than clobbering the namespace, but in the long term a better name that distinguishes API vs CLI functions (with CLI usually marked private since it is odd to expose them to library) is still preferred

Which would be a breaking change, not saying do it here, just thinking out loud

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah -- typically we just clobbered click's interfaces (sorry didn't interleave... but at least manually pruned some unrelated)

❯ grep -l 'from \.\.\(.*\) import \1' dandi/cli/cmd_* | xargs grep '^def '
dandi/cli/cmd_delete.py:def delete(paths, skip_missing, dandi_instance, force, devel_debug=False):
dandi/cli/cmd_download.py:def download(
dandi/cli/cmd_move.py:def move(
dandi/cli/cmd_organize.py:def organize(
dandi/cli/cmd_upload.py:def upload(
dandi/cli/cmd_validate.py:def validate_bids(
dandi/cli/cmd_validate.py:def validate(
❯ grep 'from \.\.\(.*\) import \1' dandi/cli/cmd_*
dandi/cli/cmd_delete.py:    from ..delete import delete
dandi/cli/cmd_download.py:    from .. import download
dandi/cli/cmd_move.py:    from .. import move as move_mod
dandi/cli/cmd_organize.py:    from ..organize import organize
dandi/cli/cmd_upload.py:    from ..upload import upload
dandi/cli/cmd_validate.py:from ..validate import validate as validate_

but frankly and unfortunately here it doesn't matter much since those click functions are not usable as python interfaces! I wish it was otherwise. So, no point of giving them any special names really.

Copy link
Copy Markdown
Contributor

@CodyCBakerPhD CodyCBakerPhD Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but frankly and unfortunately here it doesn't matter much since those click functions are not usable as python interfaces!

How do you mean? Any other library can import them and manipulate the click groups; sometimes that might even be intentional, but I don't think so here

from dandi.cli.command import main
from dandi.cli.cmd_upload import upload  # Implying it is not private and intended to be imported and customized

import click

@main.command("upload2")  # Or even re-register under the same name if attempting some nasty injection
@click.pass_context
def wrapped_original(ctx):
    click.echo("Before original")  # Inject custom code here
    ctx.invoke(upload)
    click.echo("After original")  # Inject more custom code here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and what can I now invoke as a python function (with all the options cli has)? I have tried but failed -- a complete demo would be aprecitated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You would have to use .main(..., standalone_mode=False) on the method or .invoke(...) on the function to directly call it like that, which is awkward

I did not mean to imply that anyone would actually want to do it that way, only that because they have no specific names or any marked naming (such as _cli_upload) indicating intent (besides their submodule, which doesn't actually mean it is the CLI method since the same submodule could also house the API helper it wraps)

Usually, ending in underscore is a convention to avoid clobbering built-ins or other definitions with nearly the same meaning (such as for input kwargs), not the same as marking this method as 'not intended to import'

Whereas the only reason to expose for import is the full example I posted above showing CLI manipulation

Comment on lines -114 to -120
if not paths:
paths = (os.curdir,)
# below we are using load_namespaces but it causes HDMF to whine if there
# is no cached name spaces in the file. It is benign but not really useful
# at this point, so we ignore it although ideally there should be a formal
# way to get relevant warnings (not errors) from PyNWB
ignore_benign_pynwb_warnings()
Copy link
Copy Markdown
Contributor

@CodyCBakerPhD CodyCBakerPhD Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, pointing out that if this PR had been broken into smaller modular PRs, the negative breaks of this changelog may have aligned better with the relevant changes to catch any relevant drops or alterations from current behavior

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>
yarikoptic and others added 7 commits April 2, 2026 17:00
Limit how many results are shown per leaf group (or in the flat list
when no grouping is used).  Excess results are replaced by a
TruncationNotice placeholder — a distinct dataclass (not a
ValidationResult) so consumers can isinstance() check.

- TruncationNotice dataclass + LeafItem/TruncatedResults type aliases
- _truncate_leaves() walks the grouped tree, caps leaf lists
- Human output: "... and N more issues" in cyan
- Structured output: {"_truncated": true, "omitted_count": N} sentinel
- Headers show original counts including omitted items
- Works without grouping (flat list) and with multi-level grouping

Co-Authored-By: Claude Code 2.1.63 / Claude Opus 4.6 <noreply@anthropic.com>
The _auto_save_sidecar() call was only in the structured-to-stdout
branch, so the default human format (the most common usage) never
wrote the _validation.jsonl sidecar next to the log file.

Move the sidecar write and _exit_if_errors() into a shared path that
runs after all rendering branches. The sidecar is now written whenever
there are results, unless --output or --load is active.

Also update the validate docstring/help text to document the sidecar
behavior, and update the design spec (Phase 1b, Phase 3, testing
strategy) to reflect the --validation-log CLI option for upload and
proper CLI integration testing via CliRunner through main().

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
- Rename "human" output format to "text" throughout cmd_validate and tests
  (Click option, default values, function names _render_human → _render_text,
  _render_human_grouped → _render_text_grouped, test names, docstrings)
- Add field docstring to TruncationNotice.omitted_count
- Fix CodeQL warning: remove unused `r =` assignment in test_validate_load
- Use match statements in _get_formatter and _group_key
- Simplify cmd_upload sidecar path derivation to conditional expression
- Merge implicit string concatenation in validate/io.py warning

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
…ave companion unfiltered in cmd_validate

In BIDS, "sidecar" specifically refers to .json files accompanying data
files. Rename all internal references to the _validation.jsonl file from
"sidecar" to "companion" to avoid confusion:

- validation_sidecar_path() → validation_companion_path()
- _auto_save_sidecar() → _auto_save_companion()
- Variable names, docstrings, comments, and spec prose

The only remaining "sidecar" reference is in validate/types.py where it
correctly describes BIDS sidecar JSON files.

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6
<noreply@anthropic.com>
Extend grouping test coverage from only severity to all grouping values
and composite (multi-level) grouping specs:

- Parametrize text and JSON CLI tests with 8 specs each: 5 single
  values (severity, id, validator, standard, dandiset) + 3 composite
  (severity+id, validator+severity, id+validator)
- Parametrize --load and --output tests with single and composite specs
- Add _grouping_opts() helper to compose -g args, reused across tests
- Assert known issue ID (DANDI.NO_DANDISET_FOUND) in output
- Assert nested indentation for composite groupings in text format
- Assert nested dict structure for composite groupings in JSON format

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
Eliminate duplication between write_validation_jsonl and
append_validation_jsonl by adding a keyword-only `append` parameter
to write_validation_jsonl. The two functions had identical bodies
differing only in the file open mode ("w" vs "a").

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
yarikoptic added a commit that referenced this pull request Apr 2, 2026
Refactoring of codebase into dandi/validate/ subpackage for larger #1822
yarikoptic and others added 4 commits April 2, 2026 23:12
Pure git mv to preserve rename tracking. Import updates follow in
the next commit.

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
- Update all import paths to use _io, _core, _types across 16 files
- Add io functions to __init__.py re-exports and __all__
- Change load_validation_jsonl from variadic *paths to Iterable[paths]
- Move record_version check from io loader into
  ValidationResult.model_post_init (fires regardless of load method)

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
Move write_validation_jsonl import in upload.py and
load_validation_jsonl import in cmd_validate.py to module level.
These had no circular import justification (unlike the validate_bids
import in files/bids.py which must stay lazy).

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
Reuse the shared _GROUPING_SPECS list (with composite groupings) in
test_validate_grouping_json_cli, replacing the separate hardcoded list.

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
@yarikoptic yarikoptic requested a review from CodyCBakerPhD April 4, 2026 00:06
Add TextFormatter to formatter.py following the same Formatter protocol
as JSONFormatter, JSONLinesFormatter, YAMLFormatter. Merge _render_text
and _render_structured into a unified _render() that handles all formats
through a common path. The three-way branch in validate() (text vs
output-file vs structured-stdout) collapses to two-way (output-file vs
stdout).

Co-Authored-By: Claude Code 2.1.81 / Claude Opus 4.6 <noreply@anthropic.com>
yarikoptic added a commit to dandi/dandi-docs that referenced this pull request Apr 4, 2026
…rsist validation logs)

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "yolo -v /home/yoh/proj/dandi/dandi-cli-enh-validators:/home/yoh/proj/dandi/dandi-cli-enh-validators:ro -- 'In /home/yoh/proj/dandi/dandi-cli-enh-validators which is submitted as dandi/dandi-cli#1822 we significantly improved validation interfacing -- we serialize validation outputs and store so we could reload and potentially review with different filtering or use external tools like visidata to navigate.  I would like here to improve our https://docs.dandiarchive.org/user-guide-sharing/validating-files/ section with improved documentation, reflecting the state of that PR. We should demonstrate that we store companion validation files during upload so they could be re-reviewed/analyzed. We should show basic use of visidata to quickly review them.  Could use bids-examples repo and some sample dandisets (should be sufficiently small) to show how e.g. to compose multiple validation files. Ideally we should script production of example outputs, and/or store/share validation output example for easier access.  Do research how other projects using mkdocs produce similar demo walkthroughs, and what we have done so far in this repo. Do research, build plan for content and also implementation details.'",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
yarikoptic added a commit to dandi/dandi-docs that referenced this pull request Apr 4, 2026
…rsist validation logs)

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "yolo -v /home/yoh/proj/dandi/dandi-cli-enh-validators:/home/yoh/proj/dandi/dandi-cli-enh-validators:ro -- --resume",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
@yarikoptic
Copy link
Copy Markdown
Member Author

Hard to find any specific comment here now -- @CodyCBakerPhD , you wanted more docs, here is WiP as suggested (not promised ;) ):

yarikoptic added a commit to dandi/dandi-docs that referenced this pull request Apr 5, 2026
…rsist validation logs)

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "yolo -v /home/yoh/proj/dandi/dandi-cli-enh-validators:/home/yoh/proj/dandi/dandi-cli-enh-validators:ro -- 'In /home/yoh/proj/dandi/dandi-cli-enh-validators which is submitted as dandi/dandi-cli#1822 we significantly improved validation interfacing -- we serialize validation outputs and store so we could reload and potentially review with different filtering or use external tools like visidata to navigate.  I would like here to improve our https://docs.dandiarchive.org/user-guide-sharing/validating-files/ section with improved documentation, reflecting the state of that PR. We should demonstrate that we store companion validation files during upload so they could be re-reviewed/analyzed. We should show basic use of visidata to quickly review them.  Could use bids-examples repo and some sample dandisets (should be sufficiently small) to show how e.g. to compose multiple validation files. Ideally we should script production of example outputs, and/or store/share validation output example for easier access.  Do research how other projects using mkdocs produce similar demo walkthroughs, and what we have done so far in this repo. Do research, build plan for content and also implementation details.'",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
yarikoptic added a commit to dandi/dandi-docs that referenced this pull request Apr 5, 2026
…rsist validation logs)

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "yolo -v /home/yoh/proj/dandi/dandi-cli-enh-validators:/home/yoh/proj/dandi/dandi-cli-enh-validators:ro -- --resume",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
@CodyCBakerPhD
Copy link
Copy Markdown
Contributor

At this point impractical to do deeper dive without wasting time

'Seems safe enough' as a new feature

Hopefully doesn't break anything in practice (e.g., trigger new errors during dandi validate or dandi upload)

Some concerns about docs falling out of date, would be easier IMO to have such tests of output here but can delegate to followup

@yarikoptic
Copy link
Copy Markdown
Member Author

ok, let's proceed then.

@yarikoptic yarikoptic merged commit fa33e8d into master Apr 5, 2026
38 checks passed
@yarikoptic yarikoptic deleted the enh-validators branch April 5, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cmd-upload cmd-validate enhancement New feature or request minor Increment the minor version when merged UX

Projects

None yet

4 participants