bioimageio.spec.summary

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

An error in a ValidationDetail

with_traceback: bool = False
traceback_md: str = ''
traceback_html: str = ''
traceback_rich
 99    @property
100    def traceback_rich(self):
101        return self._traceback_rich
Inherited Members
ValidationEntry
loc
msg
type
class WarningEntry(ValidationEntry):
122class WarningEntry(ValidationEntry):
123    """A warning in a `ValidationDetail`"""
124
125    severity: WarningSeverity = WARNING
126
127    @property
128    def severity_name(self) -> WarningSeverityName:
129        return WARNING_SEVERITY_TO_NAME[self.severity]

A warning in a ValidationDetail

severity: Literal[20, 30, 35] = 30
severity_name: Literal['info', 'warning', 'alert']
127    @property
128    def severity_name(self) -> WarningSeverityName:
129        return WARNING_SEVERITY_TO_NAME[self.severity]
Inherited Members
ValidationEntry
loc
msg
type
def format_loc( loc: Tuple[Union[int, str], ...], target: Union[Literal['md', 'html', 'plain'], rich.console.Console]) -> str:
132def format_loc(
133    loc: Loc, target: Union[Literal["md", "html", "plain"], rich.console.Console]
134) -> str:
135    """helper to format a location tuple **loc**"""
136    loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc))
137
138    # additional field validation can make the location information quite convoluted, e.g.
139    # `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
140    # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here
141    loc_str, *_ = loc_str.split(".function-after")
142    if loc_str:
143        if target == "md" or isinstance(target, rich.console.Console):
144            start = "`"
145            end = "`"
146        elif target == "html":
147            start = "<code>"
148            end = "</code>"
149        elif target == "plain":
150            start = ""
151            end = ""
152        else:
153            assert_never(target)
154
155        return f"{start}{loc_str}{end}"
156    else:
157        return ""

helper to format a location tuple loc

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

a detail in a validation summary

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

location in the RDF that this detail applies to

errors: List[ErrorEntry] = PydanticUndefined
warnings: List[WarningEntry] = PydanticUndefined
recommended_env: Optional[bioimageio.spec.conda_env.CondaEnv] = None

recommended conda environemnt for this validation detail

saved_conda_compare: Optional[str] = None

output of conda compare <recommended env>

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

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

name: str = PydanticUndefined

Name of the validation

source_name: str = PydanticUndefined

Source of the validated bioimageio description

id: Optional[str] = None

ID of the resource being validated

type: str = PydanticUndefined

Type of the resource being validated

format_version: str = PydanticUndefined

Format version of the resource being validated

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

Overall status of the bioimageio validation

metadata_completeness: Annotated[float, Interval(gt=None, ge=0, lt=None, le=1)] = 0.0

Estimate of completeness of the metadata in the resource description.

Note: This completeness estimate may change with subsequent releases and should be considered bioimageio.spec version specific.

details: List[ValidationDetail] = PydanticUndefined

List of validation details

env: Set[InstalledPackage] = PydanticUndefined

List of selected, relevant package versions

saved_conda_list: Optional[str] = None
conda_list
290    @property
291    def conda_list(self):
292        if self.saved_conda_list is None:
293            p = subprocess.run(
294                [CONDA_CMD, "list"],
295                stdout=subprocess.PIPE,
296                stderr=subprocess.STDOUT,
297                shell=False,
298                text=True,
299            )
300            self.saved_conda_list = (
301                p.stdout or f"`conda list` exited with {p.returncode}"
302            )
303
304        return self.saved_conda_list
status_icon
306    @property
307    def status_icon(self):
308        if self.status == "passed":
309            return "✔️"
310        elif self.status == "valid-format":
311            return "🟡"
312        else:
313            return "❌"
errors: List[ErrorEntry]
315    @property
316    def errors(self) -> List[ErrorEntry]:
317        return list(chain.from_iterable(d.errors for d in self.details))
warnings: List[WarningEntry]
319    @property
320    def warnings(self) -> List[WarningEntry]:
321        return list(chain.from_iterable(d.warnings for d in self.details))
def format( self, *, width: Optional[int] = None, include_conda_list: bool = False):
323    def format(
324        self,
325        *,
326        width: Optional[int] = None,
327        include_conda_list: bool = False,
328    ):
329        """Format summary as Markdown string"""
330        return self._format(
331            width=width, target="md", include_conda_list=include_conda_list
332        )

Format summary as Markdown string

def format_md( self, *, width: Optional[int] = None, include_conda_list: bool = False):
323    def format(
324        self,
325        *,
326        width: Optional[int] = None,
327        include_conda_list: bool = False,
328    ):
329        """Format summary as Markdown string"""
330        return self._format(
331            width=width, target="md", include_conda_list=include_conda_list
332        )

Format summary as Markdown string

def format_html( self, *, width: Optional[int] = None, include_conda_list: bool = False):
336    def format_html(
337        self,
338        *,
339        width: Optional[int] = None,
340        include_conda_list: bool = False,
341    ):
342        md_with_html = self._format(
343            target="html", width=width, include_conda_list=include_conda_list
344        )
345        return markdown.markdown(
346            md_with_html, extensions=["tables", "fenced_code", "nl2br"]
347        )
def display( self, *, width: Optional[int] = None, include_conda_list: bool = False, tab_size: int = 4, soft_wrap: bool = True) -> None:
349    def display(
350        self,
351        *,
352        width: Optional[int] = None,
353        include_conda_list: bool = False,
354        tab_size: int = 4,
355        soft_wrap: bool = True,
356    ) -> None:
357        try:  # render as HTML in Jupyter notebook
358            from IPython.core.getipython import get_ipython
359            from IPython.display import (
360                display_html,  # pyright: ignore[reportUnknownVariableType]
361            )
362        except ImportError:
363            pass
364        else:
365            if get_ipython() is not None:
366                _ = display_html(
367                    self.format_html(
368                        width=width, include_conda_list=include_conda_list
369                    ),
370                    raw=True,
371                )
372                return
373
374        # render with rich
375        _ = self._format(
376            target=rich.console.Console(
377                width=width,
378                tab_size=tab_size,
379                soft_wrap=soft_wrap,
380            ),
381            width=width,
382            include_conda_list=include_conda_list,
383        )
def add_detail( self, detail: ValidationDetail, update_status: bool = True):
385    def add_detail(self, detail: ValidationDetail, update_status: bool = True):
386        if update_status:
387            if self.status == "valid-format" and detail.status == "passed":
388                # once status is 'valid-format' we can only improve to 'passed'
389                self.status = "passed"
390            elif self.status == "passed" and detail.status == "failed":
391                # once status is 'passed' it can only degrade to 'valid-format'
392                self.status = "valid-format"
393            # once format is 'failed' it cannot improve
394
395        self.details.append(detail)
def log( self, to: Union[Literal['display'], pathlib.Path, Sequence[Union[Literal['display'], pathlib.Path]]]) -> List[pathlib.Path]:
397    def log(
398        self,
399        to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
400    ) -> List[Path]:
401        """Convenience method to display the validation summary in the terminal and/or
402        save it to disk. See `save` for details."""
403        if to == "display":
404            display = True
405            save_to = []
406        elif isinstance(to, Path):
407            display = False
408            save_to = [to]
409        else:
410            display = "display" in to
411            save_to = [p for p in to if p != "display"]
412
413        if display:
414            self.display()
415
416        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]:
418    def save(
419        self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
420    ) -> List[Path]:
421        """Save the validation/test summary in JSON, Markdown or HTML format.
422
423        Returns:
424            List of file paths the summary was saved to.
425
426        Notes:
427        - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
428        - If **path** has no suffix it is assumed to be a direcotry to which a
429          `summary.json`, `summary.md` and `summary.html` are saved to.
430        """
431        if isinstance(path, (str, Path)):
432            path = [Path(path)]
433
434        # folder to file paths
435        file_paths: List[Path] = []
436        for p in path:
437            if p.suffix:
438                file_paths.append(p)
439            else:
440                file_paths.extend(
441                    [
442                        p / "summary.json",
443                        p / "summary.md",
444                        p / "summary.html",
445                    ]
446                )
447
448        now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
449        for p in file_paths:
450            p = Path(str(p).format(id=self.id or "bioimageio", now=now))
451            if p.suffix == ".json":
452                self.save_json(p)
453            elif p.suffix == ".md":
454                self.save_markdown(p)
455            elif p.suffix == ".html":
456                self.save_html(p)
457            else:
458                raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
459
460        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):
462    def save_json(
463        self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
464    ):
465        """Save validation/test summary as JSON file."""
466        json_str = self.model_dump_json(indent=indent)
467        path.parent.mkdir(exist_ok=True, parents=True)
468        _ = path.write_text(json_str, encoding="utf-8")
469        logger.info("Saved summary to {}", path.absolute())

Save validation/test summary as JSON file.

def save_markdown(self, path: pathlib.Path = PosixPath('summary.md')):
471    def save_markdown(self, path: Path = Path("summary.md")):
472        """Save rendered validation/test summary as Markdown file."""
473        formatted = self.format_md()
474        path.parent.mkdir(exist_ok=True, parents=True)
475        _ = path.write_text(formatted, encoding="utf-8")
476        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:
478    def save_html(self, path: Path = Path("summary.html")) -> None:
479        """Save rendered validation/test summary as HTML file."""
480        path.parent.mkdir(exist_ok=True, parents=True)
481
482        html = self.format_html()
483        _ = path.write_text(html, encoding="utf-8")
484        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:
486    @classmethod
487    def load_json(cls, path: Path) -> Self:
488        """Load validation/test summary from a suitable JSON file"""
489        json_str = Path(path).read_text(encoding="utf-8")
490        return cls.model_validate_json(json_str)

Load validation/test summary from a suitable JSON file