Skip to content

Commit 0ac3b13

Browse files
yarikopticclaude
andcommitted
refactor: add TextFormatter for uniform rendering dispatch
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>
1 parent b5f1558 commit 0ac3b13

2 files changed

Lines changed: 118 additions & 80 deletions

File tree

dandi/cli/cmd_validate.py

Lines changed: 82 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import click
1414

1515
from .base import devel_debug_option, devel_option, map_to_click_exceptions
16-
from .formatter import JSONFormatter, JSONLinesFormatter, YAMLFormatter
16+
from .formatter import JSONFormatter, JSONLinesFormatter, TextFormatter, YAMLFormatter
1717
from ..utils import pluralize
1818
from ..validate._core import validate as validate_
1919
from ..validate._io import (
@@ -283,32 +283,19 @@ def validate(
283283

284284
filtered = _filter_results(results, min_severity, ignore)
285285

286-
if output_format == "text":
287-
_render_text(filtered, grouping, max_per_group=max_per_group)
288-
if summary:
289-
_print_summary(filtered, sys.stdout)
290-
elif output_file is not None:
286+
if output_file is not None:
291287
with open(output_file, "w") as fh:
292-
_render_structured(
293-
filtered,
294-
output_format,
295-
fh,
296-
grouping,
297-
max_per_group=max_per_group,
298-
)
288+
_render(filtered, output_format, fh, grouping, max_per_group=max_per_group)
299289
lgr.info("Validation output written to %s", output_file)
300290
if summary:
301291
_print_summary(filtered, sys.stderr)
302292
else:
303-
_render_structured(
304-
filtered,
305-
output_format,
306-
sys.stdout,
307-
grouping,
308-
max_per_group=max_per_group,
293+
_render(
294+
filtered, output_format, sys.stdout, grouping, max_per_group=max_per_group
309295
)
310296
if summary:
311-
_print_summary(filtered, sys.stderr)
297+
summary_out = sys.stdout if output_format == "text" else sys.stderr
298+
_print_summary(filtered, summary_out)
312299

313300
_exit_if_errors(filtered)
314301

@@ -356,9 +343,11 @@ def _print_summary(results: list[ValidationResult], out: IO[str]) -> None:
356343

357344
def _get_formatter(
358345
output_format: str, out: IO[str] | None = None
359-
) -> JSONFormatter | JSONLinesFormatter | YAMLFormatter:
346+
) -> JSONFormatter | JSONLinesFormatter | TextFormatter | YAMLFormatter:
360347
"""Create a formatter for the given output format."""
361348
match output_format:
349+
case "text":
350+
return TextFormatter(out=out)
362351
case "json":
363352
return JSONFormatter(out=out)
364353
case "json_pp":
@@ -371,43 +360,85 @@ def _get_formatter(
371360
raise ValueError(f"Unknown format: {output_format}")
372361

373362

374-
def _render_structured(
363+
def _render(
375364
results: list[ValidationResult],
376365
output_format: str,
377366
out: IO[str],
378367
grouping: tuple[str, ...] = (),
379368
max_per_group: int | None = None,
380369
) -> None:
381-
"""Render validation results in a structured format."""
370+
"""Render validation results in the given format.
371+
372+
Handles both text and structured (JSON/JSONL/YAML) formats, with
373+
optional grouping and truncation.
374+
"""
375+
is_text = output_format == "text"
376+
382377
if grouping:
383-
# Grouped output: build nested dict, serialize directly
384378
grouped: GroupedResults | TruncatedResults = _group_results(results, grouping)
385379
if max_per_group is not None:
386380
grouped = _truncate_leaves(grouped, max_per_group)
387-
data = _serialize_grouped(grouped)
388-
if output_format in ("json", "json_pp"):
389-
indent = 2 if output_format == "json_pp" else None
390-
json_mod.dump(data, out, indent=indent, sort_keys=True, default=str)
391-
out.write("\n")
392-
elif output_format == "yaml":
393-
import ruamel.yaml
394-
395-
yaml = ruamel.yaml.YAML(typ="safe")
396-
yaml.default_flow_style = False
397-
yaml.dump(data, out)
381+
if is_text:
382+
# Text grouped output uses colored section headers
383+
if grouping == ("path",):
384+
# Legacy path grouping: per-path display_errors
385+
purviews = list(set(i.purview for i in results))
386+
for purview in purviews:
387+
applies_to = [i for i in results if purview == i.purview]
388+
display_errors(
389+
[purview],
390+
[i.id for i in applies_to],
391+
cast("list[Severity]", [i.severity for i in applies_to]),
392+
[i.message for i in applies_to],
393+
)
394+
else:
395+
_render_text_grouped(grouped, depth=0)
396+
if not any(
397+
r.severity is not None and r.severity >= Severity.ERROR for r in results
398+
):
399+
click.secho("No errors found.", fg="green")
398400
else:
399-
raise ValueError(f"Unsupported format for grouped output: {output_format}")
401+
# Structured grouped output: nested dict
402+
data = _serialize_grouped(grouped)
403+
if output_format in ("json", "json_pp"):
404+
indent = 2 if output_format == "json_pp" else None
405+
json_mod.dump(data, out, indent=indent, sort_keys=True, default=str)
406+
out.write("\n")
407+
elif output_format == "yaml":
408+
import ruamel.yaml
409+
410+
yaml = ruamel.yaml.YAML(typ="safe")
411+
yaml.default_flow_style = False
412+
yaml.dump(data, out)
413+
else:
414+
raise ValueError(
415+
f"Unsupported format for grouped output: {output_format}"
416+
)
400417
else:
401-
items: list[dict] = [r.model_dump(mode="json") for r in results]
402-
if max_per_group is not None and len(items) > max_per_group:
403-
items = items[:max_per_group]
404-
items.append(
405-
{"_truncated": True, "omitted_count": len(results) - max_per_group}
406-
)
407-
formatter = _get_formatter(output_format, out=out)
408-
with formatter:
409-
for item in items:
410-
formatter(item)
418+
# Ungrouped: use formatter per-record
419+
if is_text:
420+
shown = results
421+
omitted = 0
422+
if max_per_group is not None and len(results) > max_per_group:
423+
shown = results[:max_per_group]
424+
omitted = len(results) - max_per_group
425+
formatter = _get_formatter(output_format, out=out)
426+
with formatter:
427+
for r in shown:
428+
formatter(r)
429+
if omitted:
430+
click.secho(f"... and {pluralize(omitted, 'more issue')}", fg="cyan")
431+
else:
432+
items: list[dict] = [r.model_dump(mode="json") for r in results]
433+
if max_per_group is not None and len(items) > max_per_group:
434+
items = items[:max_per_group]
435+
items.append(
436+
{"_truncated": True, "omitted_count": len(results) - max_per_group}
437+
)
438+
formatter = _get_formatter(output_format, out=out)
439+
with formatter:
440+
for item in items:
441+
formatter(item)
411442

412443

413444
def _exit_if_errors(results: list[ValidationResult]) -> None:
@@ -500,41 +531,12 @@ def _render_text(
500531
grouping: tuple[str, ...],
501532
max_per_group: int | None = None,
502533
) -> None:
503-
"""Render validation results in colored text format."""
504-
if not grouping:
505-
shown = issues
506-
omitted = 0
507-
if max_per_group is not None and len(issues) > max_per_group:
508-
shown = issues[:max_per_group]
509-
omitted = len(issues) - max_per_group
510-
purviews = [i.purview for i in shown]
511-
display_errors(
512-
purviews,
513-
[i.id for i in shown],
514-
cast("list[Severity]", [i.severity for i in shown]),
515-
[i.message for i in shown],
516-
)
517-
if omitted:
518-
click.secho(f"... and {pluralize(omitted, 'more issue')}", fg="cyan")
519-
elif grouping == ("path",):
520-
# Legacy path grouping: de-duplicate purviews, show per-path
521-
purviews = list(set(i.purview for i in issues))
522-
for purview in purviews:
523-
applies_to = [i for i in issues if purview == i.purview]
524-
display_errors(
525-
[purview],
526-
[i.id for i in applies_to],
527-
cast("list[Severity]", [i.severity for i in applies_to]),
528-
[i.message for i in applies_to],
529-
)
530-
else:
531-
grouped: GroupedResults | TruncatedResults = _group_results(issues, grouping)
532-
if max_per_group is not None:
533-
grouped = _truncate_leaves(grouped, max_per_group)
534-
_render_text_grouped(grouped, depth=0)
534+
"""Render validation results in colored text format.
535535
536-
if not any(r.severity is not None and r.severity >= Severity.ERROR for r in issues):
537-
click.secho("No errors found.", fg="green")
536+
Thin wrapper around ``_render`` for backwards compatibility with tests
537+
and ``_process_issues``.
538+
"""
539+
_render(issues, "text", sys.stdout, grouping, max_per_group=max_per_group)
538540

539541

540542
def _count_leaves(grouped: GroupedResults | TruncatedResults) -> int:

dandi/cli/formatter.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import sys
44
from textwrap import indent
55

6+
import click
67
import ruamel.yaml
78

89
from .. import get_logger
910
from ..support import pyout as pyouts
11+
from ..validate._types import Severity
1012

1113
lgr = get_logger()
1214

@@ -89,6 +91,40 @@ def __call__(self, rec):
8991
self.records.append(rec)
9092

9193

94+
class TextFormatter(Formatter):
95+
"""Render validation results as colored text lines.
96+
97+
Unlike other formatters which receive dicts, this receives
98+
``ValidationResult`` objects directly (needs ``.purview``, ``.severity``).
99+
"""
100+
101+
def __init__(self, out=None):
102+
self.out = out or sys.stdout
103+
self._has_errors = False
104+
105+
def __exit__(self, exc_type, exc_value, traceback):
106+
if not self._has_errors:
107+
click.secho("No errors found.", fg="green", file=self.out)
108+
109+
def __call__(self, rec):
110+
111+
severity = rec.severity
112+
purview = rec.purview
113+
msg = f"[{rec.id}] {purview}{rec.message}"
114+
if severity is not None and severity >= Severity.ERROR:
115+
self._has_errors = True
116+
if severity is not None:
117+
if severity >= Severity.ERROR:
118+
fg = "red"
119+
elif severity >= Severity.WARNING:
120+
fg = "yellow"
121+
else:
122+
fg = "blue"
123+
else:
124+
fg = "blue"
125+
click.secho(msg, fg=fg, file=self.out)
126+
127+
92128
class PYOUTFormatter(pyouts.LogSafeTabular):
93129
def __init__(self, fields, **kwargs):
94130
PYOUT_STYLE = pyouts.get_style(hide_if_missing=not fields)

0 commit comments

Comments
 (0)