Coverage for src/bioimageio/spec/summary.py: 66%

396 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 12:40 +0000

1"""Utilities for summarizing and formatting BioImage.IO validation results. 

2 

3This module defines data structures to capture validation errors, warnings, 

4and summaries for BioImage.IO resource descriptions, along with helpers to 

5format these results as plain text, Markdown, or HTML for reporting and 

6diagnostics. 

7""" 

8 

9import html 

10import os 

11import platform 

12import subprocess 

13from dataclasses import dataclass 

14from datetime import datetime, timezone 

15from io import StringIO 

16from itertools import chain 

17from pathlib import Path 

18from tempfile import TemporaryDirectory 

19from textwrap import TextWrapper 

20from types import MappingProxyType 

21from typing import ( 

22 Any, 

23 Callable, 

24 Dict, 

25 List, 

26 Literal, 

27 Mapping, 

28 NamedTuple, 

29 Optional, 

30 Sequence, 

31 Set, 

32 Tuple, 

33 Union, 

34) 

35 

36import annotated_types 

37import markdown 

38import rich.console 

39import rich.markdown 

40import rich.traceback 

41from loguru import logger 

42from pydantic import ( 

43 BaseModel, 

44 Field, 

45 field_serializer, 

46 field_validator, 

47 model_validator, 

48) 

49from pydantic_core.core_schema import ErrorType 

50from typing_extensions import Annotated, Self, assert_never, cast 

51 

52from ._internal.io import is_yaml_value 

53from ._internal.io_utils import write_yaml 

54from ._internal.type_guards import is_dict 

55from ._internal.validation_context import ValidationContextSummary 

56from ._internal.version_type import Version 

57from ._internal.warning_levels import ( 

58 ALERT, 

59 ALERT_NAME, 

60 ERROR, 

61 ERROR_NAME, 

62 INFO, 

63 INFO_NAME, 

64 WARNING, 

65 WARNING_NAME, 

66 WarningLevel, 

67 WarningSeverity, 

68) 

69from ._version import VERSION 

70from .conda_env import CondaEnv 

71 

72CONDA_CMD = "conda.bat" if platform.system() == "Windows" else "conda" 

73 

74Loc = Tuple[Union[int, str], ...] 

75"""location of error/warning in a nested data structure""" 

76 

77WarningSeverityName = Literal["info", "warning", "alert"] 

78WarningLevelName = Literal[WarningSeverityName, "error"] 

79 

80WARNING_SEVERITY_TO_NAME: Mapping[WarningSeverity, WarningSeverityName] = ( 

81 MappingProxyType({INFO: INFO_NAME, WARNING: WARNING_NAME, ALERT: ALERT_NAME}) 

82) 

83WARNING_LEVEL_TO_NAME: Mapping[WarningLevel, WarningLevelName] = MappingProxyType( 

84 {INFO: INFO_NAME, WARNING: WARNING_NAME, ALERT: ALERT_NAME, ERROR: ERROR_NAME} 

85) 

86WARNING_NAME_TO_LEVEL: Mapping[WarningLevelName, WarningLevel] = MappingProxyType( 

87 {v: k for k, v in WARNING_LEVEL_TO_NAME.items()} 

88) 

89 

90 

91class ValidationEntry(BaseModel): 

92 """Base of `ErrorEntry` and `WarningEntry`""" 

93 

94 loc: Loc 

95 msg: str 

96 type: Union[ErrorType, str] 

97 

98 

99class ErrorEntry(ValidationEntry): 

100 """An error in a `ValidationDetail`""" 

101 

102 with_traceback: bool = False 

103 traceback_md: str = "" 

104 traceback_html: str = "" 

105 # private rich traceback that is not serialized 

106 _traceback_rich: Optional[rich.traceback.Traceback] = None 

107 

108 @property 

109 def traceback_rich(self) -> Optional[rich.traceback.Traceback]: 

110 return self._traceback_rich 

111 

112 def model_post_init(self, __context: Any): 

113 if self.with_traceback and not (self.traceback_md or self.traceback_html): 

114 self._traceback_rich = rich.traceback.Traceback() 

115 console = rich.console.Console( 

116 record=True, 

117 file=open(os.devnull, "wt", encoding="utf-8"), 

118 color_system="truecolor", 

119 width=120, 

120 tab_size=4, 

121 soft_wrap=True, 

122 ) 

123 console.print(self._traceback_rich) 

124 if not self.traceback_md: 

125 self.traceback_md = console.export_text(clear=False) 

126 

127 if not self.traceback_html: 

128 self.traceback_html = console.export_html(clear=False) 

129 

130 

131class WarningEntry(ValidationEntry): 

132 """A warning in a `ValidationDetail`""" 

133 

134 severity: WarningSeverity = WARNING 

135 

136 @property 

137 def severity_name(self) -> WarningSeverityName: 

138 return WARNING_SEVERITY_TO_NAME[self.severity] 

139 

140 

141def format_loc( 

142 loc: Loc, target: Union[Literal["md", "html", "plain"], rich.console.Console] 

143) -> str: 

144 """helper to format a location tuple **loc**""" 

145 loc_str = ".".join(f"({x})" if x[0].isupper() else x for x in map(str, loc)) 

146 

147 # additional field validation can make the location information quite convoluted, e.g. 

148 # `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 

149 # therefore we remove the `.function-after[validate_url_ok(), url['http','https']]` here 

150 loc_str, *_ = loc_str.split(".function-after") 

151 if loc_str: 

152 if target == "md" or isinstance(target, rich.console.Console): 

153 start = "`" 

154 end = "`" 

155 elif target == "html": 

156 start = "<code>" 

157 end = "</code>" 

158 elif target == "plain": 

159 start = "" 

160 end = "" 

161 else: 

162 assert_never(target) 

163 

164 return f"{start}{loc_str}{end}" 

165 else: 

166 return "" 

167 

168 

169class InstalledPackage(NamedTuple): 

170 name: str 

171 version: str 

172 build: str = "" 

173 channel: str = "" 

174 

175 

176class ValidationDetail(BaseModel, extra="allow"): 

177 """a detail in a validation summary""" 

178 

179 name: str 

180 status: Literal["passed", "failed"] 

181 loc: Loc = () 

182 """location in the RDF that this detail applies to""" 

183 errors: List[ErrorEntry] = Field( 

184 default_factory=cast(Callable[[], List[ErrorEntry]], list) 

185 ) 

186 warnings: List[WarningEntry] = Field( 

187 default_factory=cast(Callable[[], List[WarningEntry]], list) 

188 ) 

189 

190 context: Optional[ValidationContextSummary] = None 

191 

192 recommended_env: Optional[CondaEnv] = None 

193 """recommended conda environemnt for this validation detail""" 

194 

195 saved_conda_compare: Optional[str] = None 

196 """output of `conda compare <recommended env>`""" 

197 

198 @field_serializer("saved_conda_compare") 

199 def _save_conda_compare(self, value: Optional[str]): 

200 return self.conda_compare 

201 

202 @model_validator(mode="before") 

203 def _load_legacy(cls, data: Any): 

204 if is_dict(data): 

205 field_name = "conda_compare" 

206 if ( 

207 field_name in data 

208 and (saved_field_name := f"saved_{field_name}") not in data 

209 ): 

210 data[saved_field_name] = data.pop(field_name) 

211 

212 return data 

213 

214 @property 

215 def conda_compare(self) -> Optional[str]: 

216 if self.recommended_env is None: 

217 return None 

218 

219 if self.saved_conda_compare is None: 

220 dumped_env = self.recommended_env.model_dump(mode="json") 

221 if is_yaml_value(dumped_env): 

222 with TemporaryDirectory() as d: 

223 path = Path(d) / "env.yaml" 

224 with path.open("w", encoding="utf-8") as f: 

225 write_yaml(dumped_env, f) 

226 

227 try: 

228 compare_proc = subprocess.run( 

229 [CONDA_CMD, "compare", str(path)], 

230 stdout=subprocess.PIPE, 

231 stderr=subprocess.STDOUT, 

232 shell=False, 

233 text=True, 

234 ) 

235 except Exception as e: 

236 self.saved_conda_compare = f"Failed to run `conda compare`: {e}" 

237 else: 

238 self.saved_conda_compare = ( 

239 compare_proc.stdout 

240 or f"`conda compare` exited with {compare_proc.returncode}" 

241 ) 

242 else: 

243 self.saved_conda_compare = ( 

244 "Failed to dump recommended env to valid yaml" 

245 ) 

246 

247 return self.saved_conda_compare 

248 

249 @property 

250 def status_icon(self) -> str: 

251 if self.status == "passed": 

252 return "✔️" 

253 else: 

254 return "❌" 

255 

256 

257class ValidationSummary(BaseModel, extra="allow"): 

258 """Summarizes output of all bioimageio validations and tests 

259 for one specific `ResourceDescr` instance.""" 

260 

261 name: str 

262 """Name of the validation""" 

263 

264 source_name: str 

265 """Source of the validated bioimageio description""" 

266 

267 id: Optional[str] = None 

268 """ID of the validated resource""" 

269 

270 version: Optional[Version] = None 

271 """Version of the validated resource""" 

272 

273 type: str 

274 """Type of the validated resource""" 

275 

276 format_version: str 

277 """Format version of the validated resource""" 

278 

279 status: Literal["passed", "valid-format", "failed"] 

280 """Overall status of the bioimageio validation""" 

281 

282 metadata_completeness: Annotated[float, annotated_types.Interval(ge=0, le=1)] = 0.0 

283 """Estimate of completeness of the metadata in the resource description. 

284 

285 Note: This completeness estimate may change with subsequent releases 

286 and should be considered bioimageio.spec version specific. 

287 """ 

288 

289 details: List[ValidationDetail] 

290 """List of validation details""" 

291 env: Set[InstalledPackage] = Field( 

292 default_factory=lambda: { 

293 InstalledPackage( 

294 name="bioimageio.spec", 

295 version=VERSION, 

296 ) 

297 } 

298 ) 

299 """List of selected, relevant package versions""" 

300 

301 saved_conda_list: Optional[str] = None 

302 

303 @field_serializer("saved_conda_list") 

304 def _save_conda_list(self, value: Optional[str]): 

305 return self.conda_list 

306 

307 @property 

308 def conda_list(self) -> str: 

309 if self.saved_conda_list is None: 

310 try: 

311 p = subprocess.run( 

312 [CONDA_CMD, "list"], 

313 stdout=subprocess.PIPE, 

314 stderr=subprocess.STDOUT, 

315 shell=False, 

316 text=True, 

317 ) 

318 except Exception as e: 

319 self.saved_conda_list = f"Failed to run `conda list`: {e}" 

320 else: 

321 self.saved_conda_list = ( 

322 p.stdout or f"`conda list` exited with {p.returncode}" 

323 ) 

324 

325 return self.saved_conda_list 

326 

327 @property 

328 def status_icon(self) -> str: 

329 if self.status == "passed": 

330 return "✔️" 

331 elif self.status == "valid-format": 

332 return "🟡" 

333 else: 

334 return "❌" 

335 

336 @property 

337 def errors(self) -> List[ErrorEntry]: 

338 return list(chain.from_iterable(d.errors for d in self.details)) 

339 

340 @property 

341 def warnings(self) -> List[WarningEntry]: 

342 return list(chain.from_iterable(d.warnings for d in self.details)) 

343 

344 def format( 

345 self, 

346 *, 

347 width: Optional[int] = None, 

348 include_conda_list: bool = False, 

349 ) -> str: 

350 """Format summary as Markdown string (alias to `format_md`)""" 

351 return self.format_md(width=width, include_conda_list=include_conda_list) 

352 

353 def format_md( 

354 self, 

355 *, 

356 width: Optional[int] = None, 

357 include_conda_list: bool = False, 

358 ) -> str: 

359 """Format summary as Markdown string""" 

360 return self._format( 

361 width=width, target="md", include_conda_list=include_conda_list 

362 ) 

363 

364 def format_html( 

365 self, 

366 *, 

367 width: Optional[int] = None, 

368 include_conda_list: bool = False, 

369 ) -> str: 

370 md_with_html = self._format( 

371 target="html", width=width, include_conda_list=include_conda_list 

372 ) 

373 return markdown.markdown( 

374 md_with_html, extensions=["tables", "fenced_code", "nl2br"] 

375 ) 

376 

377 def display( 

378 self, 

379 *, 

380 width: Optional[int] = None, 

381 include_conda_list: bool = False, 

382 tab_size: int = 4, 

383 soft_wrap: bool = True, 

384 ) -> None: 

385 try: # render as HTML in Jupyter notebook 

386 from IPython.core.getipython import get_ipython 

387 from IPython.display import ( 

388 display_html, # pyright: ignore[reportUnknownVariableType] 

389 ) 

390 except ImportError: 

391 pass 

392 else: 

393 if get_ipython() is not None: 

394 _ = display_html( 

395 self.format_html( 

396 width=width, include_conda_list=include_conda_list 

397 ), 

398 raw=True, 

399 ) 

400 return 

401 

402 # render with rich 

403 _ = self._format( 

404 target=rich.console.Console( 

405 width=width, 

406 tab_size=tab_size, 

407 soft_wrap=soft_wrap, 

408 ), 

409 width=width, 

410 include_conda_list=include_conda_list, 

411 ) 

412 

413 def add_detail( 

414 self, 

415 detail: ValidationDetail, 

416 update_status: bool = True, 

417 ) -> None: 

418 """add a validation detail to the summary and, if `update_status` is True, 

419 possibly downgrade the overall status from "passed" to "valid-format" 

420 """ 

421 if detail in self.details: 

422 return 

423 

424 # An overall status 'passed' can degrade to 'valid-format' 

425 # The status can not be upgraded here as we do not know if it has been downgraded before or not yet tested. 

426 # Once status is 'failed' it cannot change anymore. 

427 if update_status and self.status == "passed" and detail.status == "failed": 

428 # "passed" -> "valid-format" 

429 self.status = "valid-format" 

430 

431 self.details.append(detail) 

432 

433 def log( 

434 self, 

435 to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]], 

436 ) -> List[Path]: 

437 """Convenience method to display the validation summary in the terminal and/or 

438 save it to disk. See `save` for details.""" 

439 if to == "display": 

440 display = True 

441 save_to = [] 

442 elif isinstance(to, Path): 

443 display = False 

444 save_to = [to] 

445 else: 

446 display = "display" in to 

447 save_to = [p for p in to if p != "display"] 

448 

449 if display: 

450 self.display() 

451 

452 return self.save(save_to) 

453 

454 def save( 

455 self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}") 

456 ) -> List[Path]: 

457 """Save the validation/test summary in JSON, Markdown or HTML format. 

458 

459 Returns: 

460 List of file paths the summary was saved to. 

461 

462 Notes: 

463 - Format is chosen based on the suffix: `.json`, `.md`, `.html`. 

464 - If **path** has no suffix it is assumed to be a direcotry to which a 

465 `summary.json`, `summary.md` and `summary.html` are saved to. 

466 """ 

467 if isinstance(path, (str, Path)): 

468 path = [Path(path)] 

469 

470 # folder to file paths 

471 file_paths: List[Path] = [] 

472 for p in path: 

473 if p.suffix: 

474 file_paths.append(p) 

475 else: 

476 file_paths.extend( 

477 [ 

478 p / "summary.json", 

479 p / "summary.md", 

480 p / "summary.html", 

481 ] 

482 ) 

483 

484 now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") 

485 for p in file_paths: 

486 p = Path(str(p).format(id=self.id or "bioimageio", now=now)) 

487 if p.suffix == ".json": 

488 self.save_json(p) 

489 elif p.suffix == ".md": 

490 self.save_markdown(p) 

491 elif p.suffix == ".html": 

492 self.save_html(p) 

493 else: 

494 raise ValueError(f"Unknown summary path suffix '{p.suffix}'") 

495 

496 return file_paths 

497 

498 def save_json( 

499 self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2 

500 ) -> None: 

501 """Save validation/test summary as JSON file.""" 

502 json_str = self.model_dump_json(indent=indent) 

503 path.parent.mkdir(exist_ok=True, parents=True) 

504 _ = path.write_text(json_str, encoding="utf-8") 

505 logger.info("Saved summary to {}", path.absolute()) 

506 

507 def save_markdown(self, path: Path = Path("summary.md")) -> None: 

508 """Save rendered validation/test summary as Markdown file.""" 

509 formatted = self.format_md() 

510 path.parent.mkdir(exist_ok=True, parents=True) 

511 _ = path.write_text(formatted, encoding="utf-8") 

512 logger.info("Saved Markdown formatted summary to {}", path.absolute()) 

513 

514 def save_html(self, path: Path = Path("summary.html")) -> None: 

515 """Save rendered validation/test summary as HTML file.""" 

516 path.parent.mkdir(exist_ok=True, parents=True) 

517 

518 html = self.format_html() 

519 _ = path.write_text(html, encoding="utf-8") 

520 logger.info("Saved HTML formatted summary to {}", path.absolute()) 

521 

522 @classmethod 

523 def load_json(cls, path: Path) -> Self: 

524 """Load validation/test summary from a suitable JSON file""" 

525 json_str = Path(path).read_text(encoding="utf-8") 

526 return cls.model_validate_json(json_str) 

527 

528 @field_validator("env", mode="before") 

529 def _convert_old_env(cls, value: List[Union[List[str], Dict[str, str]]]): 

530 """convert old style dict values of `env` for backwards compatibility""" 

531 if isinstance(value, list): 

532 return [ 

533 ( 

534 (v["name"], v["version"], v.get("build", ""), v.get("channel", "")) 

535 if isinstance(v, dict) and "name" in v and "version" in v 

536 else v 

537 ) 

538 for v in value 

539 ] 

540 else: 

541 return value 

542 

543 def _format( 

544 self, 

545 *, 

546 target: Union[rich.console.Console, Literal["html", "md"]], 

547 width: Optional[int], 

548 include_conda_list: bool, 

549 ) -> str: 

550 return _format_summary( 

551 self, 

552 target=target, 

553 width=width or 100, 

554 include_conda_list=include_conda_list, 

555 ) 

556 

557 

558def _format_summary( 

559 summary: ValidationSummary, 

560 *, 

561 hide_tracebacks: bool = False, # TODO: remove? 

562 hide_source: bool = False, # TODO: remove? 

563 hide_env: bool = False, # TODO: remove? 

564 target: Union[rich.console.Console, Literal["html", "md"]] = "md", 

565 include_conda_list: bool, 

566 width: int, 

567) -> str: 

568 parts: List[str] = [] 

569 format_table = _format_html_table if target == "html" else _format_md_table 

570 details_below: Dict[str, Union[str, Tuple[str, rich.traceback.Traceback]]] = {} 

571 left_out_details: int = 0 

572 left_out_details_header = "Left out details" 

573 

574 def add_part(part: str): 

575 parts.append(part) 

576 if isinstance(target, rich.console.Console): 

577 target.print(rich.markdown.Markdown(part)) 

578 

579 def add_section(header: str): 

580 if target == "md" or isinstance(target, rich.console.Console): 

581 add_part(f"\n### {header}\n") 

582 elif target == "html": 

583 parts.append(f'<h3 id="{header_to_tag(header)}">{header}</h3>') 

584 else: 

585 assert_never(target) 

586 

587 def header_to_tag(header: str): 

588 return ( 

589 header.replace("`", "") 

590 .replace("(", "") 

591 .replace(")", "") 

592 .replace(" ", "-") 

593 .lower() 

594 ) 

595 

596 def add_as_details_below( 

597 title: str, text: Union[str, Tuple[str, rich.traceback.Traceback]] 

598 ): 

599 """returns a header and its tag to link to details below""" 

600 

601 def make_link(header: str): 

602 tag = header_to_tag(header) 

603 if target == "md": 

604 return f"[{header}](#{tag})" 

605 elif target == "html": 

606 return f'<a href="#{tag}">{header}</a>' 

607 elif isinstance(target, rich.console.Console): 

608 return f"{header} below" 

609 else: 

610 assert_never(target) 

611 

612 for n in range(1, 4): 

613 header = f"{title} {n}" 

614 if header in details_below: 

615 if details_below[header] == text: 

616 return make_link(header) 

617 else: 

618 details_below[header] = text 

619 return make_link(header) 

620 

621 nonlocal left_out_details 

622 left_out_details += 1 

623 return make_link(left_out_details_header) 

624 

625 @dataclass 

626 class CodeCell: 

627 text: str 

628 

629 @dataclass 

630 class CodeRef: 

631 text: str 

632 

633 def format_code( 

634 code: str, 

635 lang: str = "", 

636 title: str = "Details", 

637 cell_line_limit: int = 15, 

638 cell_width_limit: int = 120, 

639 ) -> Union[CodeRef, CodeCell]: 

640 if not code.strip(): 

641 return CodeCell("") 

642 

643 if target == "html": 

644 html_lang = f' lang="{lang}"' if lang else "" 

645 code = f"<pre{html_lang}>{code}</pre>" 

646 put_below = ( 

647 code.count("\n") > cell_line_limit 

648 or max(map(len, code.split("\n"))) > cell_width_limit 

649 ) 

650 else: 

651 put_below = True 

652 code = f"\n```{lang}\n{code}\n```\n" 

653 

654 if put_below: 

655 link = add_as_details_below(title, code) 

656 return CodeRef(f"See {link}.") 

657 else: 

658 return CodeCell(code) 

659 

660 def format_traceback(entry: ErrorEntry): 

661 if isinstance(target, rich.console.Console): 

662 if entry.traceback_rich is None: 

663 return format_code(entry.traceback_md, title="Traceback") 

664 else: 

665 link = add_as_details_below( 

666 "Traceback", (entry.traceback_md, entry.traceback_rich) 

667 ) 

668 return CodeRef(f"See {link}.") 

669 

670 if target == "md": 

671 return format_code(entry.traceback_md, title="Traceback") 

672 elif target == "html": 

673 return format_code(entry.traceback_html, title="Traceback") 

674 else: 

675 assert_never(target) 

676 

677 def format_text(text: str): 

678 if target == "html": 

679 return [f"<pre>{text}</pre>"] 

680 else: 

681 return text.split("\n") 

682 

683 def get_info_table(): 

684 info_rows = [ 

685 [summary.status_icon, summary.name.strip(".").strip()], 

686 ["status", summary.status], 

687 ] 

688 if not hide_source: 

689 info_rows.append(["source", html.escape(summary.source_name)]) 

690 

691 if summary.id is not None: 

692 info_rows.append(["id", summary.id]) 

693 

694 if summary.version is not None: 

695 info_rows.append(["version", str(summary.version)]) 

696 

697 info_rows.append(["applied format", f"{summary.type} {summary.format_version}"]) 

698 if not hide_env: 

699 info_rows.extend([[e.name, e.version] for e in sorted(summary.env)]) 

700 

701 if include_conda_list: 

702 info_rows.append( 

703 ["conda list", format_code(summary.conda_list, title="Conda List").text] 

704 ) 

705 return format_table(info_rows) 

706 

707 def get_details_table(): 

708 details = [["", "Location", "Details"]] 

709 

710 def append_detail( 

711 status: str, loc: Loc, text: str, code: Union[CodeRef, CodeCell, None] 

712 ): 

713 text_lines = format_text(text) 

714 status_lines = [""] * len(text_lines) 

715 loc_lines = [""] * len(text_lines) 

716 status_lines[0] = status 

717 loc_lines[0] = format_loc(loc, target) 

718 for s_line, loc_line, text_line in zip(status_lines, loc_lines, text_lines): 

719 details.append([s_line, loc_line, text_line]) 

720 

721 if code is not None: 

722 details.append(["", "", code.text]) 

723 

724 for d in summary.details: 

725 details.append([d.status_icon, format_loc(d.loc, target), d.name]) 

726 

727 for entry in d.errors: 

728 append_detail( 

729 "❌", 

730 entry.loc, 

731 entry.msg, 

732 None if hide_tracebacks else format_traceback(entry), 

733 ) 

734 

735 for entry in d.warnings: 

736 append_detail( 

737 "⚠" if entry.severity > INFO else "ℹ", entry.loc, entry.msg, None 

738 ) 

739 

740 if d.recommended_env is not None: 

741 rec_env = StringIO() 

742 json_env = d.recommended_env.model_dump( 

743 mode="json", exclude_defaults=True 

744 ) 

745 assert is_yaml_value(json_env) 

746 write_yaml(json_env, rec_env) 

747 append_detail( 

748 "", 

749 d.loc, 

750 f"recommended conda environment ({d.name})", 

751 format_code( 

752 rec_env.getvalue(), 

753 lang="yaml", 

754 title="Recommended Conda Environment", 

755 ), 

756 ) 

757 

758 if d.conda_compare: 

759 wrapped_conda_compare = "\n".join( 

760 TextWrapper(width=width - 4).wrap(d.conda_compare) 

761 ) 

762 append_detail( 

763 "", 

764 d.loc, 

765 f"conda compare ({d.name})", 

766 format_code( 

767 wrapped_conda_compare, 

768 title="Conda Environment Comparison", 

769 ), 

770 ) 

771 

772 return format_table(details) 

773 

774 add_part(get_info_table()) 

775 add_part(get_details_table()) 

776 

777 for header, text in details_below.items(): 

778 add_section(header) 

779 if isinstance(text, tuple): 

780 assert isinstance(target, rich.console.Console) 

781 text, rich_obj = text 

782 target.print(rich_obj) 

783 parts.append(f"{text}\n") 

784 else: 

785 add_part(f"{text}\n") 

786 

787 if left_out_details: 

788 parts.append( 

789 f"\n{left_out_details_header}\nLeft out {left_out_details} more details for brevity.\n" 

790 ) 

791 

792 return "".join(parts) 

793 

794 

795def _format_md_table(rows: List[List[str]]) -> str: 

796 """format `rows` as markdown table""" 

797 n_cols = len(rows[0]) 

798 assert all(len(row) == n_cols for row in rows) 

799 col_widths = [max(max(len(row[i]) for row in rows), 3) for i in range(n_cols)] 

800 

801 # fix new lines in table cell 

802 rows = [[line.replace("\n", "<br>") for line in r] for r in rows] 

803 

804 lines = [" | ".join(rows[0][i].center(col_widths[i]) for i in range(n_cols))] 

805 lines.append(" | ".join("---".center(col_widths[i]) for i in range(n_cols))) 

806 lines.extend( 

807 [ 

808 " | ".join(row[i].ljust(col_widths[i]) for i in range(n_cols)) 

809 for row in rows[1:] 

810 ] 

811 ) 

812 return "\n| " + " |\n| ".join(lines) + " |\n" 

813 

814 

815def _format_html_table(rows: List[List[str]]) -> str: 

816 """format `rows` as HTML table""" 

817 

818 def get_line(cells: List[str], cell_tag: Literal["th", "td"] = "td"): 

819 return ( 

820 [" <tr>"] 

821 + [ 

822 f' <{cell_tag} style="text-align:{"center" if cell_tag == "th" else "left"}">{c}</{cell_tag}>' 

823 for c in cells 

824 ] 

825 + [" </tr>"] 

826 ) 

827 

828 table = ["<table>"] + get_line(rows[0], cell_tag="th") 

829 for r in rows[1:]: 

830 table.extend(get_line(r)) 

831 

832 table.append("</table>") 

833 

834 return "\n".join(table)