bioimageio.spec.summary

  1import subprocess
  2from io import StringIO
  3from itertools import chain
  4from pathlib import Path
  5from tempfile import TemporaryDirectory
  6from types import MappingProxyType
  7from typing import (
  8    Any,
  9    Dict,
 10    Iterable,
 11    List,
 12    Literal,
 13    Mapping,
 14    NamedTuple,
 15    Optional,
 16    Sequence,
 17    Set,
 18    Tuple,
 19    Union,
 20    no_type_check,
 21)
 22
 23import rich.console
 24import rich.markdown
 25from pydantic import BaseModel, Field, field_validator, model_validator
 26from pydantic_core.core_schema import ErrorType
 27from typing_extensions import TypedDict, assert_never
 28
 29from ._internal.constants import VERSION
 30from ._internal.io import is_yaml_value
 31from ._internal.io_utils import write_yaml
 32from ._internal.type_guards import is_mapping
 33from ._internal.warning_levels import (
 34    ALERT,
 35    ALERT_NAME,
 36    ERROR,
 37    ERROR_NAME,
 38    INFO,
 39    INFO_NAME,
 40    WARNING,
 41    WARNING_NAME,
 42    WarningLevel,
 43    WarningSeverity,
 44)
 45from .conda_env import CondaEnv
 46
 47Loc = Tuple[Union[int, str], ...]
 48"""location of error/warning in a nested data structure"""
 49
 50WarningSeverityName = Literal["info", "warning", "alert"]
 51WarningLevelName = Literal[WarningSeverityName, "error"]
 52
 53WARNING_SEVERITY_TO_NAME: Mapping[WarningSeverity, WarningSeverityName] = (
 54    MappingProxyType({INFO: INFO_NAME, WARNING: WARNING_NAME, ALERT: ALERT_NAME})
 55)
 56WARNING_LEVEL_TO_NAME: Mapping[WarningLevel, WarningLevelName] = MappingProxyType(
 57    {INFO: INFO_NAME, WARNING: WARNING_NAME, ALERT: ALERT_NAME, ERROR: ERROR_NAME}
 58)
 59WARNING_NAME_TO_LEVEL: Mapping[WarningLevelName, WarningLevel] = MappingProxyType(
 60    {v: k for k, v in WARNING_LEVEL_TO_NAME.items()}
 61)
 62
 63
 64class ValidationEntry(BaseModel):
 65    """Base of `ErrorEntry` and `WarningEntry`"""
 66
 67    loc: Loc
 68    msg: str
 69    type: Union[ErrorType, str]
 70
 71
 72class ErrorEntry(ValidationEntry):
 73    """An error in a `ValidationDetail`"""
 74
 75    traceback: List[str] = Field(default_factory=list)
 76
 77
 78class WarningEntry(ValidationEntry):
 79    """A warning in a `ValidationDetail`"""
 80
 81    severity: WarningSeverity = WARNING
 82    severity_name: WarningSeverityName = WARNING_NAME
 83
 84    @model_validator(mode="before")
 85    @classmethod
 86    def sync_severity_with_severity_name(
 87        cls, data: Union[Mapping[Any, Any], Any]
 88    ) -> Any:
 89        if is_mapping(data):
 90            data = dict(data)
 91            if (
 92                "severity" in data
 93                and "severity_name" not in data
 94                and data["severity"] in WARNING_SEVERITY_TO_NAME
 95            ):
 96                data["severity_name"] = WARNING_SEVERITY_TO_NAME[data["severity"]]
 97
 98            if (
 99                "severity" in data
100                and "severity_name" not in data
101                and data["severity"] in WARNING_SEVERITY_TO_NAME
102            ):
103                data["severity"] = WARNING_NAME_TO_LEVEL[data["severity_name"]]
104
105        return data
106
107
108def format_loc(loc: Loc, enclose_in: str = "`") -> str:
109    """helper to format a location tuple `Loc` as Markdown string"""
110    if not loc:
111        loc = ("__root__",)
112
113    loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc))
114
115    # additional field validation can make the location information quite convoluted, e.g.
116    # `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
117    # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here
118    brief_loc_str, *_ = loc_str.split(".function-after")
119    return f"{enclose_in}{brief_loc_str}{enclose_in}"
120
121
122class InstalledPackage(NamedTuple):
123    name: str
124    version: str
125    build: str = ""
126    channel: str = ""
127
128
129class ValidationContextSummary(TypedDict):
130    perform_io_checks: bool
131    known_files: Mapping[str, str]
132    root: str
133    warning_level: str
134
135
136class ValidationDetail(BaseModel, extra="allow"):
137    """a detail in a validation summary"""
138
139    name: str
140    status: Literal["passed", "failed"]
141    loc: Loc = ()
142    """location in the RDF that this detail applies to"""
143    errors: List[ErrorEntry] = Field(default_factory=list)
144    warnings: List[WarningEntry] = Field(default_factory=list)
145    context: Optional[ValidationContextSummary] = None
146
147    recommended_env: Optional[CondaEnv] = None
148    """recommended conda environemnt for this validation detail"""
149    conda_compare: Optional[str] = None
150    """output of `conda compare <recommended env>`"""
151
152    def model_post_init(self, __context: Any):
153        """create `conda_compare` default value if needed"""
154        super().model_post_init(__context)
155        if self.recommended_env is None or self.conda_compare is not None:
156            return
157
158        dumped_env = self.recommended_env.model_dump(mode="json")
159        if not is_yaml_value(dumped_env):
160            self.conda_compare = "Failed to dump recommended env to valid yaml"
161            return
162
163        with TemporaryDirectory() as d:
164            path = Path(d) / "env.yaml"
165            with path.open("w", encoding="utf-8") as f:
166                write_yaml(dumped_env, f)
167
168            compare_proc = subprocess.run(
169                ["conda", "compare", str(path)],
170                stdout=subprocess.PIPE,
171                stderr=subprocess.STDOUT,
172                shell=True,
173                text=True,
174            )
175            self.conda_compare = (
176                compare_proc.stdout
177                or f"conda compare exited with {compare_proc.returncode}"
178            )
179
180    def __str__(self):
181        return f"{self.__class__.__name__}:\n" + self.format()
182
183    @property
184    def status_icon(self):
185        if self.status == "passed":
186            return "✔️"
187        else:
188            return "❌"
189
190    def format(self, hide_tracebacks: bool = False, root_loc: Loc = ()) -> str:
191        """format as Markdown string"""
192        indent = "    " if root_loc else ""
193        errs_wrns = self._format_errors_and_warnings(
194            hide_tracebacks=hide_tracebacks, root_loc=root_loc
195        )
196        return f"{indent}{self.status_icon} {self.name.strip('.')}: {self.status}{errs_wrns}"
197
198    def _format_errors_and_warnings(self, hide_tracebacks: bool, root_loc: Loc):
199        indent = "    " if root_loc else ""
200        if hide_tracebacks:
201            tbs = [""] * len(self.errors)
202        else:
203            slim_tracebacks = [
204                [tt.replace("\n", "<br>") for t in e.traceback if (tt := t.strip())]
205                for e in self.errors
206            ]
207            tbs = [
208                ("<br>      Traceback:<br>      " if st else "") + "<br>      ".join(st)
209                for st in slim_tracebacks
210            ]
211
212        def join_parts(parts: Iterable[Tuple[str, str]]):
213            last_loc = None
214            lines: List[str] = []
215            for loc, msg in parts:
216                if loc == last_loc:
217                    lines.append(f"<br>  {loc} {msg}")
218                else:
219                    lines.append(f"<br>- {loc} {msg}")
220
221                last_loc = loc
222
223            return "".join(lines)
224
225        es = join_parts(
226            (format_loc(root_loc + e.loc), f"{e.msg}{tb}")
227            for e, tb in zip(self.errors, tbs)
228        )
229        ws = join_parts((format_loc(root_loc + w.loc), w.msg) for w in self.warnings)
230
231        return (
232            f"\n{indent}errors:\n{es}"
233            if es
234            else "" + f"\n{indent}warnings:\n{ws}" if ws else ""
235        )
236
237
238class ValidationSummary(BaseModel, extra="allow"):
239    """Summarizes output of all bioimageio validations and tests
240    for one specific `ResourceDescr` instance."""
241
242    name: str
243    source_name: str
244    type: str
245    format_version: str
246    status: Literal["passed", "failed"]
247    details: List[ValidationDetail]
248    env: Set[InstalledPackage] = Field(
249        default_factory=lambda: {
250            InstalledPackage(name="bioimageio.spec", version=VERSION)
251        }
252    )
253    """list of selected, relevant package versions"""
254
255    conda_list: Optional[Sequence[InstalledPackage]] = None
256    """parsed output of conda list"""
257
258    @property
259    def status_icon(self):
260        if self.status == "passed":
261            return "✔️"
262        else:
263            return "❌"
264
265    @property
266    def errors(self) -> List[ErrorEntry]:
267        return list(chain.from_iterable(d.errors for d in self.details))
268
269    @property
270    def warnings(self) -> List[WarningEntry]:
271        return list(chain.from_iterable(d.warnings for d in self.details))
272
273    def __str__(self):
274        return f"{self.__class__.__name__}:\n" + self.format()
275
276    @staticmethod
277    def _format_md_table(rows: List[List[str]]) -> str:
278        """format `rows` as markdown table"""
279        n_cols = len(rows[0])
280        assert all(len(row) == n_cols for row in rows)
281        col_widths = [max(max(len(row[i]) for row in rows), 3) for i in range(n_cols)]
282
283        # fix new lines in table cell
284        rows = [[line.replace("\n", "<br>") for line in r] for r in rows]
285
286        lines = [" | ".join(rows[0][i].center(col_widths[i]) for i in range(n_cols))]
287        lines.append(" | ".join("---".center(col_widths[i]) for i in range(n_cols)))
288        lines.extend(
289            [
290                " | ".join(row[i].ljust(col_widths[i]) for i in range(n_cols))
291                for row in rows[1:]
292            ]
293        )
294        return "\n| " + " |\n| ".join(lines) + " |\n"
295
296    def format(
297        self,
298        hide_tracebacks: bool = False,
299        hide_source: bool = False,
300        hide_env: bool = False,
301        root_loc: Loc = (),
302    ) -> str:
303        """Format summary as Markdown string
304
305        Suitable to embed in HTML using '<br>' instead of '\n'.
306        """
307        info = self._format_md_table(
308            [[self.status_icon, f"{self.name.strip('.').strip()} {self.status}"]]
309            + ([] if hide_source else [["source", self.source_name]])
310            + [
311                ["format version", f"{self.type} {self.format_version}"],
312            ]
313            + ([] if hide_env else [[e.name, e.version] for e in self.env])
314        )
315
316        def format_loc(loc: Loc):
317            return "`" + (".".join(map(str, root_loc + loc)) or ".") + "`"
318
319        details = [["❓", "location", "detail"]]
320        for d in self.details:
321            details.append([d.status_icon, format_loc(d.loc), d.name])
322            if d.context is not None:
323                details.append(
324                    [
325                        "🔍",
326                        "context.perform_io_checks",
327                        str(d.context["perform_io_checks"]),
328                    ]
329                )
330                if d.context["perform_io_checks"]:
331                    details.append(["🔍", "context.root", d.context["root"]])
332                    for kfn, sha in d.context["known_files"].items():
333                        details.append(["🔍", f"context.known_files.{kfn}", sha])
334
335                details.append(
336                    ["🔍", "context.warning_level", d.context["warning_level"]]
337                )
338
339            if d.recommended_env is not None:
340                rec_env = StringIO()
341                json_env = d.recommended_env.model_dump(
342                    mode="json", exclude_defaults=True
343                )
344                assert is_yaml_value(json_env)
345                write_yaml(json_env, rec_env)
346                rec_env_code = rec_env.getvalue().replace("\n", "</code><br><code>")
347                details.append(
348                    [
349                        "🐍",
350                        format_loc(d.loc),
351                        f"recommended conda env ({d.name})<br>"
352                        + f"<pre><code>{rec_env_code}</code></pre>",
353                    ]
354                )
355
356            if d.conda_compare:
357                details.append(
358                    [
359                        "🐍",
360                        format_loc(d.loc),
361                        "conda compare ({d.name}):<br>"
362                        + d.conda_compare.replace("\n", "<br>"),
363                    ]
364                )
365
366            for entry in d.errors:
367                details.append(
368                    [
369                        "❌",
370                        format_loc(entry.loc),
371                        entry.msg.replace("\n\n", "<br>").replace("\n", "<br>"),
372                    ]
373                )
374                if hide_tracebacks:
375                    continue
376
377                formatted_tb_lines: List[str] = []
378                for tb in entry.traceback:
379                    if not (tb_stripped := tb.strip()):
380                        continue
381
382                    first_tb_line, *tb_lines = tb_stripped.split("\n")
383                    if (
384                        first_tb_line.startswith('File "')
385                        and '", line' in first_tb_line
386                    ):
387                        path, where = first_tb_line[len('File "') :].split('", line')
388                        try:
389                            p = Path(path)
390                        except Exception:
391                            file_name = path
392                        else:
393                            path = p.as_posix()
394                            file_name = p.name
395
396                        where = ", line" + where
397                        first_tb_line = f'[{file_name}]({file_name} "{path}"){where}'
398
399                    if tb_lines:
400                        tb_rest = "<br>`" + "`<br>`".join(tb_lines) + "`"
401                    else:
402                        tb_rest = ""
403
404                    formatted_tb_lines.append(first_tb_line + tb_rest)
405
406                details.append(["", "", "<br>".join(formatted_tb_lines)])
407
408            for entry in d.warnings:
409                details.append(["⚠", format_loc(entry.loc), entry.msg])
410
411        return f"{info}{self._format_md_table(details)}"
412
413    # TODO: fix bug which casuses extensive white space between the info table and details table
414    @no_type_check
415    def display(self) -> None:
416        formatted = self.format()
417        try:
418            from IPython.core.getipython import get_ipython
419            from IPython.display import Markdown, display
420        except ImportError:
421            pass
422        else:
423            if get_ipython() is not None:
424                _ = display(Markdown(formatted))
425                return
426
427        rich_markdown = rich.markdown.Markdown(formatted)
428        console = rich.console.Console()
429        console.print(rich_markdown)
430
431    def add_detail(self, detail: ValidationDetail):
432        if detail.status == "failed":
433            self.status = "failed"
434        elif detail.status != "passed":
435            assert_never(detail.status)
436
437        self.details.append(detail)
438
439    @field_validator("env", mode="before")
440    def _convert_dict(cls, value: List[Union[List[str], Dict[str, str]]]):
441        """convert old env value for backwards compatibility"""
442        if isinstance(value, list):
443            return [
444                (
445                    (v["name"], v["version"], v.get("build", ""), v.get("channel", ""))
446                    if isinstance(v, dict) and "name" in v and "version" in v
447                    else v
448                )
449                for v in value
450            ]
451        else:
452            return value
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):
65class ValidationEntry(BaseModel):
66    """Base of `ErrorEntry` and `WarningEntry`"""
67
68    loc: Loc
69    msg: str
70    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):
73class ErrorEntry(ValidationEntry):
74    """An error in a `ValidationDetail`"""
75
76    traceback: List[str] = Field(default_factory=list)

An error in a ValidationDetail

traceback: List[str]
Inherited Members
ValidationEntry
loc
msg
type
class WarningEntry(ValidationEntry):
 79class WarningEntry(ValidationEntry):
 80    """A warning in a `ValidationDetail`"""
 81
 82    severity: WarningSeverity = WARNING
 83    severity_name: WarningSeverityName = WARNING_NAME
 84
 85    @model_validator(mode="before")
 86    @classmethod
 87    def sync_severity_with_severity_name(
 88        cls, data: Union[Mapping[Any, Any], Any]
 89    ) -> Any:
 90        if is_mapping(data):
 91            data = dict(data)
 92            if (
 93                "severity" in data
 94                and "severity_name" not in data
 95                and data["severity"] in WARNING_SEVERITY_TO_NAME
 96            ):
 97                data["severity_name"] = WARNING_SEVERITY_TO_NAME[data["severity"]]
 98
 99            if (
100                "severity" in data
101                and "severity_name" not in data
102                and data["severity"] in WARNING_SEVERITY_TO_NAME
103            ):
104                data["severity"] = WARNING_NAME_TO_LEVEL[data["severity_name"]]
105
106        return data

A warning in a ValidationDetail

severity: Literal[20, 30, 35]
severity_name: Literal['info', 'warning', 'alert']
@model_validator(mode='before')
@classmethod
def sync_severity_with_severity_name(cls, data: Union[Mapping[Any, Any], Any]) -> Any:
 85    @model_validator(mode="before")
 86    @classmethod
 87    def sync_severity_with_severity_name(
 88        cls, data: Union[Mapping[Any, Any], Any]
 89    ) -> Any:
 90        if is_mapping(data):
 91            data = dict(data)
 92            if (
 93                "severity" in data
 94                and "severity_name" not in data
 95                and data["severity"] in WARNING_SEVERITY_TO_NAME
 96            ):
 97                data["severity_name"] = WARNING_SEVERITY_TO_NAME[data["severity"]]
 98
 99            if (
100                "severity" in data
101                and "severity_name" not in data
102                and data["severity"] in WARNING_SEVERITY_TO_NAME
103            ):
104                data["severity"] = WARNING_NAME_TO_LEVEL[data["severity_name"]]
105
106        return data
Inherited Members
ValidationEntry
loc
msg
type
def format_loc(loc: Tuple[Union[int, str], ...], enclose_in: str = '`') -> str:
109def format_loc(loc: Loc, enclose_in: str = "`") -> str:
110    """helper to format a location tuple `Loc` as Markdown string"""
111    if not loc:
112        loc = ("__root__",)
113
114    loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc))
115
116    # additional field validation can make the location information quite convoluted, e.g.
117    # `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
118    # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here
119    brief_loc_str, *_ = loc_str.split(".function-after")
120    return f"{enclose_in}{brief_loc_str}{enclose_in}"

helper to format a location tuple Loc as Markdown string

class InstalledPackage(typing.NamedTuple):
123class InstalledPackage(NamedTuple):
124    name: str
125    version: str
126    build: str = ""
127    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 ValidationContextSummary(typing_extensions.TypedDict):
130class ValidationContextSummary(TypedDict):
131    perform_io_checks: bool
132    known_files: Mapping[str, str]
133    root: str
134    warning_level: str
perform_io_checks: bool
known_files: Mapping[str, str]
root: str
warning_level: str
class ValidationDetail(pydantic.main.BaseModel):
137class ValidationDetail(BaseModel, extra="allow"):
138    """a detail in a validation summary"""
139
140    name: str
141    status: Literal["passed", "failed"]
142    loc: Loc = ()
143    """location in the RDF that this detail applies to"""
144    errors: List[ErrorEntry] = Field(default_factory=list)
145    warnings: List[WarningEntry] = Field(default_factory=list)
146    context: Optional[ValidationContextSummary] = None
147
148    recommended_env: Optional[CondaEnv] = None
149    """recommended conda environemnt for this validation detail"""
150    conda_compare: Optional[str] = None
151    """output of `conda compare <recommended env>`"""
152
153    def model_post_init(self, __context: Any):
154        """create `conda_compare` default value if needed"""
155        super().model_post_init(__context)
156        if self.recommended_env is None or self.conda_compare is not None:
157            return
158
159        dumped_env = self.recommended_env.model_dump(mode="json")
160        if not is_yaml_value(dumped_env):
161            self.conda_compare = "Failed to dump recommended env to valid yaml"
162            return
163
164        with TemporaryDirectory() as d:
165            path = Path(d) / "env.yaml"
166            with path.open("w", encoding="utf-8") as f:
167                write_yaml(dumped_env, f)
168
169            compare_proc = subprocess.run(
170                ["conda", "compare", str(path)],
171                stdout=subprocess.PIPE,
172                stderr=subprocess.STDOUT,
173                shell=True,
174                text=True,
175            )
176            self.conda_compare = (
177                compare_proc.stdout
178                or f"conda compare exited with {compare_proc.returncode}"
179            )
180
181    def __str__(self):
182        return f"{self.__class__.__name__}:\n" + self.format()
183
184    @property
185    def status_icon(self):
186        if self.status == "passed":
187            return "✔️"
188        else:
189            return "❌"
190
191    def format(self, hide_tracebacks: bool = False, root_loc: Loc = ()) -> str:
192        """format as Markdown string"""
193        indent = "    " if root_loc else ""
194        errs_wrns = self._format_errors_and_warnings(
195            hide_tracebacks=hide_tracebacks, root_loc=root_loc
196        )
197        return f"{indent}{self.status_icon} {self.name.strip('.')}: {self.status}{errs_wrns}"
198
199    def _format_errors_and_warnings(self, hide_tracebacks: bool, root_loc: Loc):
200        indent = "    " if root_loc else ""
201        if hide_tracebacks:
202            tbs = [""] * len(self.errors)
203        else:
204            slim_tracebacks = [
205                [tt.replace("\n", "<br>") for t in e.traceback if (tt := t.strip())]
206                for e in self.errors
207            ]
208            tbs = [
209                ("<br>      Traceback:<br>      " if st else "") + "<br>      ".join(st)
210                for st in slim_tracebacks
211            ]
212
213        def join_parts(parts: Iterable[Tuple[str, str]]):
214            last_loc = None
215            lines: List[str] = []
216            for loc, msg in parts:
217                if loc == last_loc:
218                    lines.append(f"<br>  {loc} {msg}")
219                else:
220                    lines.append(f"<br>- {loc} {msg}")
221
222                last_loc = loc
223
224            return "".join(lines)
225
226        es = join_parts(
227            (format_loc(root_loc + e.loc), f"{e.msg}{tb}")
228            for e, tb in zip(self.errors, tbs)
229        )
230        ws = join_parts((format_loc(root_loc + w.loc), w.msg) for w in self.warnings)
231
232        return (
233            f"\n{indent}errors:\n{es}"
234            if es
235            else "" + f"\n{indent}warnings:\n{ws}" if ws else ""
236        )

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[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):
153    def model_post_init(self, __context: Any):
154        """create `conda_compare` default value if needed"""
155        super().model_post_init(__context)
156        if self.recommended_env is None or self.conda_compare is not None:
157            return
158
159        dumped_env = self.recommended_env.model_dump(mode="json")
160        if not is_yaml_value(dumped_env):
161            self.conda_compare = "Failed to dump recommended env to valid yaml"
162            return
163
164        with TemporaryDirectory() as d:
165            path = Path(d) / "env.yaml"
166            with path.open("w", encoding="utf-8") as f:
167                write_yaml(dumped_env, f)
168
169            compare_proc = subprocess.run(
170                ["conda", "compare", str(path)],
171                stdout=subprocess.PIPE,
172                stderr=subprocess.STDOUT,
173                shell=True,
174                text=True,
175            )
176            self.conda_compare = (
177                compare_proc.stdout
178                or f"conda compare exited with {compare_proc.returncode}"
179            )

create conda_compare default value if needed

status_icon
184    @property
185    def status_icon(self):
186        if self.status == "passed":
187            return "✔️"
188        else:
189            return "❌"
def format( self, hide_tracebacks: bool = False, root_loc: Tuple[Union[int, str], ...] = ()) -> str:
191    def format(self, hide_tracebacks: bool = False, root_loc: Loc = ()) -> str:
192        """format as Markdown string"""
193        indent = "    " if root_loc else ""
194        errs_wrns = self._format_errors_and_warnings(
195            hide_tracebacks=hide_tracebacks, root_loc=root_loc
196        )
197        return f"{indent}{self.status_icon} {self.name.strip('.')}: {self.status}{errs_wrns}"

format as Markdown string

class ValidationSummary(pydantic.main.BaseModel):
239class ValidationSummary(BaseModel, extra="allow"):
240    """Summarizes output of all bioimageio validations and tests
241    for one specific `ResourceDescr` instance."""
242
243    name: str
244    source_name: str
245    type: str
246    format_version: str
247    status: Literal["passed", "failed"]
248    details: List[ValidationDetail]
249    env: Set[InstalledPackage] = Field(
250        default_factory=lambda: {
251            InstalledPackage(name="bioimageio.spec", version=VERSION)
252        }
253    )
254    """list of selected, relevant package versions"""
255
256    conda_list: Optional[Sequence[InstalledPackage]] = None
257    """parsed output of conda list"""
258
259    @property
260    def status_icon(self):
261        if self.status == "passed":
262            return "✔️"
263        else:
264            return "❌"
265
266    @property
267    def errors(self) -> List[ErrorEntry]:
268        return list(chain.from_iterable(d.errors for d in self.details))
269
270    @property
271    def warnings(self) -> List[WarningEntry]:
272        return list(chain.from_iterable(d.warnings for d in self.details))
273
274    def __str__(self):
275        return f"{self.__class__.__name__}:\n" + self.format()
276
277    @staticmethod
278    def _format_md_table(rows: List[List[str]]) -> str:
279        """format `rows` as markdown table"""
280        n_cols = len(rows[0])
281        assert all(len(row) == n_cols for row in rows)
282        col_widths = [max(max(len(row[i]) for row in rows), 3) for i in range(n_cols)]
283
284        # fix new lines in table cell
285        rows = [[line.replace("\n", "<br>") for line in r] for r in rows]
286
287        lines = [" | ".join(rows[0][i].center(col_widths[i]) for i in range(n_cols))]
288        lines.append(" | ".join("---".center(col_widths[i]) for i in range(n_cols)))
289        lines.extend(
290            [
291                " | ".join(row[i].ljust(col_widths[i]) for i in range(n_cols))
292                for row in rows[1:]
293            ]
294        )
295        return "\n| " + " |\n| ".join(lines) + " |\n"
296
297    def format(
298        self,
299        hide_tracebacks: bool = False,
300        hide_source: bool = False,
301        hide_env: bool = False,
302        root_loc: Loc = (),
303    ) -> str:
304        """Format summary as Markdown string
305
306        Suitable to embed in HTML using '<br>' instead of '\n'.
307        """
308        info = self._format_md_table(
309            [[self.status_icon, f"{self.name.strip('.').strip()} {self.status}"]]
310            + ([] if hide_source else [["source", self.source_name]])
311            + [
312                ["format version", f"{self.type} {self.format_version}"],
313            ]
314            + ([] if hide_env else [[e.name, e.version] for e in self.env])
315        )
316
317        def format_loc(loc: Loc):
318            return "`" + (".".join(map(str, root_loc + loc)) or ".") + "`"
319
320        details = [["❓", "location", "detail"]]
321        for d in self.details:
322            details.append([d.status_icon, format_loc(d.loc), d.name])
323            if d.context is not None:
324                details.append(
325                    [
326                        "🔍",
327                        "context.perform_io_checks",
328                        str(d.context["perform_io_checks"]),
329                    ]
330                )
331                if d.context["perform_io_checks"]:
332                    details.append(["🔍", "context.root", d.context["root"]])
333                    for kfn, sha in d.context["known_files"].items():
334                        details.append(["🔍", f"context.known_files.{kfn}", sha])
335
336                details.append(
337                    ["🔍", "context.warning_level", d.context["warning_level"]]
338                )
339
340            if d.recommended_env is not None:
341                rec_env = StringIO()
342                json_env = d.recommended_env.model_dump(
343                    mode="json", exclude_defaults=True
344                )
345                assert is_yaml_value(json_env)
346                write_yaml(json_env, rec_env)
347                rec_env_code = rec_env.getvalue().replace("\n", "</code><br><code>")
348                details.append(
349                    [
350                        "🐍",
351                        format_loc(d.loc),
352                        f"recommended conda env ({d.name})<br>"
353                        + f"<pre><code>{rec_env_code}</code></pre>",
354                    ]
355                )
356
357            if d.conda_compare:
358                details.append(
359                    [
360                        "🐍",
361                        format_loc(d.loc),
362                        "conda compare ({d.name}):<br>"
363                        + d.conda_compare.replace("\n", "<br>"),
364                    ]
365                )
366
367            for entry in d.errors:
368                details.append(
369                    [
370                        "❌",
371                        format_loc(entry.loc),
372                        entry.msg.replace("\n\n", "<br>").replace("\n", "<br>"),
373                    ]
374                )
375                if hide_tracebacks:
376                    continue
377
378                formatted_tb_lines: List[str] = []
379                for tb in entry.traceback:
380                    if not (tb_stripped := tb.strip()):
381                        continue
382
383                    first_tb_line, *tb_lines = tb_stripped.split("\n")
384                    if (
385                        first_tb_line.startswith('File "')
386                        and '", line' in first_tb_line
387                    ):
388                        path, where = first_tb_line[len('File "') :].split('", line')
389                        try:
390                            p = Path(path)
391                        except Exception:
392                            file_name = path
393                        else:
394                            path = p.as_posix()
395                            file_name = p.name
396
397                        where = ", line" + where
398                        first_tb_line = f'[{file_name}]({file_name} "{path}"){where}'
399
400                    if tb_lines:
401                        tb_rest = "<br>`" + "`<br>`".join(tb_lines) + "`"
402                    else:
403                        tb_rest = ""
404
405                    formatted_tb_lines.append(first_tb_line + tb_rest)
406
407                details.append(["", "", "<br>".join(formatted_tb_lines)])
408
409            for entry in d.warnings:
410                details.append(["⚠", format_loc(entry.loc), entry.msg])
411
412        return f"{info}{self._format_md_table(details)}"
413
414    # TODO: fix bug which casuses extensive white space between the info table and details table
415    @no_type_check
416    def display(self) -> None:
417        formatted = self.format()
418        try:
419            from IPython.core.getipython import get_ipython
420            from IPython.display import Markdown, display
421        except ImportError:
422            pass
423        else:
424            if get_ipython() is not None:
425                _ = display(Markdown(formatted))
426                return
427
428        rich_markdown = rich.markdown.Markdown(formatted)
429        console = rich.console.Console()
430        console.print(rich_markdown)
431
432    def add_detail(self, detail: ValidationDetail):
433        if detail.status == "failed":
434            self.status = "failed"
435        elif detail.status != "passed":
436            assert_never(detail.status)
437
438        self.details.append(detail)
439
440    @field_validator("env", mode="before")
441    def _convert_dict(cls, value: List[Union[List[str], Dict[str, str]]]):
442        """convert old env value for backwards compatibility"""
443        if isinstance(value, list):
444            return [
445                (
446                    (v["name"], v["version"], v.get("build", ""), v.get("channel", ""))
447                    if isinstance(v, dict) and "name" in v and "version" in v
448                    else v
449                )
450                for v in value
451            ]
452        else:
453            return value

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

name: str
source_name: str
type: str
format_version: str
status: Literal['passed', 'failed']
details: List[ValidationDetail]
env: Set[InstalledPackage]

list of selected, relevant package versions

conda_list: Optional[Sequence[InstalledPackage]]

parsed output of conda list

status_icon
259    @property
260    def status_icon(self):
261        if self.status == "passed":
262            return "✔️"
263        else:
264            return "❌"
errors: List[ErrorEntry]
266    @property
267    def errors(self) -> List[ErrorEntry]:
268        return list(chain.from_iterable(d.errors for d in self.details))
warnings: List[WarningEntry]
270    @property
271    def warnings(self) -> List[WarningEntry]:
272        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, root_loc: Tuple[Union[int, str], ...] = ()) -> str:
297    def format(
298        self,
299        hide_tracebacks: bool = False,
300        hide_source: bool = False,
301        hide_env: bool = False,
302        root_loc: Loc = (),
303    ) -> str:
304        """Format summary as Markdown string
305
306        Suitable to embed in HTML using '<br>' instead of '\n'.
307        """
308        info = self._format_md_table(
309            [[self.status_icon, f"{self.name.strip('.').strip()} {self.status}"]]
310            + ([] if hide_source else [["source", self.source_name]])
311            + [
312                ["format version", f"{self.type} {self.format_version}"],
313            ]
314            + ([] if hide_env else [[e.name, e.version] for e in self.env])
315        )
316
317        def format_loc(loc: Loc):
318            return "`" + (".".join(map(str, root_loc + loc)) or ".") + "`"
319
320        details = [["❓", "location", "detail"]]
321        for d in self.details:
322            details.append([d.status_icon, format_loc(d.loc), d.name])
323            if d.context is not None:
324                details.append(
325                    [
326                        "🔍",
327                        "context.perform_io_checks",
328                        str(d.context["perform_io_checks"]),
329                    ]
330                )
331                if d.context["perform_io_checks"]:
332                    details.append(["🔍", "context.root", d.context["root"]])
333                    for kfn, sha in d.context["known_files"].items():
334                        details.append(["🔍", f"context.known_files.{kfn}", sha])
335
336                details.append(
337                    ["🔍", "context.warning_level", d.context["warning_level"]]
338                )
339
340            if d.recommended_env is not None:
341                rec_env = StringIO()
342                json_env = d.recommended_env.model_dump(
343                    mode="json", exclude_defaults=True
344                )
345                assert is_yaml_value(json_env)
346                write_yaml(json_env, rec_env)
347                rec_env_code = rec_env.getvalue().replace("\n", "</code><br><code>")
348                details.append(
349                    [
350                        "🐍",
351                        format_loc(d.loc),
352                        f"recommended conda env ({d.name})<br>"
353                        + f"<pre><code>{rec_env_code}</code></pre>",
354                    ]
355                )
356
357            if d.conda_compare:
358                details.append(
359                    [
360                        "🐍",
361                        format_loc(d.loc),
362                        "conda compare ({d.name}):<br>"
363                        + d.conda_compare.replace("\n", "<br>"),
364                    ]
365                )
366
367            for entry in d.errors:
368                details.append(
369                    [
370                        "❌",
371                        format_loc(entry.loc),
372                        entry.msg.replace("\n\n", "<br>").replace("\n", "<br>"),
373                    ]
374                )
375                if hide_tracebacks:
376                    continue
377
378                formatted_tb_lines: List[str] = []
379                for tb in entry.traceback:
380                    if not (tb_stripped := tb.strip()):
381                        continue
382
383                    first_tb_line, *tb_lines = tb_stripped.split("\n")
384                    if (
385                        first_tb_line.startswith('File "')
386                        and '", line' in first_tb_line
387                    ):
388                        path, where = first_tb_line[len('File "') :].split('", line')
389                        try:
390                            p = Path(path)
391                        except Exception:
392                            file_name = path
393                        else:
394                            path = p.as_posix()
395                            file_name = p.name
396
397                        where = ", line" + where
398                        first_tb_line = f'[{file_name}]({file_name} "{path}"){where}'
399
400                    if tb_lines:
401                        tb_rest = "<br>`" + "`<br>`".join(tb_lines) + "`"
402                    else:
403                        tb_rest = ""
404
405                    formatted_tb_lines.append(first_tb_line + tb_rest)
406
407                details.append(["", "", "<br>".join(formatted_tb_lines)])
408
409            for entry in d.warnings:
410                details.append(["⚠", format_loc(entry.loc), entry.msg])
411
412        return f"{info}{self._format_md_table(details)}"

Format summary as Markdown string

    Suitable to embed in HTML using '<br>' instead of '

'.

@no_type_check
def display(self) -> None:
415    @no_type_check
416    def display(self) -> None:
417        formatted = self.format()
418        try:
419            from IPython.core.getipython import get_ipython
420            from IPython.display import Markdown, display
421        except ImportError:
422            pass
423        else:
424            if get_ipython() is not None:
425                _ = display(Markdown(formatted))
426                return
427
428        rich_markdown = rich.markdown.Markdown(formatted)
429        console = rich.console.Console()
430        console.print(rich_markdown)
def add_detail(self, detail: ValidationDetail):
432    def add_detail(self, detail: ValidationDetail):
433        if detail.status == "failed":
434            self.status = "failed"
435        elif detail.status != "passed":
436            assert_never(detail.status)
437
438        self.details.append(detail)