bioimageio.spec.summary

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

An error in a ValidationDetail

with_traceback: bool
traceback_md: str
traceback_html: str
traceback_rich
95    @property
96    def traceback_rich(self):
97        return self._traceback_rich
def model_post_init(self, _ErrorEntry__context: Any):
 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)

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):
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]

A warning in a ValidationDetail

severity: Literal[20, 30, 35]
severity_name: Literal['info', 'warning', 'alert']
123    @property
124    def severity_name(self) -> WarningSeverityName:
125        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:
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 ""

helper to format a location tuple loc

class InstalledPackage(typing.NamedTuple):
156class InstalledPackage(NamedTuple):
157    name: str
158    version: str
159    build: str = ""
160    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):
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 "❌"

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

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

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

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]

List of validation details

env: Set[InstalledPackage]

List of selected, relevant package versions

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

Format summary as Markdown string

def format_md( self, *, width: Optional[int] = None, include_conda_list: bool = False):
313    def format(
314        self,
315        *,
316        width: Optional[int] = None,
317        include_conda_list: bool = False,
318    ):
319        """Format summary as Markdown string"""
320        return self._format(
321            width=width, target="md", include_conda_list=include_conda_list
322        )

Format summary as Markdown string

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

Save validation/test summary as JSON file.

def save_markdown(self, path: pathlib.Path = PosixPath('summary.md')):
457    def save_markdown(self, path: Path = Path("summary.md")):
458        """Save rendered validation/test summary as Markdown file."""
459        formatted = self.format_md()
460        path.parent.mkdir(exist_ok=True, parents=True)
461        _ = path.write_text(formatted, encoding="utf-8")
462        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:
464    def save_html(self, path: Path = Path("summary.html")) -> None:
465        """Save rendered validation/test summary as HTML file."""
466        path.parent.mkdir(exist_ok=True, parents=True)
467
468        html = self.format_html()
469        _ = path.write_text(html, encoding="utf-8")
470        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:
472    @classmethod
473    def load_json(cls, path: Path) -> Self:
474        """Load validation/test summary from a suitable JSON file"""
475        json_str = Path(path).read_text(encoding="utf-8")
476        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].