bioimageio.spec.summary

  1import os
  2import subprocess
  3from dataclasses import dataclass
  4from datetime import datetime, timezone
  5from io import StringIO
  6from itertools import chain
  7from pathlib import Path
  8from tempfile import TemporaryDirectory
  9from textwrap import TextWrapper
 10from types import MappingProxyType
 11from typing import (
 12    Any,
 13    Dict,
 14    List,
 15    Literal,
 16    Mapping,
 17    NamedTuple,
 18    Optional,
 19    Sequence,
 20    Set,
 21    Tuple,
 22    Union,
 23    no_type_check,
 24)
 25
 26import markdown
 27import rich.console
 28import rich.markdown
 29import rich.traceback
 30from loguru import logger
 31from pydantic import (
 32    BaseModel,
 33    Field,
 34    field_serializer,
 35    field_validator,
 36    model_validator,
 37)
 38from pydantic_core.core_schema import ErrorType
 39from typing_extensions import Self, assert_never
 40
 41from bioimageio.spec._internal.type_guards import is_dict
 42
 43from ._internal.constants import VERSION
 44from ._internal.io import is_yaml_value
 45from ._internal.io_utils import write_yaml
 46from ._internal.validation_context import ValidationContextSummary
 47from ._internal.warning_levels import (
 48    ALERT,
 49    ALERT_NAME,
 50    ERROR,
 51    ERROR_NAME,
 52    INFO,
 53    INFO_NAME,
 54    WARNING,
 55    WARNING_NAME,
 56    WarningLevel,
 57    WarningSeverity,
 58)
 59from .conda_env import CondaEnv
 60
 61Loc = Tuple[Union[int, str], ...]
 62"""location of error/warning in a nested data structure"""
 63
 64WarningSeverityName = Literal["info", "warning", "alert"]
 65WarningLevelName = Literal[WarningSeverityName, "error"]
 66
 67WARNING_SEVERITY_TO_NAME: Mapping[WarningSeverity, WarningSeverityName] = (
 68    MappingProxyType({INFO: INFO_NAME, WARNING: WARNING_NAME, ALERT: ALERT_NAME})
 69)
 70WARNING_LEVEL_TO_NAME: Mapping[WarningLevel, WarningLevelName] = MappingProxyType(
 71    {INFO: INFO_NAME, WARNING: WARNING_NAME, ALERT: ALERT_NAME, ERROR: ERROR_NAME}
 72)
 73WARNING_NAME_TO_LEVEL: Mapping[WarningLevelName, WarningLevel] = MappingProxyType(
 74    {v: k for k, v in WARNING_LEVEL_TO_NAME.items()}
 75)
 76
 77
 78class ValidationEntry(BaseModel):
 79    """Base of `ErrorEntry` and `WarningEntry`"""
 80
 81    loc: Loc
 82    msg: str
 83    type: Union[ErrorType, str]
 84
 85
 86class ErrorEntry(ValidationEntry):
 87    """An error in a `ValidationDetail`"""
 88
 89    with_traceback: bool = False
 90    traceback_md: str = ""
 91    traceback_html: str = ""
 92    # private rich traceback that is not serialized
 93    _traceback_rich: Optional[rich.traceback.Traceback] = None
 94
 95    @property
 96    def traceback_rich(self):
 97        return self._traceback_rich
 98
 99    def model_post_init(self, __context: Any):
100        if self.with_traceback and not (self.traceback_md or self.traceback_html):
101            self._traceback_rich = rich.traceback.Traceback()
102            console = rich.console.Console(
103                record=True,
104                file=open(os.devnull, "wt", encoding="utf-8"),
105                color_system="truecolor",
106                width=120,
107                tab_size=4,
108                soft_wrap=True,
109            )
110            console.print(self._traceback_rich)
111            if not self.traceback_md:
112                self.traceback_md = console.export_text(clear=False)
113
114            if not self.traceback_html:
115                self.traceback_html = console.export_html(clear=False)
116
117
118class WarningEntry(ValidationEntry):
119    """A warning in a `ValidationDetail`"""
120
121    severity: WarningSeverity = WARNING
122
123    @property
124    def severity_name(self) -> WarningSeverityName:
125        return WARNING_SEVERITY_TO_NAME[self.severity]
126
127
128def format_loc(
129    loc: Loc, target: Union[Literal["md", "html", "plain"], rich.console.Console]
130) -> str:
131    """helper to format a location tuple **loc**"""
132    loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc))
133
134    # additional field validation can make the location information quite convoluted, e.g.
135    # `weights.pytorch_state_dict.dependencies.source.function-after[validate_url_ok(), url['http','https']]` Input should be a valid URL, relative URL without a base
136    # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here
137    loc_str, *_ = loc_str.split(".function-after")
138    if loc_str:
139        if target == "md" or isinstance(target, rich.console.Console):
140            start = "`"
141            end = "`"
142        elif target == "html":
143            start = "<code>"
144            end = "</code>"
145        elif target == "plain":
146            start = ""
147            end = ""
148        else:
149            assert_never(target)
150
151        return f"{start}{loc_str}{end}"
152    else:
153        return ""
154
155
156class InstalledPackage(NamedTuple):
157    name: str
158    version: str
159    build: str = ""
160    channel: str = ""
161
162
163class ValidationDetail(BaseModel, extra="allow"):
164    """a detail in a validation summary"""
165
166    name: str
167    status: Literal["passed", "failed"]
168    loc: Loc = ()
169    """location in the RDF that this detail applies to"""
170    errors: List[ErrorEntry] = Field(  # pyright: ignore[reportUnknownVariableType]
171        default_factory=list
172    )
173    warnings: List[WarningEntry] = Field(  # pyright: ignore[reportUnknownVariableType]
174        default_factory=list
175    )
176    context: Optional[ValidationContextSummary] = None
177
178    recommended_env: Optional[CondaEnv] = None
179    """recommended conda environemnt for this validation detail"""
180
181    saved_conda_compare: Optional[str] = None
182    """output of `conda compare <recommended env>`"""
183
184    @field_serializer("saved_conda_compare")
185    def _save_conda_compare(self, value: Optional[str]):
186        return self.conda_compare
187
188    @model_validator(mode="before")
189    def _load_legacy(cls, data: Any):
190        if is_dict(data):
191            field_name = "conda_compare"
192            if (
193                field_name in data
194                and (saved_field_name := f"saved_{field_name}") not in data
195            ):
196                data[saved_field_name] = data.pop(field_name)
197
198        return data
199
200    @property
201    def conda_compare(self) -> Optional[str]:
202        if self.recommended_env is None:
203            return None
204
205        if self.saved_conda_compare is None:
206            dumped_env = self.recommended_env.model_dump(mode="json")
207            if is_yaml_value(dumped_env):
208                with TemporaryDirectory() as d:
209                    path = Path(d) / "env.yaml"
210                    with path.open("w", encoding="utf-8") as f:
211                        write_yaml(dumped_env, f)
212
213                    compare_proc = subprocess.run(
214                        ["conda", "compare", str(path)],
215                        stdout=subprocess.PIPE,
216                        stderr=subprocess.STDOUT,
217                        shell=True,
218                        text=True,
219                    )
220                    self.saved_conda_compare = (
221                        compare_proc.stdout
222                        or f"`conda compare` exited with {compare_proc.returncode}"
223                    )
224            else:
225                self.saved_conda_compare = (
226                    "Failed to dump recommended env to valid yaml"
227                )
228
229        return self.saved_conda_compare
230
231    @property
232    def status_icon(self):
233        if self.status == "passed":
234            return "✔️"
235        else:
236            return "❌"
237
238
239class ValidationSummary(BaseModel, extra="allow"):
240    """Summarizes output of all bioimageio validations and tests
241    for one specific `ResourceDescr` instance."""
242
243    name: str
244    """name of the validation"""
245    source_name: str
246    """source of the validated bioimageio description"""
247    id: Optional[str] = None
248    """ID of the resource being validated"""
249    type: str
250    """type of the resource being validated"""
251    format_version: str
252    """format version of the resource being validated"""
253    status: Literal["passed", "valid-format", "failed"]
254    """overall status of the bioimageio validation"""
255    details: List[ValidationDetail]
256    """list of validation details"""
257    env: Set[InstalledPackage] = Field(
258        default_factory=lambda: {
259            InstalledPackage(name="bioimageio.spec", version=VERSION)
260        }
261    )
262    """list of selected, relevant package versions"""
263
264    saved_conda_list: Optional[str] = None
265
266    @field_serializer("saved_conda_list")
267    def _save_conda_list(self, value: Optional[str]):
268        return self.conda_list
269
270    @property
271    def conda_list(self):
272        if self.saved_conda_list is None:
273            p = subprocess.run(
274                ["conda", "list"],
275                stdout=subprocess.PIPE,
276                stderr=subprocess.STDOUT,
277                shell=True,
278                text=True,
279            )
280            self.saved_conda_list = (
281                p.stdout or f"`conda list` exited with {p.returncode}"
282            )
283
284        return self.saved_conda_list
285
286    @property
287    def status_icon(self):
288        if self.status == "passed":
289            return "✔️"
290        elif self.status == "valid-format":
291            return "🟡"
292        else:
293            return "❌"
294
295    @property
296    def errors(self) -> List[ErrorEntry]:
297        return list(chain.from_iterable(d.errors for d in self.details))
298
299    @property
300    def warnings(self) -> List[WarningEntry]:
301        return list(chain.from_iterable(d.warnings for d in self.details))
302
303    def format(
304        self,
305        *,
306        width: Optional[int] = None,
307        include_conda_list: bool = False,
308    ):
309        """Format summary as Markdown string"""
310        return self._format(
311            width=width, target="md", include_conda_list=include_conda_list
312        )
313
314    format_md = format
315
316    def format_html(
317        self,
318        *,
319        width: Optional[int] = None,
320        include_conda_list: bool = False,
321    ):
322        md_with_html = self._format(
323            target="html", width=width, include_conda_list=include_conda_list
324        )
325        return markdown.markdown(
326            md_with_html, extensions=["tables", "fenced_code", "nl2br"]
327        )
328
329    # TODO: fix bug which casuses extensive white space between the info table and details table
330    # (the generated markdown seems fine)
331    @no_type_check
332    def display(
333        self,
334        *,
335        width: Optional[int] = None,
336        include_conda_list: bool = False,
337        tab_size: int = 4,
338        soft_wrap: bool = True,
339    ) -> None:
340        try:  # render as HTML in Jupyter notebook
341            from IPython.core.getipython import get_ipython
342            from IPython.display import display_html
343        except ImportError:
344            pass
345        else:
346            if get_ipython() is not None:
347                _ = display_html(
348                    self.format_html(
349                        width=width, include_conda_list=include_conda_list
350                    ),
351                    raw=True,
352                )
353                return
354
355        # render with rich
356        self._format(
357            target=rich.console.Console(
358                width=width,
359                tab_size=tab_size,
360                soft_wrap=soft_wrap,
361            ),
362            width=width,
363            include_conda_list=include_conda_list,
364        )
365
366    def add_detail(self, detail: ValidationDetail):
367        if detail.status == "failed":
368            self.status = "failed"
369        elif detail.status != "passed":
370            assert_never(detail.status)
371
372        self.details.append(detail)
373
374    def log(
375        self,
376        to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
377    ) -> List[Path]:
378        """Convenience method to display the validation summary in the terminal and/or
379        save it to disk. See `save` for details."""
380        if to == "display":
381            display = True
382            save_to = []
383        elif isinstance(to, Path):
384            display = False
385            save_to = [to]
386        else:
387            display = "display" in to
388            save_to = [p for p in to if p != "display"]
389
390        if display:
391            self.display()
392
393        return self.save(save_to)
394
395    def save(
396        self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
397    ) -> List[Path]:
398        """Save the validation/test summary in JSON, Markdown or HTML format.
399
400        Returns:
401            List of file paths the summary was saved to.
402
403        Notes:
404        - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
405        - If **path** has no suffix it is assumed to be a direcotry to which a
406          `summary.json`, `summary.md` and `summary.html` are saved to.
407        """
408        if isinstance(path, (str, Path)):
409            path = [Path(path)]
410
411        # folder to file paths
412        file_paths: List[Path] = []
413        for p in path:
414            if p.suffix:
415                file_paths.append(p)
416            else:
417                file_paths.extend(
418                    [
419                        p / "summary.json",
420                        p / "summary.md",
421                        p / "summary.html",
422                    ]
423                )
424
425        now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
426        for p in file_paths:
427            p = Path(str(p).format(id=self.id or "bioimageio", now=now))
428            if p.suffix == ".json":
429                self.save_json(p)
430            elif p.suffix == ".md":
431                self.save_markdown(p)
432            elif p.suffix == ".html":
433                self.save_html(p)
434            else:
435                raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
436
437        return file_paths
438
439    def save_json(
440        self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
441    ):
442        """Save validation/test summary as JSON file."""
443        json_str = self.model_dump_json(indent=indent)
444        path.parent.mkdir(exist_ok=True, parents=True)
445        _ = path.write_text(json_str, encoding="utf-8")
446        logger.info("Saved summary to {}", path.absolute())
447
448    def save_markdown(self, path: Path = Path("summary.md")):
449        """Save rendered validation/test summary as Markdown file."""
450        formatted = self.format_md()
451        path.parent.mkdir(exist_ok=True, parents=True)
452        _ = path.write_text(formatted, encoding="utf-8")
453        logger.info("Saved Markdown formatted summary to {}", path.absolute())
454
455    def save_html(self, path: Path = Path("summary.html")) -> None:
456        """Save rendered validation/test summary as HTML file."""
457        path.parent.mkdir(exist_ok=True, parents=True)
458
459        html = self.format_html()
460        _ = path.write_text(html, encoding="utf-8")
461        logger.info("Saved HTML formatted summary to {}", path.absolute())
462
463    @classmethod
464    def load_json(cls, path: Path) -> Self:
465        """Load validation/test summary from a suitable JSON file"""
466        json_str = Path(path).read_text(encoding="utf-8")
467        return cls.model_validate_json(json_str)
468
469    @field_validator("env", mode="before")
470    def _convert_dict(cls, value: List[Union[List[str], Dict[str, str]]]):
471        """convert old env value for backwards compatibility"""
472        if isinstance(value, list):
473            return [
474                (
475                    (v["name"], v["version"], v.get("build", ""), v.get("channel", ""))
476                    if isinstance(v, dict) and "name" in v and "version" in v
477                    else v
478                )
479                for v in value
480            ]
481        else:
482            return value
483
484    def _format(
485        self,
486        *,
487        target: Union[rich.console.Console, Literal["html", "md"]],
488        width: Optional[int],
489        include_conda_list: bool,
490    ):
491        return _format_summary(
492            self,
493            target=target,
494            width=width or 100,
495            include_conda_list=include_conda_list,
496        )
497
498
499def _format_summary(
500    summary: ValidationSummary,
501    *,
502    hide_tracebacks: bool = False,  # TODO: remove?
503    hide_source: bool = False,  # TODO: remove?
504    hide_env: bool = False,  # TODO: remove?
505    target: Union[rich.console.Console, Literal["html", "md"]] = "md",
506    include_conda_list: bool,
507    width: int,
508) -> str:
509    parts: List[str] = []
510    format_table = _format_html_table if target == "html" else _format_md_table
511    details_below: Dict[str, Union[str, Tuple[str, rich.traceback.Traceback]]] = {}
512    left_out_details: int = 0
513    left_out_details_header = "Left out details"
514
515    def add_part(part: str):
516        parts.append(part)
517        if isinstance(target, rich.console.Console):
518            target.print(rich.markdown.Markdown(part))
519
520    def add_section(header: str):
521        if target == "md" or isinstance(target, rich.console.Console):
522            add_part(f"\n### {header}\n")
523        elif target == "html":
524            parts.append(f'<h3 id="{header_to_tag(header)}">{header}</h3>')
525        else:
526            assert_never(target)
527
528    def header_to_tag(header: str):
529        return (
530            header.replace("`", "")
531            .replace("(", "")
532            .replace(")", "")
533            .replace(" ", "-")
534            .lower()
535        )
536
537    def add_as_details_below(
538        title: str, text: Union[str, Tuple[str, rich.traceback.Traceback]]
539    ):
540        """returns a header and its tag to link to details below"""
541
542        def make_link(header: str):
543            tag = header_to_tag(header)
544            if target == "md":
545                return f"[{header}](#{tag})"
546            elif target == "html":
547                return f'<a href="#{tag}">{header}</a>'
548            elif isinstance(target, rich.console.Console):
549                return f"{header} below"
550            else:
551                assert_never(target)
552
553        for n in range(1, 4):
554            header = f"{title} {n}"
555            if header in details_below:
556                if details_below[header] == text:
557                    return make_link(header)
558            else:
559                details_below[header] = text
560                return make_link(header)
561
562        nonlocal left_out_details
563        left_out_details += 1
564        return make_link(left_out_details_header)
565
566    @dataclass
567    class CodeCell:
568        text: str
569
570    @dataclass
571    class CodeRef:
572        text: str
573
574    def format_code(
575        code: str,
576        lang: str = "",
577        title: str = "Details",
578        cell_line_limit: int = 15,
579        cell_width_limit: int = 120,
580    ) -> Union[CodeRef, CodeCell]:
581
582        if not code.strip():
583            return CodeCell("")
584
585        if target == "html":
586            html_lang = f' lang="{lang}"' if lang else ""
587            code = f"<pre{html_lang}>{code}</pre>"
588            put_below = (
589                code.count("\n") > cell_line_limit
590                or max(map(len, code.split("\n"))) > cell_width_limit
591            )
592        else:
593            put_below = True
594            code = f"\n```{lang}\n{code}\n```\n"
595
596        if put_below:
597            link = add_as_details_below(title, code)
598            return CodeRef(f"See {link}.")
599        else:
600            return CodeCell(code)
601
602    def format_traceback(entry: ErrorEntry):
603        if isinstance(target, rich.console.Console):
604            if entry.traceback_rich is None:
605                return format_code(entry.traceback_md, title="Traceback")
606            else:
607                link = add_as_details_below(
608                    "Traceback", (entry.traceback_md, entry.traceback_rich)
609                )
610                return CodeRef(f"See {link}.")
611
612        if target == "md":
613            return format_code(entry.traceback_md, title="Traceback")
614        elif target == "html":
615            return format_code(entry.traceback_html, title="Traceback")
616        else:
617            assert_never(target)
618
619    def format_text(text: str):
620        if target == "html":
621            return [f"<pre>{text}</pre>"]
622        else:
623            return text.split("\n")
624
625    def get_info_table():
626        info_rows = [
627            [summary.status_icon, summary.name.strip(".").strip()],
628            ["status", summary.status],
629        ]
630        if not hide_source:
631            info_rows.append(["source", summary.source_name])
632
633        if summary.id is not None:
634            info_rows.append(["id", summary.id])
635
636        info_rows.append(["format version", f"{summary.type} {summary.format_version}"])
637        if not hide_env:
638            info_rows.extend([[e.name, e.version] for e in summary.env])
639
640        if include_conda_list:
641            info_rows.append(
642                ["conda list", format_code(summary.conda_list, title="Conda List").text]
643            )
644        return format_table(info_rows)
645
646    def get_details_table():
647        details = [["", "Location", "Details"]]
648
649        def append_detail(
650            status: str, loc: Loc, text: str, code: Union[CodeRef, CodeCell, None]
651        ):
652
653            text_lines = format_text(text)
654            status_lines = [""] * len(text_lines)
655            loc_lines = [""] * len(text_lines)
656            status_lines[0] = status
657            loc_lines[0] = format_loc(loc, target)
658            for s_line, loc_line, text_line in zip(status_lines, loc_lines, text_lines):
659                details.append([s_line, loc_line, text_line])
660
661            if code is not None:
662                details.append(["", "", code.text])
663
664        for d in summary.details:
665            details.append([d.status_icon, format_loc(d.loc, target), d.name])
666
667            for entry in d.errors:
668                append_detail(
669                    "❌",
670                    entry.loc,
671                    entry.msg,
672                    None if hide_tracebacks else format_traceback(entry),
673                )
674
675            for entry in d.warnings:
676                append_detail("⚠", entry.loc, entry.msg, None)
677
678            if d.recommended_env is not None:
679                rec_env = StringIO()
680                json_env = d.recommended_env.model_dump(
681                    mode="json", exclude_defaults=True
682                )
683                assert is_yaml_value(json_env)
684                write_yaml(json_env, rec_env)
685                append_detail(
686                    "",
687                    d.loc,
688                    f"recommended conda environment ({d.name})",
689                    format_code(
690                        rec_env.getvalue(),
691                        lang="yaml",
692                        title="Recommended Conda Environment",
693                    ),
694                )
695
696            if d.conda_compare:
697                wrapped_conda_compare = "\n".join(
698                    TextWrapper(width=width - 4).wrap(d.conda_compare)
699                )
700                append_detail(
701                    "",
702                    d.loc,
703                    f"conda compare ({d.name})",
704                    format_code(
705                        wrapped_conda_compare,
706                        title="Conda Environment Comparison",
707                    ),
708                )
709
710        return format_table(details)
711
712    add_part(get_info_table())
713    add_part(get_details_table())
714
715    for header, text in details_below.items():
716        add_section(header)
717        if isinstance(text, tuple):
718            assert isinstance(target, rich.console.Console)
719            text, rich_obj = text
720            target.print(rich_obj)
721            parts.append(f"{text}\n")
722        else:
723            add_part(f"{text}\n")
724
725    if left_out_details:
726        parts.append(
727            f"\n{left_out_details_header}\nLeft out {left_out_details} more details for brevity.\n"
728        )
729
730    return "".join(parts)
731
732
733def _format_md_table(rows: List[List[str]]) -> str:
734    """format `rows` as markdown table"""
735    n_cols = len(rows[0])
736    assert all(len(row) == n_cols for row in rows)
737    col_widths = [max(max(len(row[i]) for row in rows), 3) for i in range(n_cols)]
738
739    # fix new lines in table cell
740    rows = [[line.replace("\n", "<br>") for line in r] for r in rows]
741
742    lines = [" | ".join(rows[0][i].center(col_widths[i]) for i in range(n_cols))]
743    lines.append(" | ".join("---".center(col_widths[i]) for i in range(n_cols)))
744    lines.extend(
745        [
746            " | ".join(row[i].ljust(col_widths[i]) for i in range(n_cols))
747            for row in rows[1:]
748        ]
749    )
750    return "\n| " + " |\n| ".join(lines) + " |\n"
751
752
753def _format_html_table(rows: List[List[str]]) -> str:
754    """format `rows` as HTML table"""
755
756    def get_line(cells: List[str], cell_tag: Literal["th", "td"] = "td"):
757        return (
758            ["  <tr>"]
759            + [f"    <{cell_tag}>{c}</{cell_tag}>" for c in cells]
760            + ["  </tr>"]
761        )
762
763    table = ["<table>"] + get_line(rows[0], cell_tag="th")
764    for r in rows[1:]:
765        table.extend(get_line(r))
766
767    table.append("</table>")
768
769    return "\n".join(table)
Loc = typing.Tuple[typing.Union[int, str], ...]

location of error/warning in a nested data structure

WarningSeverityName = typing.Literal['info', 'warning', 'alert']
WarningLevelName = typing.Literal['info', 'warning', 'alert', 'error']
WARNING_SEVERITY_TO_NAME: Mapping[Literal[20, 30, 35], Literal['info', 'warning', 'alert']] = mappingproxy({20: 'info', 30: 'warning', 35: 'alert'})
WARNING_LEVEL_TO_NAME: Mapping[Literal[20, 30, 35, 50], Literal['info', 'warning', 'alert', 'error']] = mappingproxy({20: 'info', 30: 'warning', 35: 'alert', 50: 'error'})
WARNING_NAME_TO_LEVEL: Mapping[Literal['info', 'warning', 'alert', 'error'], Literal[20, 30, 35, 50]] = mappingproxy({'info': 20, 'warning': 30, 'alert': 35, 'error': 50})
class ValidationEntry(pydantic.main.BaseModel):
79class ValidationEntry(BaseModel):
80    """Base of `ErrorEntry` and `WarningEntry`"""
81
82    loc: Loc
83    msg: str
84    type: Union[ErrorType, str]
loc: Tuple[Union[int, str], ...]
msg: str
type: Union[Literal['no_such_attribute', 'json_invalid', 'json_type', 'needs_python_object', 'recursion_loop', 'missing', 'frozen_field', 'frozen_instance', 'extra_forbidden', 'invalid_key', 'get_attribute_error', 'model_type', 'model_attributes_type', 'dataclass_type', 'dataclass_exact_type', 'none_required', 'greater_than', 'greater_than_equal', 'less_than', 'less_than_equal', 'multiple_of', 'finite_number', 'too_short', 'too_long', 'iterable_type', 'iteration_error', 'string_type', 'string_sub_type', 'string_unicode', 'string_too_short', 'string_too_long', 'string_pattern_mismatch', 'enum', 'dict_type', 'mapping_type', 'list_type', 'tuple_type', 'set_type', 'set_item_not_hashable', 'bool_type', 'bool_parsing', 'int_type', 'int_parsing', 'int_parsing_size', 'int_from_float', 'float_type', 'float_parsing', 'bytes_type', 'bytes_too_short', 'bytes_too_long', 'bytes_invalid_encoding', 'value_error', 'assertion_error', 'literal_error', 'date_type', 'date_parsing', 'date_from_datetime_parsing', 'date_from_datetime_inexact', 'date_past', 'date_future', 'time_type', 'time_parsing', 'datetime_type', 'datetime_parsing', 'datetime_object_invalid', 'datetime_from_date_parsing', 'datetime_past', 'datetime_future', 'timezone_naive', 'timezone_aware', 'timezone_offset', 'time_delta_type', 'time_delta_parsing', 'frozen_set_type', 'is_instance_of', 'is_subclass_of', 'callable_type', 'union_tag_invalid', 'union_tag_not_found', 'arguments_type', 'missing_argument', 'unexpected_keyword_argument', 'missing_keyword_only_argument', 'unexpected_positional_argument', 'missing_positional_only_argument', 'multiple_argument_values', 'url_type', 'url_parsing', 'url_syntax_violation', 'url_too_long', 'url_scheme', 'uuid_type', 'uuid_parsing', 'uuid_version', 'decimal_type', 'decimal_parsing', 'decimal_max_digits', 'decimal_max_places', 'decimal_whole_digits', 'complex_type', 'complex_str_parsing'], str]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ErrorEntry(ValidationEntry):
 87class ErrorEntry(ValidationEntry):
 88    """An error in a `ValidationDetail`"""
 89
 90    with_traceback: bool = False
 91    traceback_md: str = ""
 92    traceback_html: str = ""
 93    # private rich traceback that is not serialized
 94    _traceback_rich: Optional[rich.traceback.Traceback] = None
 95
 96    @property
 97    def traceback_rich(self):
 98        return self._traceback_rich
 99
100    def model_post_init(self, __context: Any):
101        if self.with_traceback and not (self.traceback_md or self.traceback_html):
102            self._traceback_rich = rich.traceback.Traceback()
103            console = rich.console.Console(
104                record=True,
105                file=open(os.devnull, "wt", encoding="utf-8"),
106                color_system="truecolor",
107                width=120,
108                tab_size=4,
109                soft_wrap=True,
110            )
111            console.print(self._traceback_rich)
112            if not self.traceback_md:
113                self.traceback_md = console.export_text(clear=False)
114
115            if not self.traceback_html:
116                self.traceback_html = console.export_html(clear=False)

An error in a ValidationDetail

with_traceback: bool
traceback_md: str
traceback_html: str
traceback_rich
96    @property
97    def traceback_rich(self):
98        return self._traceback_rich
def model_post_init(self, _ErrorEntry__context: Any):
100    def model_post_init(self, __context: Any):
101        if self.with_traceback and not (self.traceback_md or self.traceback_html):
102            self._traceback_rich = rich.traceback.Traceback()
103            console = rich.console.Console(
104                record=True,
105                file=open(os.devnull, "wt", encoding="utf-8"),
106                color_system="truecolor",
107                width=120,
108                tab_size=4,
109                soft_wrap=True,
110            )
111            console.print(self._traceback_rich)
112            if not self.traceback_md:
113                self.traceback_md = console.export_text(clear=False)
114
115            if not self.traceback_html:
116                self.traceback_html = console.export_html(clear=False)

Override this method to perform additional initialization after __init__ and model_construct. This is useful if you want to do some validation that requires the entire model to be initialized.

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

Inherited Members
ValidationEntry
loc
msg
type
class WarningEntry(ValidationEntry):
119class WarningEntry(ValidationEntry):
120    """A warning in a `ValidationDetail`"""
121
122    severity: WarningSeverity = WARNING
123
124    @property
125    def severity_name(self) -> WarningSeverityName:
126        return WARNING_SEVERITY_TO_NAME[self.severity]

A warning in a ValidationDetail

severity: Literal[20, 30, 35]
severity_name: Literal['info', 'warning', 'alert']
124    @property
125    def severity_name(self) -> WarningSeverityName:
126        return WARNING_SEVERITY_TO_NAME[self.severity]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

Inherited Members
ValidationEntry
loc
msg
type
def format_loc( loc: Tuple[Union[int, str], ...], target: Union[Literal['md', 'html', 'plain'], rich.console.Console]) -> str:
129def format_loc(
130    loc: Loc, target: Union[Literal["md", "html", "plain"], rich.console.Console]
131) -> str:
132    """helper to format a location tuple **loc**"""
133    loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc))
134
135    # additional field validation can make the location information quite convoluted, e.g.
136    # `weights.pytorch_state_dict.dependencies.source.function-after[validate_url_ok(), url['http','https']]` Input should be a valid URL, relative URL without a base
137    # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here
138    loc_str, *_ = loc_str.split(".function-after")
139    if loc_str:
140        if target == "md" or isinstance(target, rich.console.Console):
141            start = "`"
142            end = "`"
143        elif target == "html":
144            start = "<code>"
145            end = "</code>"
146        elif target == "plain":
147            start = ""
148            end = ""
149        else:
150            assert_never(target)
151
152        return f"{start}{loc_str}{end}"
153    else:
154        return ""

helper to format a location tuple loc

class InstalledPackage(typing.NamedTuple):
157class InstalledPackage(NamedTuple):
158    name: str
159    version: str
160    build: str = ""
161    channel: str = ""

InstalledPackage(name, version, build, channel)

InstalledPackage(name: str, version: str, build: str = '', channel: str = '')

Create new instance of InstalledPackage(name, version, build, channel)

name: str

Alias for field number 0

version: str

Alias for field number 1

build: str

Alias for field number 2

channel: str

Alias for field number 3

class ValidationDetail(pydantic.main.BaseModel):
164class ValidationDetail(BaseModel, extra="allow"):
165    """a detail in a validation summary"""
166
167    name: str
168    status: Literal["passed", "failed"]
169    loc: Loc = ()
170    """location in the RDF that this detail applies to"""
171    errors: List[ErrorEntry] = Field(  # pyright: ignore[reportUnknownVariableType]
172        default_factory=list
173    )
174    warnings: List[WarningEntry] = Field(  # pyright: ignore[reportUnknownVariableType]
175        default_factory=list
176    )
177    context: Optional[ValidationContextSummary] = None
178
179    recommended_env: Optional[CondaEnv] = None
180    """recommended conda environemnt for this validation detail"""
181
182    saved_conda_compare: Optional[str] = None
183    """output of `conda compare <recommended env>`"""
184
185    @field_serializer("saved_conda_compare")
186    def _save_conda_compare(self, value: Optional[str]):
187        return self.conda_compare
188
189    @model_validator(mode="before")
190    def _load_legacy(cls, data: Any):
191        if is_dict(data):
192            field_name = "conda_compare"
193            if (
194                field_name in data
195                and (saved_field_name := f"saved_{field_name}") not in data
196            ):
197                data[saved_field_name] = data.pop(field_name)
198
199        return data
200
201    @property
202    def conda_compare(self) -> Optional[str]:
203        if self.recommended_env is None:
204            return None
205
206        if self.saved_conda_compare is None:
207            dumped_env = self.recommended_env.model_dump(mode="json")
208            if is_yaml_value(dumped_env):
209                with TemporaryDirectory() as d:
210                    path = Path(d) / "env.yaml"
211                    with path.open("w", encoding="utf-8") as f:
212                        write_yaml(dumped_env, f)
213
214                    compare_proc = subprocess.run(
215                        ["conda", "compare", str(path)],
216                        stdout=subprocess.PIPE,
217                        stderr=subprocess.STDOUT,
218                        shell=True,
219                        text=True,
220                    )
221                    self.saved_conda_compare = (
222                        compare_proc.stdout
223                        or f"`conda compare` exited with {compare_proc.returncode}"
224                    )
225            else:
226                self.saved_conda_compare = (
227                    "Failed to dump recommended env to valid yaml"
228                )
229
230        return self.saved_conda_compare
231
232    @property
233    def status_icon(self):
234        if self.status == "passed":
235            return "✔️"
236        else:
237            return "❌"

a detail in a validation summary

name: str
status: Literal['passed', 'failed']
loc: Tuple[Union[int, str], ...]

location in the RDF that this detail applies to

errors: List[ErrorEntry]
warnings: List[WarningEntry]
context: Optional[bioimageio.spec._internal.validation_context.ValidationContextSummary]
recommended_env: Optional[bioimageio.spec.conda_env.CondaEnv]

recommended conda environemnt for this validation detail

saved_conda_compare: Optional[str]

output of conda compare <recommended env>

conda_compare: Optional[str]
201    @property
202    def conda_compare(self) -> Optional[str]:
203        if self.recommended_env is None:
204            return None
205
206        if self.saved_conda_compare is None:
207            dumped_env = self.recommended_env.model_dump(mode="json")
208            if is_yaml_value(dumped_env):
209                with TemporaryDirectory() as d:
210                    path = Path(d) / "env.yaml"
211                    with path.open("w", encoding="utf-8") as f:
212                        write_yaml(dumped_env, f)
213
214                    compare_proc = subprocess.run(
215                        ["conda", "compare", str(path)],
216                        stdout=subprocess.PIPE,
217                        stderr=subprocess.STDOUT,
218                        shell=True,
219                        text=True,
220                    )
221                    self.saved_conda_compare = (
222                        compare_proc.stdout
223                        or f"`conda compare` exited with {compare_proc.returncode}"
224                    )
225            else:
226                self.saved_conda_compare = (
227                    "Failed to dump recommended env to valid yaml"
228                )
229
230        return self.saved_conda_compare
status_icon
232    @property
233    def status_icon(self):
234        if self.status == "passed":
235            return "✔️"
236        else:
237            return "❌"
model_config: ClassVar[pydantic.config.ConfigDict] = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ValidationSummary(pydantic.main.BaseModel):
240class ValidationSummary(BaseModel, extra="allow"):
241    """Summarizes output of all bioimageio validations and tests
242    for one specific `ResourceDescr` instance."""
243
244    name: str
245    """name of the validation"""
246    source_name: str
247    """source of the validated bioimageio description"""
248    id: Optional[str] = None
249    """ID of the resource being validated"""
250    type: str
251    """type of the resource being validated"""
252    format_version: str
253    """format version of the resource being validated"""
254    status: Literal["passed", "valid-format", "failed"]
255    """overall status of the bioimageio validation"""
256    details: List[ValidationDetail]
257    """list of validation details"""
258    env: Set[InstalledPackage] = Field(
259        default_factory=lambda: {
260            InstalledPackage(name="bioimageio.spec", version=VERSION)
261        }
262    )
263    """list of selected, relevant package versions"""
264
265    saved_conda_list: Optional[str] = None
266
267    @field_serializer("saved_conda_list")
268    def _save_conda_list(self, value: Optional[str]):
269        return self.conda_list
270
271    @property
272    def conda_list(self):
273        if self.saved_conda_list is None:
274            p = subprocess.run(
275                ["conda", "list"],
276                stdout=subprocess.PIPE,
277                stderr=subprocess.STDOUT,
278                shell=True,
279                text=True,
280            )
281            self.saved_conda_list = (
282                p.stdout or f"`conda list` exited with {p.returncode}"
283            )
284
285        return self.saved_conda_list
286
287    @property
288    def status_icon(self):
289        if self.status == "passed":
290            return "✔️"
291        elif self.status == "valid-format":
292            return "🟡"
293        else:
294            return "❌"
295
296    @property
297    def errors(self) -> List[ErrorEntry]:
298        return list(chain.from_iterable(d.errors for d in self.details))
299
300    @property
301    def warnings(self) -> List[WarningEntry]:
302        return list(chain.from_iterable(d.warnings for d in self.details))
303
304    def format(
305        self,
306        *,
307        width: Optional[int] = None,
308        include_conda_list: bool = False,
309    ):
310        """Format summary as Markdown string"""
311        return self._format(
312            width=width, target="md", include_conda_list=include_conda_list
313        )
314
315    format_md = format
316
317    def format_html(
318        self,
319        *,
320        width: Optional[int] = None,
321        include_conda_list: bool = False,
322    ):
323        md_with_html = self._format(
324            target="html", width=width, include_conda_list=include_conda_list
325        )
326        return markdown.markdown(
327            md_with_html, extensions=["tables", "fenced_code", "nl2br"]
328        )
329
330    # TODO: fix bug which casuses extensive white space between the info table and details table
331    # (the generated markdown seems fine)
332    @no_type_check
333    def display(
334        self,
335        *,
336        width: Optional[int] = None,
337        include_conda_list: bool = False,
338        tab_size: int = 4,
339        soft_wrap: bool = True,
340    ) -> None:
341        try:  # render as HTML in Jupyter notebook
342            from IPython.core.getipython import get_ipython
343            from IPython.display import display_html
344        except ImportError:
345            pass
346        else:
347            if get_ipython() is not None:
348                _ = display_html(
349                    self.format_html(
350                        width=width, include_conda_list=include_conda_list
351                    ),
352                    raw=True,
353                )
354                return
355
356        # render with rich
357        self._format(
358            target=rich.console.Console(
359                width=width,
360                tab_size=tab_size,
361                soft_wrap=soft_wrap,
362            ),
363            width=width,
364            include_conda_list=include_conda_list,
365        )
366
367    def add_detail(self, detail: ValidationDetail):
368        if detail.status == "failed":
369            self.status = "failed"
370        elif detail.status != "passed":
371            assert_never(detail.status)
372
373        self.details.append(detail)
374
375    def log(
376        self,
377        to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
378    ) -> List[Path]:
379        """Convenience method to display the validation summary in the terminal and/or
380        save it to disk. See `save` for details."""
381        if to == "display":
382            display = True
383            save_to = []
384        elif isinstance(to, Path):
385            display = False
386            save_to = [to]
387        else:
388            display = "display" in to
389            save_to = [p for p in to if p != "display"]
390
391        if display:
392            self.display()
393
394        return self.save(save_to)
395
396    def save(
397        self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
398    ) -> List[Path]:
399        """Save the validation/test summary in JSON, Markdown or HTML format.
400
401        Returns:
402            List of file paths the summary was saved to.
403
404        Notes:
405        - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
406        - If **path** has no suffix it is assumed to be a direcotry to which a
407          `summary.json`, `summary.md` and `summary.html` are saved to.
408        """
409        if isinstance(path, (str, Path)):
410            path = [Path(path)]
411
412        # folder to file paths
413        file_paths: List[Path] = []
414        for p in path:
415            if p.suffix:
416                file_paths.append(p)
417            else:
418                file_paths.extend(
419                    [
420                        p / "summary.json",
421                        p / "summary.md",
422                        p / "summary.html",
423                    ]
424                )
425
426        now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
427        for p in file_paths:
428            p = Path(str(p).format(id=self.id or "bioimageio", now=now))
429            if p.suffix == ".json":
430                self.save_json(p)
431            elif p.suffix == ".md":
432                self.save_markdown(p)
433            elif p.suffix == ".html":
434                self.save_html(p)
435            else:
436                raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
437
438        return file_paths
439
440    def save_json(
441        self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
442    ):
443        """Save validation/test summary as JSON file."""
444        json_str = self.model_dump_json(indent=indent)
445        path.parent.mkdir(exist_ok=True, parents=True)
446        _ = path.write_text(json_str, encoding="utf-8")
447        logger.info("Saved summary to {}", path.absolute())
448
449    def save_markdown(self, path: Path = Path("summary.md")):
450        """Save rendered validation/test summary as Markdown file."""
451        formatted = self.format_md()
452        path.parent.mkdir(exist_ok=True, parents=True)
453        _ = path.write_text(formatted, encoding="utf-8")
454        logger.info("Saved Markdown formatted summary to {}", path.absolute())
455
456    def save_html(self, path: Path = Path("summary.html")) -> None:
457        """Save rendered validation/test summary as HTML file."""
458        path.parent.mkdir(exist_ok=True, parents=True)
459
460        html = self.format_html()
461        _ = path.write_text(html, encoding="utf-8")
462        logger.info("Saved HTML formatted summary to {}", path.absolute())
463
464    @classmethod
465    def load_json(cls, path: Path) -> Self:
466        """Load validation/test summary from a suitable JSON file"""
467        json_str = Path(path).read_text(encoding="utf-8")
468        return cls.model_validate_json(json_str)
469
470    @field_validator("env", mode="before")
471    def _convert_dict(cls, value: List[Union[List[str], Dict[str, str]]]):
472        """convert old env value for backwards compatibility"""
473        if isinstance(value, list):
474            return [
475                (
476                    (v["name"], v["version"], v.get("build", ""), v.get("channel", ""))
477                    if isinstance(v, dict) and "name" in v and "version" in v
478                    else v
479                )
480                for v in value
481            ]
482        else:
483            return value
484
485    def _format(
486        self,
487        *,
488        target: Union[rich.console.Console, Literal["html", "md"]],
489        width: Optional[int],
490        include_conda_list: bool,
491    ):
492        return _format_summary(
493            self,
494            target=target,
495            width=width or 100,
496            include_conda_list=include_conda_list,
497        )

Summarizes output of all bioimageio validations and tests for one specific ResourceDescr instance.

name: str

name of the validation

source_name: str

source of the validated bioimageio description

id: Optional[str]

ID of the resource being validated

type: str

type of the resource being validated

format_version: str

format version of the resource being validated

status: Literal['passed', 'valid-format', 'failed']

overall status of the bioimageio validation

details: List[ValidationDetail]

list of validation details

env: Set[InstalledPackage]

list of selected, relevant package versions

saved_conda_list: Optional[str]
conda_list
271    @property
272    def conda_list(self):
273        if self.saved_conda_list is None:
274            p = subprocess.run(
275                ["conda", "list"],
276                stdout=subprocess.PIPE,
277                stderr=subprocess.STDOUT,
278                shell=True,
279                text=True,
280            )
281            self.saved_conda_list = (
282                p.stdout or f"`conda list` exited with {p.returncode}"
283            )
284
285        return self.saved_conda_list
status_icon
287    @property
288    def status_icon(self):
289        if self.status == "passed":
290            return "✔️"
291        elif self.status == "valid-format":
292            return "🟡"
293        else:
294            return "❌"
errors: List[ErrorEntry]
296    @property
297    def errors(self) -> List[ErrorEntry]:
298        return list(chain.from_iterable(d.errors for d in self.details))
warnings: List[WarningEntry]
300    @property
301    def warnings(self) -> List[WarningEntry]:
302        return list(chain.from_iterable(d.warnings for d in self.details))
def format( self, *, width: Optional[int] = None, include_conda_list: bool = False):
304    def format(
305        self,
306        *,
307        width: Optional[int] = None,
308        include_conda_list: bool = False,
309    ):
310        """Format summary as Markdown string"""
311        return self._format(
312            width=width, target="md", include_conda_list=include_conda_list
313        )

Format summary as Markdown string

def format_md( self, *, width: Optional[int] = None, include_conda_list: bool = False):
304    def format(
305        self,
306        *,
307        width: Optional[int] = None,
308        include_conda_list: bool = False,
309    ):
310        """Format summary as Markdown string"""
311        return self._format(
312            width=width, target="md", include_conda_list=include_conda_list
313        )

Format summary as Markdown string

def format_html( self, *, width: Optional[int] = None, include_conda_list: bool = False):
317    def format_html(
318        self,
319        *,
320        width: Optional[int] = None,
321        include_conda_list: bool = False,
322    ):
323        md_with_html = self._format(
324            target="html", width=width, include_conda_list=include_conda_list
325        )
326        return markdown.markdown(
327            md_with_html, extensions=["tables", "fenced_code", "nl2br"]
328        )
@no_type_check
def display( self, *, width: Optional[int] = None, include_conda_list: bool = False, tab_size: int = 4, soft_wrap: bool = True) -> None:
332    @no_type_check
333    def display(
334        self,
335        *,
336        width: Optional[int] = None,
337        include_conda_list: bool = False,
338        tab_size: int = 4,
339        soft_wrap: bool = True,
340    ) -> None:
341        try:  # render as HTML in Jupyter notebook
342            from IPython.core.getipython import get_ipython
343            from IPython.display import display_html
344        except ImportError:
345            pass
346        else:
347            if get_ipython() is not None:
348                _ = display_html(
349                    self.format_html(
350                        width=width, include_conda_list=include_conda_list
351                    ),
352                    raw=True,
353                )
354                return
355
356        # render with rich
357        self._format(
358            target=rich.console.Console(
359                width=width,
360                tab_size=tab_size,
361                soft_wrap=soft_wrap,
362            ),
363            width=width,
364            include_conda_list=include_conda_list,
365        )
def add_detail(self, detail: ValidationDetail):
367    def add_detail(self, detail: ValidationDetail):
368        if detail.status == "failed":
369            self.status = "failed"
370        elif detail.status != "passed":
371            assert_never(detail.status)
372
373        self.details.append(detail)
def log( self, to: Union[Literal['display'], pathlib.Path, Sequence[Union[Literal['display'], pathlib.Path]]]) -> List[pathlib.Path]:
375    def log(
376        self,
377        to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
378    ) -> List[Path]:
379        """Convenience method to display the validation summary in the terminal and/or
380        save it to disk. See `save` for details."""
381        if to == "display":
382            display = True
383            save_to = []
384        elif isinstance(to, Path):
385            display = False
386            save_to = [to]
387        else:
388            display = "display" in to
389            save_to = [p for p in to if p != "display"]
390
391        if display:
392            self.display()
393
394        return self.save(save_to)

Convenience method to display the validation summary in the terminal and/or save it to disk. See save for details.

def save( self, path: Union[pathlib.Path, Sequence[pathlib.Path]] = PosixPath('{id}_summary_{now}')) -> List[pathlib.Path]:
396    def save(
397        self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
398    ) -> List[Path]:
399        """Save the validation/test summary in JSON, Markdown or HTML format.
400
401        Returns:
402            List of file paths the summary was saved to.
403
404        Notes:
405        - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
406        - If **path** has no suffix it is assumed to be a direcotry to which a
407          `summary.json`, `summary.md` and `summary.html` are saved to.
408        """
409        if isinstance(path, (str, Path)):
410            path = [Path(path)]
411
412        # folder to file paths
413        file_paths: List[Path] = []
414        for p in path:
415            if p.suffix:
416                file_paths.append(p)
417            else:
418                file_paths.extend(
419                    [
420                        p / "summary.json",
421                        p / "summary.md",
422                        p / "summary.html",
423                    ]
424                )
425
426        now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
427        for p in file_paths:
428            p = Path(str(p).format(id=self.id or "bioimageio", now=now))
429            if p.suffix == ".json":
430                self.save_json(p)
431            elif p.suffix == ".md":
432                self.save_markdown(p)
433            elif p.suffix == ".html":
434                self.save_html(p)
435            else:
436                raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
437
438        return file_paths

Save the validation/test summary in JSON, Markdown or HTML format.

Returns:

List of file paths the summary was saved to.

Notes:

  • Format is chosen based on the suffix: .json, .md, .html.
  • If path has no suffix it is assumed to be a direcotry to which a summary.json, summary.md and summary.html are saved to.
def save_json( self, path: pathlib.Path = PosixPath('summary.json'), *, indent: Optional[int] = 2):
440    def save_json(
441        self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
442    ):
443        """Save validation/test summary as JSON file."""
444        json_str = self.model_dump_json(indent=indent)
445        path.parent.mkdir(exist_ok=True, parents=True)
446        _ = path.write_text(json_str, encoding="utf-8")
447        logger.info("Saved summary to {}", path.absolute())

Save validation/test summary as JSON file.

def save_markdown(self, path: pathlib.Path = PosixPath('summary.md')):
449    def save_markdown(self, path: Path = Path("summary.md")):
450        """Save rendered validation/test summary as Markdown file."""
451        formatted = self.format_md()
452        path.parent.mkdir(exist_ok=True, parents=True)
453        _ = path.write_text(formatted, encoding="utf-8")
454        logger.info("Saved Markdown formatted summary to {}", path.absolute())

Save rendered validation/test summary as Markdown file.

def save_html(self, path: pathlib.Path = PosixPath('summary.html')) -> None:
456    def save_html(self, path: Path = Path("summary.html")) -> None:
457        """Save rendered validation/test summary as HTML file."""
458        path.parent.mkdir(exist_ok=True, parents=True)
459
460        html = self.format_html()
461        _ = path.write_text(html, encoding="utf-8")
462        logger.info("Saved HTML formatted summary to {}", path.absolute())

Save rendered validation/test summary as HTML file.

@classmethod
def load_json(cls, path: pathlib.Path) -> Self:
464    @classmethod
465    def load_json(cls, path: Path) -> Self:
466        """Load validation/test summary from a suitable JSON file"""
467        json_str = Path(path).read_text(encoding="utf-8")
468        return cls.model_validate_json(json_str)

Load validation/test summary from a suitable JSON file

model_config: ClassVar[pydantic.config.ConfigDict] = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].