You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

process_notebooks.py 18 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. #!/usr/bin/env python
  2. from __future__ import annotations
  3. import argparse
  4. import concurrent.futures
  5. import json
  6. import os
  7. import shutil
  8. import signal
  9. import subprocess
  10. import sys
  11. import tempfile
  12. import threading
  13. import time
  14. import typing
  15. from dataclasses import dataclass
  16. from multiprocessing import current_process
  17. from pathlib import Path
  18. from typing import Dict, Optional, Tuple, Union
  19. from termcolor import colored
  20. try:
  21. import yaml
  22. except ImportError:
  23. print("pyyaml not found.\n\nPlease install pyyaml:\n\tpip install pyyaml\n")
  24. sys.exit(1)
  25. try:
  26. import nbclient
  27. from nbclient.client import (
  28. CellExecutionError,
  29. CellTimeoutError,
  30. NotebookClient,
  31. )
  32. except ImportError:
  33. if current_process().name == "MainProcess":
  34. print("nbclient not found.\n\nPlease install nbclient:\n\tpip install nbclient\n")
  35. print("test won't work without nbclient")
  36. try:
  37. import nbformat
  38. from nbformat import NotebookNode
  39. except ImportError:
  40. if current_process().name == "MainProcess":
  41. print("nbformat not found.\n\nPlease install nbformat:\n\tpip install nbformat\n")
  42. print("test won't work without nbclient")
  43. class Result:
  44. def __init__(self, returncode: int, stdout: str, stderr: str):
  45. self.returncode = returncode
  46. self.stdout = stdout
  47. self.stderr = stderr
  48. def check_quarto_bin(quarto_bin: str = "quarto") -> None:
  49. """Check if quarto is installed."""
  50. try:
  51. version = subprocess.check_output([quarto_bin, "--version"], text=True).strip()
  52. version = tuple(map(int, version.split(".")))
  53. if version < (1, 5, 23):
  54. print("Quarto version is too old. Please upgrade to 1.5.23 or later.")
  55. sys.exit(1)
  56. except FileNotFoundError:
  57. print("Quarto is not installed. Please install it from https://quarto.org")
  58. sys.exit(1)
  59. def notebooks_target_dir(website_directory: Path) -> Path:
  60. """Return the target directory for notebooks."""
  61. return website_directory / "docs" / "notebooks"
  62. def load_metadata(notebook: Path) -> typing.Dict:
  63. content = json.load(notebook.open(encoding="utf-8"))
  64. return content["metadata"]
  65. def skip_reason_or_none_if_ok(notebook: Path) -> typing.Optional[str]:
  66. """Return a reason to skip the notebook, or None if it should not be skipped."""
  67. if notebook.suffix != ".ipynb":
  68. return "not a notebook"
  69. if not notebook.exists():
  70. return "file does not exist"
  71. # Extra checks for notebooks in the notebook directory
  72. if "notebook" not in notebook.parts:
  73. return None
  74. with open(notebook, "r", encoding="utf-8") as f:
  75. content = f.read()
  76. # Load the json and get the first cell
  77. json_content = json.loads(content)
  78. first_cell = json_content["cells"][0]
  79. # <!-- and --> must exists on lines on their own
  80. if first_cell["cell_type"] == "markdown" and first_cell["source"][0].strip() == "<!--":
  81. raise ValueError(
  82. f"Error in {str(notebook.resolve())} - Front matter should be defined in the notebook metadata now."
  83. )
  84. metadata = load_metadata(notebook)
  85. if "skip_render" in metadata:
  86. return metadata["skip_render"]
  87. if "front_matter" not in metadata:
  88. return "front matter missing from notebook metadata ⚠️"
  89. front_matter = metadata["front_matter"]
  90. if "tags" not in front_matter:
  91. return "tags is not in front matter"
  92. if "description" not in front_matter:
  93. return "description is not in front matter"
  94. # Make sure tags is a list of strings
  95. if not all([isinstance(tag, str) for tag in front_matter["tags"]]):
  96. return "tags must be a list of strings"
  97. # Make sure description is a string
  98. if not isinstance(front_matter["description"], str):
  99. return "description must be a string"
  100. return None
  101. def extract_title(notebook: Path) -> Optional[str]:
  102. """Extract the title of the notebook."""
  103. with open(notebook, "r", encoding="utf-8") as f:
  104. content = f.read()
  105. # Load the json and get the first cell
  106. json_content = json.loads(content)
  107. first_cell = json_content["cells"][0]
  108. # find the # title
  109. for line in first_cell["source"]:
  110. if line.startswith("# "):
  111. title = line[2:].strip()
  112. # Strip off the { if it exists
  113. if "{" in title:
  114. title = title[: title.find("{")].strip()
  115. return title
  116. return None
  117. def process_notebook(src_notebook: Path, website_dir: Path, notebook_dir: Path, quarto_bin: str, dry_run: bool) -> str:
  118. """Process a single notebook."""
  119. in_notebook_dir = "notebook" in src_notebook.parts
  120. metadata = load_metadata(src_notebook)
  121. title = extract_title(src_notebook)
  122. if title is None:
  123. return fmt_error(src_notebook, "Title not found in notebook")
  124. front_matter = {}
  125. if "front_matter" in metadata:
  126. front_matter = metadata["front_matter"]
  127. front_matter["title"] = title
  128. if in_notebook_dir:
  129. relative_notebook = src_notebook.resolve().relative_to(notebook_dir.resolve())
  130. dest_dir = notebooks_target_dir(website_directory=website_dir)
  131. target_file = dest_dir / relative_notebook.with_suffix(".mdx")
  132. intermediate_notebook = dest_dir / relative_notebook
  133. # If the intermediate_notebook already exists, check if it is newer than the source file
  134. if target_file.exists():
  135. if target_file.stat().st_mtime > src_notebook.stat().st_mtime:
  136. return fmt_skip(src_notebook, f"target file ({target_file.name}) is newer ☑️")
  137. if dry_run:
  138. return colored(f"Would process {src_notebook.name}", "green")
  139. # Copy notebook to target dir
  140. # The reason we copy the notebook is that quarto does not support rendering from a different directory
  141. shutil.copy(src_notebook, intermediate_notebook)
  142. # Check if another file has to be copied too
  143. # Solely added for the purpose of agent_library_example.json
  144. if "extra_files_to_copy" in metadata:
  145. for file in metadata["extra_files_to_copy"]:
  146. shutil.copy(src_notebook.parent / file, dest_dir / file)
  147. # Capture output
  148. result = subprocess.run(
  149. [quarto_bin, "render", intermediate_notebook], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
  150. )
  151. if result.returncode != 0:
  152. return fmt_error(
  153. src_notebook, f"Failed to render {src_notebook}\n\nstderr:\n{result.stderr}\nstdout:\n{result.stdout}"
  154. )
  155. # Unlink intermediate files
  156. intermediate_notebook.unlink()
  157. else:
  158. target_file = src_notebook.with_suffix(".mdx")
  159. # If the intermediate_notebook already exists, check if it is newer than the source file
  160. if target_file.exists():
  161. if target_file.stat().st_mtime > src_notebook.stat().st_mtime:
  162. return fmt_skip(src_notebook, f"target file ({target_file.name}) is newer ☑️")
  163. if dry_run:
  164. return colored(f"Would process {src_notebook.name}", "green")
  165. result = subprocess.run(
  166. [quarto_bin, "render", src_notebook], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
  167. )
  168. if result.returncode != 0:
  169. return fmt_error(
  170. src_notebook, f"Failed to render {src_notebook}\n\nstderr:\n{result.stderr}\nstdout:\n{result.stdout}"
  171. )
  172. post_process_mdx(target_file, src_notebook, front_matter)
  173. return fmt_ok(src_notebook)
  174. # Notebook execution based on nbmake: https://github.com/treebeardtech/nbmakes
  175. @dataclass
  176. class NotebookError:
  177. error_name: str
  178. error_value: Optional[str]
  179. traceback: str
  180. cell_source: str
  181. @dataclass
  182. class NotebookSkip:
  183. reason: str
  184. NB_VERSION = 4
  185. def test_notebook(notebook_path: Path, timeout: int = 300) -> Tuple[Path, Optional[Union[NotebookError, NotebookSkip]]]:
  186. nb = nbformat.read(str(notebook_path), NB_VERSION)
  187. if "skip_test" in nb.metadata:
  188. return notebook_path, NotebookSkip(reason=nb.metadata.skip_test)
  189. try:
  190. c = NotebookClient(
  191. nb,
  192. timeout=timeout,
  193. allow_errors=False,
  194. record_timing=True,
  195. )
  196. os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1"
  197. os.environ["TOKENIZERS_PARALLELISM"] = "false"
  198. with tempfile.TemporaryDirectory() as tempdir:
  199. c.execute(cwd=tempdir)
  200. except CellExecutionError:
  201. error = get_error_info(nb)
  202. assert error is not None
  203. return notebook_path, error
  204. except CellTimeoutError:
  205. error = get_timeout_info(nb)
  206. assert error is not None
  207. return notebook_path, error
  208. return notebook_path, None
  209. # Find the first code cell which did not complete.
  210. def get_timeout_info(
  211. nb: NotebookNode,
  212. ) -> Optional[NotebookError]:
  213. for i, cell in enumerate(nb.cells):
  214. if cell.cell_type != "code":
  215. continue
  216. if "shell.execute_reply" not in cell.metadata.execution:
  217. return NotebookError(
  218. error_name="timeout",
  219. error_value="",
  220. traceback="",
  221. cell_source="".join(cell["source"]),
  222. )
  223. return None
  224. def get_error_info(nb: NotebookNode) -> Optional[NotebookError]:
  225. for cell in nb["cells"]: # get LAST error
  226. if cell["cell_type"] != "code":
  227. continue
  228. errors = [output for output in cell["outputs"] if output["output_type"] == "error" or "ename" in output]
  229. if errors:
  230. traceback = "\n".join(errors[0].get("traceback", ""))
  231. return NotebookError(
  232. error_name=errors[0].get("ename", ""),
  233. error_value=errors[0].get("evalue", ""),
  234. traceback=traceback,
  235. cell_source="".join(cell["source"]),
  236. )
  237. return None
  238. # rendered_notebook is the final mdx file
  239. def post_process_mdx(rendered_mdx: Path, source_notebooks: Path, front_matter: Dict) -> None:
  240. with open(rendered_mdx, "r", encoding="utf-8") as f:
  241. content = f.read()
  242. # If there is front matter in the mdx file, we need to remove it
  243. if content.startswith("---"):
  244. front_matter_end = content.find("---", 3)
  245. front_matter = yaml.safe_load(content[4:front_matter_end])
  246. content = content[front_matter_end + 3 :]
  247. # Each intermediate path needs to be resolved for this to work reliably
  248. repo_root = Path(__file__).parent.resolve().parent.resolve()
  249. repo_relative_notebook = source_notebooks.resolve().relative_to(repo_root)
  250. front_matter["source_notebook"] = f"/{repo_relative_notebook}"
  251. front_matter["custom_edit_url"] = f"https://github.com/microsoft/autogen/edit/main/{repo_relative_notebook}"
  252. # Is there a title on the content? Only search up until the first code cell
  253. first_code_cell = content.find("```")
  254. if first_code_cell != -1:
  255. title_search_content = content[:first_code_cell]
  256. else:
  257. title_search_content = content
  258. title_exists = title_search_content.find("\n# ") != -1
  259. if not title_exists:
  260. content = f"# {front_matter['title']}\n{content}"
  261. # inject in content directly after the markdown title the word done
  262. # Find the end of the line with the title
  263. title_end = content.find("\n", content.find("#"))
  264. # Extract page title
  265. title = content[content.find("#") + 1 : content.find("\n", content.find("#"))].strip()
  266. # If there is a { in the title we trim off the { and everything after it
  267. if "{" in title:
  268. title = title[: title.find("{")].strip()
  269. github_link = f"https://github.com/microsoft/autogen/blob/main/{repo_relative_notebook}"
  270. content = (
  271. content[:title_end]
  272. + "\n[![Open on GitHub](https://img.shields.io/badge/Open%20on%20GitHub-grey?logo=github)]("
  273. + github_link
  274. + ")"
  275. + content[title_end:]
  276. )
  277. # If no colab link is present, insert one
  278. if "colab-badge.svg" not in content:
  279. colab_link = f"https://colab.research.google.com/github/microsoft/autogen/blob/main/{repo_relative_notebook}"
  280. content = (
  281. content[:title_end]
  282. + "\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)]("
  283. + colab_link
  284. + ")"
  285. + content[title_end:]
  286. )
  287. # Dump front_matter to ysaml
  288. front_matter = yaml.dump(front_matter, default_flow_style=False)
  289. # Rewrite the content as
  290. # ---
  291. # front_matter
  292. # ---
  293. # content
  294. new_content = f"---\n{front_matter}---\n{content}"
  295. with open(rendered_mdx, "w", encoding="utf-8") as f:
  296. f.write(new_content)
  297. def path(path_str: str) -> Path:
  298. """Return a Path object."""
  299. return Path(path_str)
  300. def collect_notebooks(notebook_directory: Path, website_directory: Path) -> typing.List[Path]:
  301. notebooks = list(notebook_directory.glob("*.ipynb"))
  302. notebooks.extend(list(website_directory.glob("docs/**/*.ipynb")))
  303. return notebooks
  304. def fmt_skip(notebook: Path, reason: str) -> str:
  305. return f"{colored('[Skip]', 'yellow')} {colored(notebook.name, 'blue')}: {reason}"
  306. def fmt_ok(notebook: Path) -> str:
  307. return f"{colored('[OK]', 'green')} {colored(notebook.name, 'blue')} ✅"
  308. def fmt_error(notebook: Path, error: Union[NotebookError, str]) -> str:
  309. if isinstance(error, str):
  310. return f"{colored('[Error]', 'red')} {colored(notebook.name, 'blue')}: {error}"
  311. elif isinstance(error, NotebookError):
  312. return f"{colored('[Error]', 'red')} {colored(notebook.name, 'blue')}: {error.error_name} - {error.error_value}"
  313. else:
  314. raise ValueError("error must be a string or a NotebookError")
  315. def start_thread_to_terminate_when_parent_process_dies(ppid: int):
  316. pid = os.getpid()
  317. def f() -> None:
  318. while True:
  319. try:
  320. os.kill(ppid, 0)
  321. except OSError:
  322. os.kill(pid, signal.SIGTERM)
  323. time.sleep(1)
  324. thread = threading.Thread(target=f, daemon=True)
  325. thread.start()
  326. def main() -> None:
  327. script_dir = Path(__file__).parent.absolute()
  328. parser = argparse.ArgumentParser()
  329. subparsers = parser.add_subparsers(dest="subcommand")
  330. parser.add_argument(
  331. "--notebook-directory",
  332. type=path,
  333. help="Directory containing notebooks to process",
  334. default=script_dir / "../notebook",
  335. )
  336. parser.add_argument(
  337. "--website-directory", type=path, help="Root directory of docusarus website", default=script_dir
  338. )
  339. render_parser = subparsers.add_parser("render")
  340. render_parser.add_argument("--quarto-bin", help="Path to quarto binary", default="quarto")
  341. render_parser.add_argument("--dry-run", help="Don't render", action="store_true")
  342. render_parser.add_argument("notebooks", type=path, nargs="*", default=None)
  343. test_parser = subparsers.add_parser("test")
  344. test_parser.add_argument("--timeout", help="Timeout for each notebook", type=int, default=60)
  345. test_parser.add_argument("--exit-on-first-fail", "-e", help="Exit after first test fail", action="store_true")
  346. test_parser.add_argument("notebooks", type=path, nargs="*", default=None)
  347. test_parser.add_argument("--workers", help="Number of workers to use", type=int, default=-1)
  348. args = parser.parse_args()
  349. if args.subcommand is None:
  350. print("No subcommand specified")
  351. sys.exit(1)
  352. if args.notebooks:
  353. collected_notebooks = args.notebooks
  354. else:
  355. collected_notebooks = collect_notebooks(args.notebook_directory, args.website_directory)
  356. filtered_notebooks = []
  357. for notebook in collected_notebooks:
  358. reason = skip_reason_or_none_if_ok(notebook)
  359. if reason:
  360. print(fmt_skip(notebook, reason))
  361. else:
  362. filtered_notebooks.append(notebook)
  363. if args.subcommand == "test":
  364. if args.workers == -1:
  365. args.workers = None
  366. failure = False
  367. with concurrent.futures.ProcessPoolExecutor(
  368. max_workers=args.workers,
  369. initializer=start_thread_to_terminate_when_parent_process_dies,
  370. initargs=(os.getpid(),),
  371. ) as executor:
  372. futures = [executor.submit(test_notebook, f, args.timeout) for f in filtered_notebooks]
  373. for future in concurrent.futures.as_completed(futures):
  374. notebook, optional_error_or_skip = future.result()
  375. if isinstance(optional_error_or_skip, NotebookError):
  376. if optional_error_or_skip.error_name == "timeout":
  377. print(fmt_error(notebook, optional_error_or_skip.error_name))
  378. else:
  379. print("-" * 80)
  380. print(fmt_error(notebook, optional_error_or_skip))
  381. print(optional_error_or_skip.traceback)
  382. print("-" * 80)
  383. if args.exit_on_first_fail:
  384. sys.exit(1)
  385. failure = True
  386. elif isinstance(optional_error_or_skip, NotebookSkip):
  387. print(fmt_skip(notebook, optional_error_or_skip.reason))
  388. else:
  389. print(fmt_ok(notebook))
  390. if failure:
  391. sys.exit(1)
  392. elif args.subcommand == "render":
  393. check_quarto_bin(args.quarto_bin)
  394. if not notebooks_target_dir(args.website_directory).exists():
  395. notebooks_target_dir(args.website_directory).mkdir(parents=True)
  396. for notebook in filtered_notebooks:
  397. print(
  398. process_notebook(
  399. notebook, args.website_directory, args.notebook_directory, args.quarto_bin, args.dry_run
  400. )
  401. )
  402. else:
  403. print("Unknown subcommand")
  404. sys.exit(1)
  405. if __name__ == "__main__":
  406. main()