Coverage for bioimageio/spec/summary.py: 67%
380 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-11 07:34 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-11 07:34 +0000
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)
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
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
60Loc = Tuple[Union[int, str], ...]
61"""location of error/warning in a nested data structure"""
63WarningSeverityName = Literal["info", "warning", "alert"]
64WarningLevelName = Literal[WarningSeverityName, "error"]
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)
77class ValidationEntry(BaseModel):
78 """Base of `ErrorEntry` and `WarningEntry`"""
80 loc: Loc
81 msg: str
82 type: Union[ErrorType, str]
85class ErrorEntry(ValidationEntry):
86 """An error in a `ValidationDetail`"""
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
94 @property
95 def traceback_rich(self):
96 return self._traceback_rich
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)
113 if not self.traceback_html:
114 self.traceback_html = console.export_html(clear=False)
117class WarningEntry(ValidationEntry):
118 """A warning in a `ValidationDetail`"""
120 severity: WarningSeverity = WARNING
122 @property
123 def severity_name(self) -> WarningSeverityName:
124 return WARNING_SEVERITY_TO_NAME[self.severity]
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))
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)
150 return f"{start}{loc_str}{end}"
151 else:
152 return ""
155class InstalledPackage(NamedTuple):
156 name: str
157 version: str
158 build: str = ""
159 channel: str = ""
162class ValidationDetail(BaseModel, extra="allow"):
163 """a detail in a validation summary"""
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
177 recommended_env: Optional[CondaEnv] = None
178 """recommended conda environemnt for this validation detail"""
180 saved_conda_compare: Optional[str] = None
181 """output of `conda compare <recommended env>`"""
183 @field_serializer("saved_conda_compare")
184 def _save_conda_compare(self, value: Optional[str]):
185 return self.conda_compare
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)
197 return data
199 @property
200 def conda_compare(self) -> Optional[str]:
201 if self.recommended_env is None:
202 return None
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)
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 )
228 return self.saved_conda_compare
230 @property
231 def status_icon(self):
232 if self.status == "passed":
233 return "✔️"
234 else:
235 return "❌"
238class ValidationSummary(BaseModel, extra="allow"):
239 """Summarizes output of all bioimageio validations and tests
240 for one specific `ResourceDescr` instance."""
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.
257 Note: This completeness estimate may change with subsequent releases
258 and should be considered bioimageio.spec version specific.
259 """
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"""
273 saved_conda_list: Optional[str] = None
275 @field_serializer("saved_conda_list")
276 def _save_conda_list(self, value: Optional[str]):
277 return self.conda_list
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 )
293 return self.saved_conda_list
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 "❌"
304 @property
305 def errors(self) -> List[ErrorEntry]:
306 return list(chain.from_iterable(d.errors for d in self.details))
308 @property
309 def warnings(self) -> List[WarningEntry]:
310 return list(chain.from_iterable(d.warnings for d in self.details))
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 )
323 format_md = format
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 )
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
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 )
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)
380 self.details.append(detail)
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"]
398 if display:
399 self.display()
401 return self.save(save_to)
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.
408 Returns:
409 List of file paths the summary was saved to.
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)]
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 )
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}'")
445 return file_paths
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())
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())
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)
467 html = self.format_html()
468 _ = path.write_text(html, encoding="utf-8")
469 logger.info("Saved HTML formatted summary to {}", path.absolute())
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)
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
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 )
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"
523 def add_part(part: str):
524 parts.append(part)
525 if isinstance(target, rich.console.Console):
526 target.print(rich.markdown.Markdown(part))
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)
536 def header_to_tag(header: str):
537 return (
538 header.replace("`", "")
539 .replace("(", "")
540 .replace(")", "")
541 .replace(" ", "-")
542 .lower()
543 )
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"""
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)
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)
570 nonlocal left_out_details
571 left_out_details += 1
572 return make_link(left_out_details_header)
574 @dataclass
575 class CodeCell:
576 text: str
578 @dataclass
579 class CodeRef:
580 text: str
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("")
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"
603 if put_below:
604 link = add_as_details_below(title, code)
605 return CodeRef(f"See {link}.")
606 else:
607 return CodeCell(code)
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}.")
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)
626 def format_text(text: str):
627 if target == "html":
628 return [f"<pre>{text}</pre>"]
629 else:
630 return text.split("\n")
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])
640 if summary.id is not None:
641 info_rows.append(["id", summary.id])
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])
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)
653 def get_details_table():
654 details = [["", "Location", "Details"]]
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])
667 if code is not None:
668 details.append(["", "", code.text])
670 for d in summary.details:
671 details.append([d.status_icon, format_loc(d.loc, target), d.name])
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 )
681 for entry in d.warnings:
682 append_detail("⚠", entry.loc, entry.msg, None)
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 )
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 )
716 return format_table(details)
718 add_part(get_info_table())
719 add_part(get_details_table())
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")
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 )
736 return "".join(parts)
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)]
745 # fix new lines in table cell
746 rows = [[line.replace("\n", "<br>") for line in r] for r in rows]
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"
759def _format_html_table(rows: List[List[str]]) -> str:
760 """format `rows` as HTML table"""
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 )
769 table = ["<table>"] + get_line(rows[0], cell_tag="th")
770 for r in rows[1:]:
771 table.extend(get_line(r))
773 table.append("</table>")
775 return "\n".join(table)