bioimageio.spec.summary

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

An error in a ValidationDetail

with_traceback: bool
traceback_md: str
traceback_html: str
traceback_rich
87    @property
88    def traceback_rich(self):
89        return self._traceback_rich
def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
124                    def wrapped_model_post_init(self: BaseModel, context: Any, /) -> None:
125                        """We need to both initialize private attributes and call the user-defined model_post_init
126                        method.
127                        """
128                        init_private_attributes(self, context)
129                        original_model_post_init(self, context)

We need to both initialize private attributes and call the user-defined model_post_init method.

Inherited Members
ValidationEntry
loc
msg
type
class WarningEntry(ValidationEntry):
108class WarningEntry(ValidationEntry):
109    """A warning in a `ValidationDetail`"""
110
111    severity: WarningSeverity = WARNING
112
113    @property
114    def severity_name(self) -> WarningSeverityName:
115        return WARNING_SEVERITY_TO_NAME[self.severity]

A warning in a ValidationDetail

severity: Literal[20, 30, 35]
severity_name: Literal['info', 'warning', 'alert']
113    @property
114    def severity_name(self) -> WarningSeverityName:
115        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:
118def format_loc(
119    loc: Loc, target: Union[Literal["md", "html", "plain"], rich.console.Console]
120) -> str:
121    """helper to format a location tuple **loc**"""
122    loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc))
123
124    # additional field validation can make the location information quite convoluted, e.g.
125    # `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
126    # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here
127    loc_str, *_ = loc_str.split(".function-after")
128    if loc_str:
129        if target == "md" or isinstance(target, rich.console.Console):
130            start = "`"
131            end = "`"
132        elif target == "html":
133            start = "<code>"
134            end = "</code>"
135        elif target == "plain":
136            start = ""
137            end = ""
138        else:
139            assert_never(target)
140
141        return f"{start}{loc_str}{end}"
142    else:
143        return ""

helper to format a location tuple loc

class InstalledPackage(typing.NamedTuple):
146class InstalledPackage(NamedTuple):
147    name: str
148    version: str
149    build: str = ""
150    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):
153class ValidationDetail(BaseModel, extra="allow"):
154    """a detail in a validation summary"""
155
156    name: str
157    status: Literal["passed", "failed"]
158    loc: Loc = ()
159    """location in the RDF that this detail applies to"""
160    errors: List[ErrorEntry] = Field(default_factory=list)
161    warnings: List[WarningEntry] = Field(default_factory=list)
162    context: Optional[ValidationContextSummary] = None
163
164    recommended_env: Optional[CondaEnv] = None
165    """recommended conda environemnt for this validation detail"""
166    conda_compare: Optional[str] = None
167    """output of `conda compare <recommended env>`"""
168
169    def model_post_init(self, __context: Any):
170        """create `conda_compare` default value if needed"""
171        super().model_post_init(__context)
172        if self.recommended_env is None or self.conda_compare is not None:
173            return
174
175        dumped_env = self.recommended_env.model_dump(mode="json")
176        if not is_yaml_value(dumped_env):
177            self.conda_compare = "Failed to dump recommended env to valid yaml"
178            return
179
180        with TemporaryDirectory() as d:
181            path = Path(d) / "env.yaml"
182            with path.open("w", encoding="utf-8") as f:
183                write_yaml(dumped_env, f)
184
185            compare_proc = subprocess.run(
186                ["conda", "compare", str(path)],
187                stdout=subprocess.PIPE,
188                stderr=subprocess.STDOUT,
189                shell=True,
190                text=True,
191            )
192            self.conda_compare = (
193                compare_proc.stdout
194                or f"`conda compare` exited with {compare_proc.returncode}"
195            )
196
197    @property
198    def status_icon(self):
199        if self.status == "passed":
200            return "✔️"
201        else:
202            return "❌"

a detail in a validation summary

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

location in the RDF that this detail applies to

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

recommended conda environemnt for this validation detail

conda_compare: Optional[str]

output of conda compare <recommended env>

def model_post_init(self, _ValidationDetail__context: Any):
169    def model_post_init(self, __context: Any):
170        """create `conda_compare` default value if needed"""
171        super().model_post_init(__context)
172        if self.recommended_env is None or self.conda_compare is not None:
173            return
174
175        dumped_env = self.recommended_env.model_dump(mode="json")
176        if not is_yaml_value(dumped_env):
177            self.conda_compare = "Failed to dump recommended env to valid yaml"
178            return
179
180        with TemporaryDirectory() as d:
181            path = Path(d) / "env.yaml"
182            with path.open("w", encoding="utf-8") as f:
183                write_yaml(dumped_env, f)
184
185            compare_proc = subprocess.run(
186                ["conda", "compare", str(path)],
187                stdout=subprocess.PIPE,
188                stderr=subprocess.STDOUT,
189                shell=True,
190                text=True,
191            )
192            self.conda_compare = (
193                compare_proc.stdout
194                or f"`conda compare` exited with {compare_proc.returncode}"
195            )

create conda_compare default value if needed

status_icon
197    @property
198    def status_icon(self):
199        if self.status == "passed":
200            return "✔️"
201        else:
202            return "❌"
class ValidationSummary(pydantic.main.BaseModel):
205class ValidationSummary(BaseModel, extra="allow"):
206    """Summarizes output of all bioimageio validations and tests
207    for one specific `ResourceDescr` instance."""
208
209    name: str
210    """name of the validation"""
211    source_name: str
212    """source of the validated bioimageio description"""
213    id: Optional[str] = None
214    """ID of the resource being validated"""
215    type: str
216    """type of the resource being validated"""
217    format_version: str
218    """format version of the resource being validated"""
219    status: Literal["passed", "valid-format", "failed"]
220    """overall status of the bioimageio validation"""
221    details: NotEmpty[List[ValidationDetail]]
222    """list of validation details"""
223    env: Set[InstalledPackage] = Field(
224        default_factory=lambda: {
225            InstalledPackage(name="bioimageio.spec", version=VERSION)
226        }
227    )
228    """list of selected, relevant package versions"""
229
230    conda_list: Optional[Sequence[InstalledPackage]] = None
231    """parsed output of conda list"""
232
233    @property
234    def status_icon(self):
235        if self.status == "passed":
236            return "✔️"
237        elif self.status == "valid-format":
238            return "🟡"
239        else:
240            return "❌"
241
242    @property
243    def errors(self) -> List[ErrorEntry]:
244        return list(chain.from_iterable(d.errors for d in self.details))
245
246    @property
247    def warnings(self) -> List[WarningEntry]:
248        return list(chain.from_iterable(d.warnings for d in self.details))
249
250    def format(
251        self,
252        hide_tracebacks: bool = False,
253        hide_source: bool = False,
254        hide_env: bool = False,
255    ):
256        """Format summary as Markdown string"""
257        return self._format(
258            hide_tracebacks=hide_tracebacks,
259            hide_source=hide_source,
260            hide_env=hide_env,
261            target="md",
262        )
263
264    format_md = format
265
266    def format_html(self):
267        md_with_html = self._format(target="html")
268        return markdown.markdown(
269            md_with_html, extensions=["tables", "fenced_code", "nl2br"]
270        )
271
272    # TODO: fix bug which casuses extensive white space between the info table and details table
273    # (the generated markdown seems fine)
274    @no_type_check
275    def display(self) -> None:
276        try:  # render as HTML in Jupyter notebook
277            from IPython.core.getipython import get_ipython
278            from IPython.display import display_html
279        except ImportError:
280            pass
281        else:
282            if get_ipython() is not None:
283                _ = display_html(self.format_html(), raw=True)
284                return
285
286        # render with rich
287        self._format(target=rich.console.Console())
288
289    def add_detail(self, detail: ValidationDetail):
290        if detail.status == "failed":
291            self.status = "failed"
292        elif detail.status != "passed":
293            assert_never(detail.status)
294
295        self.details.append(detail)
296
297    def log(
298        self,
299        to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
300    ) -> List[Path]:
301        """Convenience method to display the validation summary in the terminal and/or
302        save it to disk. See `save` for details."""
303        if to == "display":
304            display = True
305            save_to = []
306        elif isinstance(to, Path):
307            display = False
308            save_to = [to]
309        else:
310            display = "display" in to
311            save_to = [p for p in to if p != "display"]
312
313        if display:
314            self.display()
315
316        return self.save(save_to)
317
318    def save(
319        self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
320    ) -> List[Path]:
321        """Save the validation/test summary in JSON, Markdown or HTML format.
322
323        Returns:
324            List of file paths the summary was saved to.
325
326        Notes:
327        - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
328        - If **path** has no suffix it is assumed to be a direcotry to which a
329          `summary.json`, `summary.md` and `summary.html` are saved to.
330        """
331        if isinstance(path, (str, Path)):
332            path = [Path(path)]
333
334        # folder to file paths
335        file_paths: List[Path] = []
336        for p in path:
337            if p.suffix:
338                file_paths.append(p)
339            else:
340                file_paths.extend(
341                    [
342                        p / "summary.json",
343                        p / "summary.md",
344                        p / "summary.html",
345                    ]
346                )
347
348        now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
349        for p in file_paths:
350            p = Path(str(p).format(id=self.id or "bioimageio", now=now))
351            if p.suffix == ".json":
352                self.save_json(p)
353            elif p.suffix == ".md":
354                self.save_markdown(p)
355            elif p.suffix == ".html":
356                self.save_html(p)
357            else:
358                raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
359
360        return file_paths
361
362    def save_json(
363        self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
364    ):
365        """Save validation/test summary as JSON file."""
366        json_str = self.model_dump_json(indent=indent)
367        path.parent.mkdir(exist_ok=True)
368        _ = path.write_text(json_str, encoding="utf-8")
369        logger.info("Saved summary to {}", path.absolute())
370
371    def save_markdown(self, path: Path = Path("summary.md")):
372        """Save rendered validation/test summary as Markdown file."""
373        formatted = self.format_md()
374        path.parent.mkdir(exist_ok=True)
375        _ = path.write_text(formatted, encoding="utf-8")
376        logger.info("Saved Markdown formatted summary to {}", path.absolute())
377
378    def save_html(self, path: Path = Path("summary.html")) -> None:
379        """Save rendered validation/test summary as HTML file."""
380        path.parent.mkdir(exist_ok=True)
381
382        html = self.format_html()
383        _ = path.write_text(html, encoding="utf-8")
384        logger.info("Saved HTML formatted summary to {}", path.absolute())
385
386    def load_json(self, path: Path) -> Self:
387        """Load validation/test summary from a suitable JSON file"""
388        json_str = path.read_text(encoding="utf-8")
389        return self.model_validate_json(json_str)
390
391    @field_validator("env", mode="before")
392    def _convert_dict(cls, value: List[Union[List[str], Dict[str, str]]]):
393        """convert old env value for backwards compatibility"""
394        if isinstance(value, list):
395            return [
396                (
397                    (v["name"], v["version"], v.get("build", ""), v.get("channel", ""))
398                    if isinstance(v, dict) and "name" in v and "version" in v
399                    else v
400                )
401                for v in value
402            ]
403        else:
404            return value
405
406    def _format(
407        self,
408        *,
409        hide_tracebacks: bool = False,
410        hide_source: bool = False,
411        hide_env: bool = False,
412        target: Union[rich.console.Console, Literal["html", "md"]],
413    ):
414        return _format_summary(
415            self,
416            hide_tracebacks=hide_tracebacks,
417            hide_source=hide_source,
418            hide_env=hide_env,
419            target=target,
420        )

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

name: str

name of the validation

source_name: str

source of the validated bioimageio description

id: Optional[str]

ID of the resource being validated

type: str

type of the resource being validated

format_version: str

format version of the resource being validated

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

overall status of the bioimageio validation

details: Annotated[List[ValidationDetail], MinLen(min_length=1)]

list of validation details

env: Set[InstalledPackage]

list of selected, relevant package versions

conda_list: Optional[Sequence[InstalledPackage]]

parsed output of conda list

status_icon
233    @property
234    def status_icon(self):
235        if self.status == "passed":
236            return "✔️"
237        elif self.status == "valid-format":
238            return "🟡"
239        else:
240            return "❌"
errors: List[ErrorEntry]
242    @property
243    def errors(self) -> List[ErrorEntry]:
244        return list(chain.from_iterable(d.errors for d in self.details))
warnings: List[WarningEntry]
246    @property
247    def warnings(self) -> List[WarningEntry]:
248        return list(chain.from_iterable(d.warnings for d in self.details))
def format( self, hide_tracebacks: bool = False, hide_source: bool = False, hide_env: bool = False):
250    def format(
251        self,
252        hide_tracebacks: bool = False,
253        hide_source: bool = False,
254        hide_env: bool = False,
255    ):
256        """Format summary as Markdown string"""
257        return self._format(
258            hide_tracebacks=hide_tracebacks,
259            hide_source=hide_source,
260            hide_env=hide_env,
261            target="md",
262        )

Format summary as Markdown string

def format_md( self, hide_tracebacks: bool = False, hide_source: bool = False, hide_env: bool = False):
250    def format(
251        self,
252        hide_tracebacks: bool = False,
253        hide_source: bool = False,
254        hide_env: bool = False,
255    ):
256        """Format summary as Markdown string"""
257        return self._format(
258            hide_tracebacks=hide_tracebacks,
259            hide_source=hide_source,
260            hide_env=hide_env,
261            target="md",
262        )

Format summary as Markdown string

def format_html(self):
266    def format_html(self):
267        md_with_html = self._format(target="html")
268        return markdown.markdown(
269            md_with_html, extensions=["tables", "fenced_code", "nl2br"]
270        )
@no_type_check
def display(self) -> None:
274    @no_type_check
275    def display(self) -> None:
276        try:  # render as HTML in Jupyter notebook
277            from IPython.core.getipython import get_ipython
278            from IPython.display import display_html
279        except ImportError:
280            pass
281        else:
282            if get_ipython() is not None:
283                _ = display_html(self.format_html(), raw=True)
284                return
285
286        # render with rich
287        self._format(target=rich.console.Console())
def add_detail(self, detail: ValidationDetail):
289    def add_detail(self, detail: ValidationDetail):
290        if detail.status == "failed":
291            self.status = "failed"
292        elif detail.status != "passed":
293            assert_never(detail.status)
294
295        self.details.append(detail)
def log( self, to: Union[Literal['display'], pathlib.Path, Sequence[Union[Literal['display'], pathlib.Path]]]) -> List[pathlib.Path]:
297    def log(
298        self,
299        to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
300    ) -> List[Path]:
301        """Convenience method to display the validation summary in the terminal and/or
302        save it to disk. See `save` for details."""
303        if to == "display":
304            display = True
305            save_to = []
306        elif isinstance(to, Path):
307            display = False
308            save_to = [to]
309        else:
310            display = "display" in to
311            save_to = [p for p in to if p != "display"]
312
313        if display:
314            self.display()
315
316        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]:
318    def save(
319        self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
320    ) -> List[Path]:
321        """Save the validation/test summary in JSON, Markdown or HTML format.
322
323        Returns:
324            List of file paths the summary was saved to.
325
326        Notes:
327        - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
328        - If **path** has no suffix it is assumed to be a direcotry to which a
329          `summary.json`, `summary.md` and `summary.html` are saved to.
330        """
331        if isinstance(path, (str, Path)):
332            path = [Path(path)]
333
334        # folder to file paths
335        file_paths: List[Path] = []
336        for p in path:
337            if p.suffix:
338                file_paths.append(p)
339            else:
340                file_paths.extend(
341                    [
342                        p / "summary.json",
343                        p / "summary.md",
344                        p / "summary.html",
345                    ]
346                )
347
348        now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
349        for p in file_paths:
350            p = Path(str(p).format(id=self.id or "bioimageio", now=now))
351            if p.suffix == ".json":
352                self.save_json(p)
353            elif p.suffix == ".md":
354                self.save_markdown(p)
355            elif p.suffix == ".html":
356                self.save_html(p)
357            else:
358                raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
359
360        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):
362    def save_json(
363        self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
364    ):
365        """Save validation/test summary as JSON file."""
366        json_str = self.model_dump_json(indent=indent)
367        path.parent.mkdir(exist_ok=True)
368        _ = path.write_text(json_str, encoding="utf-8")
369        logger.info("Saved summary to {}", path.absolute())

Save validation/test summary as JSON file.

def save_markdown(self, path: pathlib.Path = PosixPath('summary.md')):
371    def save_markdown(self, path: Path = Path("summary.md")):
372        """Save rendered validation/test summary as Markdown file."""
373        formatted = self.format_md()
374        path.parent.mkdir(exist_ok=True)
375        _ = path.write_text(formatted, encoding="utf-8")
376        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:
378    def save_html(self, path: Path = Path("summary.html")) -> None:
379        """Save rendered validation/test summary as HTML file."""
380        path.parent.mkdir(exist_ok=True)
381
382        html = self.format_html()
383        _ = path.write_text(html, encoding="utf-8")
384        logger.info("Saved HTML formatted summary to {}", path.absolute())

Save rendered validation/test summary as HTML file.

def load_json(self, path: pathlib.Path) -> Self:
386    def load_json(self, path: Path) -> Self:
387        """Load validation/test summary from a suitable JSON file"""
388        json_str = path.read_text(encoding="utf-8")
389        return self.model_validate_json(json_str)

Load validation/test summary from a suitable JSON file