Coverage for src / bioimageio / spec / summary.py: 66%
394 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 08:15 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 08:15 +0000
1"""Utilities for summarizing and formatting BioImage.IO validation results.
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"""
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)
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
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
72CONDA_CMD = "conda.bat" if platform.system() == "Windows" else "conda"
74Loc = Tuple[Union[int, str], ...]
75"""location of error/warning in a nested data structure"""
77WarningSeverityName = Literal["info", "warning", "alert"]
78WarningLevelName = Literal[WarningSeverityName, "error"]
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)
91class ValidationEntry(BaseModel):
92 """Base of `ErrorEntry` and `WarningEntry`"""
94 loc: Loc
95 msg: str
96 type: Union[ErrorType, str]
99class ErrorEntry(ValidationEntry):
100 """An error in a `ValidationDetail`"""
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
108 @property
109 def traceback_rich(self) -> Optional[rich.traceback.Traceback]:
110 return self._traceback_rich
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)
127 if not self.traceback_html:
128 self.traceback_html = console.export_html(clear=False)
131class WarningEntry(ValidationEntry):
132 """A warning in a `ValidationDetail`"""
134 severity: WarningSeverity = WARNING
136 @property
137 def severity_name(self) -> WarningSeverityName:
138 return WARNING_SEVERITY_TO_NAME[self.severity]
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))
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)
164 return f"{start}{loc_str}{end}"
165 else:
166 return ""
169class InstalledPackage(NamedTuple):
170 name: str
171 version: str
172 build: str = ""
173 channel: str = ""
176class ValidationDetail(BaseModel, extra="allow"):
177 """a detail in a validation summary"""
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 )
190 context: Optional[ValidationContextSummary] = None
192 recommended_env: Optional[CondaEnv] = None
193 """recommended conda environemnt for this validation detail"""
195 saved_conda_compare: Optional[str] = None
196 """output of `conda compare <recommended env>`"""
198 @field_serializer("saved_conda_compare")
199 def _save_conda_compare(self, value: Optional[str]):
200 return self.conda_compare
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)
212 return data
214 @property
215 def conda_compare(self) -> Optional[str]:
216 if self.recommended_env is None:
217 return None
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)
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 )
247 return self.saved_conda_compare
249 @property
250 def status_icon(self) -> str:
251 if self.status == "passed":
252 return "✔️"
253 else:
254 return "❌"
257class ValidationSummary(BaseModel, extra="allow"):
258 """Summarizes output of all bioimageio validations and tests
259 for one specific `ResourceDescr` instance."""
261 name: str
262 """Name of the validation"""
264 source_name: str
265 """Source of the validated bioimageio description"""
267 id: Optional[str] = None
268 """ID of the validated resource"""
270 version: Optional[Version] = None
271 """Version of the validated resource"""
273 type: str
274 """Type of the validated resource"""
276 format_version: str
277 """Format version of the validated resource"""
279 status: Literal["passed", "valid-format", "failed"]
280 """Overall status of the bioimageio validation"""
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.
285 Note: This completeness estimate may change with subsequent releases
286 and should be considered bioimageio.spec version specific.
287 """
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"""
301 saved_conda_list: Optional[str] = None
303 @field_serializer("saved_conda_list")
304 def _save_conda_list(self, value: Optional[str]):
305 return self.conda_list
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 )
325 return self.saved_conda_list
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 "❌"
336 @property
337 def errors(self) -> List[ErrorEntry]:
338 return list(chain.from_iterable(d.errors for d in self.details))
340 @property
341 def warnings(self) -> List[WarningEntry]:
342 return list(chain.from_iterable(d.warnings for d in self.details))
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)
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 )
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 )
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
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 )
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 # An overall status 'passed' can degrade to 'valid-format'
422 # The status can not be upgraded here as we do not know if it has been downgraded before or not yet tested.
423 # Once status is 'failed' it cannot change anymore.
424 if update_status and self.status == "passed" and detail.status == "failed":
425 # "passed" -> "valid-format"
426 self.status = "valid-format"
428 self.details.append(detail)
430 def log(
431 self,
432 to: Union[Literal["display"], Path, Sequence[Union[Literal["display"], Path]]],
433 ) -> List[Path]:
434 """Convenience method to display the validation summary in the terminal and/or
435 save it to disk. See `save` for details."""
436 if to == "display":
437 display = True
438 save_to = []
439 elif isinstance(to, Path):
440 display = False
441 save_to = [to]
442 else:
443 display = "display" in to
444 save_to = [p for p in to if p != "display"]
446 if display:
447 self.display()
449 return self.save(save_to)
451 def save(
452 self, path: Union[Path, Sequence[Path]] = Path("{id}_summary_{now}")
453 ) -> List[Path]:
454 """Save the validation/test summary in JSON, Markdown or HTML format.
456 Returns:
457 List of file paths the summary was saved to.
459 Notes:
460 - Format is chosen based on the suffix: `.json`, `.md`, `.html`.
461 - If **path** has no suffix it is assumed to be a direcotry to which a
462 `summary.json`, `summary.md` and `summary.html` are saved to.
463 """
464 if isinstance(path, (str, Path)):
465 path = [Path(path)]
467 # folder to file paths
468 file_paths: List[Path] = []
469 for p in path:
470 if p.suffix:
471 file_paths.append(p)
472 else:
473 file_paths.extend(
474 [
475 p / "summary.json",
476 p / "summary.md",
477 p / "summary.html",
478 ]
479 )
481 now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
482 for p in file_paths:
483 p = Path(str(p).format(id=self.id or "bioimageio", now=now))
484 if p.suffix == ".json":
485 self.save_json(p)
486 elif p.suffix == ".md":
487 self.save_markdown(p)
488 elif p.suffix == ".html":
489 self.save_html(p)
490 else:
491 raise ValueError(f"Unknown summary path suffix '{p.suffix}'")
493 return file_paths
495 def save_json(
496 self, path: Path = Path("summary.json"), *, indent: Optional[int] = 2
497 ) -> None:
498 """Save validation/test summary as JSON file."""
499 json_str = self.model_dump_json(indent=indent)
500 path.parent.mkdir(exist_ok=True, parents=True)
501 _ = path.write_text(json_str, encoding="utf-8")
502 logger.info("Saved summary to {}", path.absolute())
504 def save_markdown(self, path: Path = Path("summary.md")) -> None:
505 """Save rendered validation/test summary as Markdown file."""
506 formatted = self.format_md()
507 path.parent.mkdir(exist_ok=True, parents=True)
508 _ = path.write_text(formatted, encoding="utf-8")
509 logger.info("Saved Markdown formatted summary to {}", path.absolute())
511 def save_html(self, path: Path = Path("summary.html")) -> None:
512 """Save rendered validation/test summary as HTML file."""
513 path.parent.mkdir(exist_ok=True, parents=True)
515 html = self.format_html()
516 _ = path.write_text(html, encoding="utf-8")
517 logger.info("Saved HTML formatted summary to {}", path.absolute())
519 @classmethod
520 def load_json(cls, path: Path) -> Self:
521 """Load validation/test summary from a suitable JSON file"""
522 json_str = Path(path).read_text(encoding="utf-8")
523 return cls.model_validate_json(json_str)
525 @field_validator("env", mode="before")
526 def _convert_old_env(cls, value: List[Union[List[str], Dict[str, str]]]):
527 """convert old style dict values of `env` for backwards compatibility"""
528 if isinstance(value, list):
529 return [
530 (
531 (v["name"], v["version"], v.get("build", ""), v.get("channel", ""))
532 if isinstance(v, dict) and "name" in v and "version" in v
533 else v
534 )
535 for v in value
536 ]
537 else:
538 return value
540 def _format(
541 self,
542 *,
543 target: Union[rich.console.Console, Literal["html", "md"]],
544 width: Optional[int],
545 include_conda_list: bool,
546 ) -> str:
547 return _format_summary(
548 self,
549 target=target,
550 width=width or 100,
551 include_conda_list=include_conda_list,
552 )
555def _format_summary(
556 summary: ValidationSummary,
557 *,
558 hide_tracebacks: bool = False, # TODO: remove?
559 hide_source: bool = False, # TODO: remove?
560 hide_env: bool = False, # TODO: remove?
561 target: Union[rich.console.Console, Literal["html", "md"]] = "md",
562 include_conda_list: bool,
563 width: int,
564) -> str:
565 parts: List[str] = []
566 format_table = _format_html_table if target == "html" else _format_md_table
567 details_below: Dict[str, Union[str, Tuple[str, rich.traceback.Traceback]]] = {}
568 left_out_details: int = 0
569 left_out_details_header = "Left out details"
571 def add_part(part: str):
572 parts.append(part)
573 if isinstance(target, rich.console.Console):
574 target.print(rich.markdown.Markdown(part))
576 def add_section(header: str):
577 if target == "md" or isinstance(target, rich.console.Console):
578 add_part(f"\n### {header}\n")
579 elif target == "html":
580 parts.append(f'<h3 id="{header_to_tag(header)}">{header}</h3>')
581 else:
582 assert_never(target)
584 def header_to_tag(header: str):
585 return (
586 header.replace("`", "")
587 .replace("(", "")
588 .replace(")", "")
589 .replace(" ", "-")
590 .lower()
591 )
593 def add_as_details_below(
594 title: str, text: Union[str, Tuple[str, rich.traceback.Traceback]]
595 ):
596 """returns a header and its tag to link to details below"""
598 def make_link(header: str):
599 tag = header_to_tag(header)
600 if target == "md":
601 return f"[{header}](#{tag})"
602 elif target == "html":
603 return f'<a href="#{tag}">{header}</a>'
604 elif isinstance(target, rich.console.Console):
605 return f"{header} below"
606 else:
607 assert_never(target)
609 for n in range(1, 4):
610 header = f"{title} {n}"
611 if header in details_below:
612 if details_below[header] == text:
613 return make_link(header)
614 else:
615 details_below[header] = text
616 return make_link(header)
618 nonlocal left_out_details
619 left_out_details += 1
620 return make_link(left_out_details_header)
622 @dataclass
623 class CodeCell:
624 text: str
626 @dataclass
627 class CodeRef:
628 text: str
630 def format_code(
631 code: str,
632 lang: str = "",
633 title: str = "Details",
634 cell_line_limit: int = 15,
635 cell_width_limit: int = 120,
636 ) -> Union[CodeRef, CodeCell]:
637 if not code.strip():
638 return CodeCell("")
640 if target == "html":
641 html_lang = f' lang="{lang}"' if lang else ""
642 code = f"<pre{html_lang}>{code}</pre>"
643 put_below = (
644 code.count("\n") > cell_line_limit
645 or max(map(len, code.split("\n"))) > cell_width_limit
646 )
647 else:
648 put_below = True
649 code = f"\n```{lang}\n{code}\n```\n"
651 if put_below:
652 link = add_as_details_below(title, code)
653 return CodeRef(f"See {link}.")
654 else:
655 return CodeCell(code)
657 def format_traceback(entry: ErrorEntry):
658 if isinstance(target, rich.console.Console):
659 if entry.traceback_rich is None:
660 return format_code(entry.traceback_md, title="Traceback")
661 else:
662 link = add_as_details_below(
663 "Traceback", (entry.traceback_md, entry.traceback_rich)
664 )
665 return CodeRef(f"See {link}.")
667 if target == "md":
668 return format_code(entry.traceback_md, title="Traceback")
669 elif target == "html":
670 return format_code(entry.traceback_html, title="Traceback")
671 else:
672 assert_never(target)
674 def format_text(text: str):
675 if target == "html":
676 return [f"<pre>{text}</pre>"]
677 else:
678 return text.split("\n")
680 def get_info_table():
681 info_rows = [
682 [summary.status_icon, summary.name.strip(".").strip()],
683 ["status", summary.status],
684 ]
685 if not hide_source:
686 info_rows.append(["source", html.escape(summary.source_name)])
688 if summary.id is not None:
689 info_rows.append(["id", summary.id])
691 if summary.version is not None:
692 info_rows.append(["version", str(summary.version)])
694 info_rows.append(["applied format", f"{summary.type} {summary.format_version}"])
695 if not hide_env:
696 info_rows.extend([[e.name, e.version] for e in sorted(summary.env)])
698 if include_conda_list:
699 info_rows.append(
700 ["conda list", format_code(summary.conda_list, title="Conda List").text]
701 )
702 return format_table(info_rows)
704 def get_details_table():
705 details = [["", "Location", "Details"]]
707 def append_detail(
708 status: str, loc: Loc, text: str, code: Union[CodeRef, CodeCell, None]
709 ):
710 text_lines = format_text(text)
711 status_lines = [""] * len(text_lines)
712 loc_lines = [""] * len(text_lines)
713 status_lines[0] = status
714 loc_lines[0] = format_loc(loc, target)
715 for s_line, loc_line, text_line in zip(status_lines, loc_lines, text_lines):
716 details.append([s_line, loc_line, text_line])
718 if code is not None:
719 details.append(["", "", code.text])
721 for d in summary.details:
722 details.append([d.status_icon, format_loc(d.loc, target), d.name])
724 for entry in d.errors:
725 append_detail(
726 "❌",
727 entry.loc,
728 entry.msg,
729 None if hide_tracebacks else format_traceback(entry),
730 )
732 for entry in d.warnings:
733 append_detail(
734 "⚠" if entry.severity > INFO else "ℹ", entry.loc, entry.msg, None
735 )
737 if d.recommended_env is not None:
738 rec_env = StringIO()
739 json_env = d.recommended_env.model_dump(
740 mode="json", exclude_defaults=True
741 )
742 assert is_yaml_value(json_env)
743 write_yaml(json_env, rec_env)
744 append_detail(
745 "",
746 d.loc,
747 f"recommended conda environment ({d.name})",
748 format_code(
749 rec_env.getvalue(),
750 lang="yaml",
751 title="Recommended Conda Environment",
752 ),
753 )
755 if d.conda_compare:
756 wrapped_conda_compare = "\n".join(
757 TextWrapper(width=width - 4).wrap(d.conda_compare)
758 )
759 append_detail(
760 "",
761 d.loc,
762 f"conda compare ({d.name})",
763 format_code(
764 wrapped_conda_compare,
765 title="Conda Environment Comparison",
766 ),
767 )
769 return format_table(details)
771 add_part(get_info_table())
772 add_part(get_details_table())
774 for header, text in details_below.items():
775 add_section(header)
776 if isinstance(text, tuple):
777 assert isinstance(target, rich.console.Console)
778 text, rich_obj = text
779 target.print(rich_obj)
780 parts.append(f"{text}\n")
781 else:
782 add_part(f"{text}\n")
784 if left_out_details:
785 parts.append(
786 f"\n{left_out_details_header}\nLeft out {left_out_details} more details for brevity.\n"
787 )
789 return "".join(parts)
792def _format_md_table(rows: List[List[str]]) -> str:
793 """format `rows` as markdown table"""
794 n_cols = len(rows[0])
795 assert all(len(row) == n_cols for row in rows)
796 col_widths = [max(max(len(row[i]) for row in rows), 3) for i in range(n_cols)]
798 # fix new lines in table cell
799 rows = [[line.replace("\n", "<br>") for line in r] for r in rows]
801 lines = [" | ".join(rows[0][i].center(col_widths[i]) for i in range(n_cols))]
802 lines.append(" | ".join("---".center(col_widths[i]) for i in range(n_cols)))
803 lines.extend(
804 [
805 " | ".join(row[i].ljust(col_widths[i]) for i in range(n_cols))
806 for row in rows[1:]
807 ]
808 )
809 return "\n| " + " |\n| ".join(lines) + " |\n"
812def _format_html_table(rows: List[List[str]]) -> str:
813 """format `rows` as HTML table"""
815 def get_line(cells: List[str], cell_tag: Literal["th", "td"] = "td"):
816 return (
817 [" <tr>"]
818 + [
819 f' <{cell_tag} style="text-align:{"center" if cell_tag == "th" else "left"}">{c}</{cell_tag}>'
820 for c in cells
821 ]
822 + [" </tr>"]
823 )
825 table = ["<table>"] + get_line(rows[0], cell_tag="th")
826 for r in rows[1:]:
827 table.extend(get_line(r))
829 table.append("</table>")
831 return "\n".join(table)