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