Coverage for pycommons / dev / building / make_documentation.py: 97%

191 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-15 10:00 +0000

1"""Create the documentation.""" 

2 

3from argparse import ArgumentParser 

4from os import remove as osremove 

5from typing import Callable, Final, cast 

6 

7import minify_html 

8 

9from pycommons.dev.building.build_info import ( 

10 BuildInfo, 

11 parse_project_arguments, 

12) 

13from pycommons.dev.doc.doc_info import ( 

14 DocInfo, 

15 extract_md_infos, 

16 load_doc_info_from_setup_cfg, 

17) 

18from pycommons.dev.url_replacer import make_url_replacer 

19from pycommons.io.arguments import pycommons_argparser 

20from pycommons.io.console import logger 

21from pycommons.io.path import UTF8, Path, delete_path 

22from pycommons.processes.shell import STREAM_CAPTURE, STREAM_FORWARD, Command 

23from pycommons.types import type_error 

24 

25 

26def __get_source( 

27 source: Path, __dst: set[Path] | None = None) \ 

28 -> Callable[[Path], bool]: 

29 """ 

30 Get the existing files in a directory. 

31 

32 :param source: the directory 

33 :param __dst: the destination 

34 :returns: the set of files 

35 

36 >>> from pycommons.io.temp import temp_dir 

37 >>> with temp_dir() as td: 

38 ... f1 = td.resolve_inside("a") 

39 ... f1.ensure_file_exists() # gives False 

40 ... f2 = td.resolve_inside("b") 

41 ... f2.ensure_file_exists() # gives False 

42 ... d = td.resolve_inside("x") 

43 ... d.ensure_dir_exists() # gives False 

44 ... f3 = d.resolve_inside("a") 

45 ... f3.ensure_file_exists() 

46 ... g = __get_source(td) 

47 ... g(f1) # is True 

48 ... g(f2) # is True 

49 ... g(f3) # is True 

50 ... g(d) # is True 

51 ... g("bla") # is not True 

52 ... f4 = d.resolve_inside("x") 

53 ... f4.ensure_file_exists() # gives False 

54 ... g(f4) # is also not True, because generated later 

55 False 

56 False 

57 False 

58 True 

59 True 

60 True 

61 True 

62 False 

63 False 

64 False 

65 """ 

66 if __dst is None: 

67 __dst = {source} 

68 for k in source.list_dir(): 

69 __dst.add(k) 

70 if k.is_dir(): 

71 __get_source(k, __dst) 

72 return __dst.__contains__ 

73 

74 

75def __keep_only_source( 

76 source: Path, keep: Callable[[Path], bool], 

77 __collect: Callable[[Path], None] | None = None) -> None: 

78 """ 

79 Keep only the source items, delete the rest. 

80 

81 :param source: the source path 

82 :param keep: the set of files and directories to keep 

83 :param __collect: the collector 

84 

85 >>> from pycommons.io.temp import temp_dir 

86 >>> with temp_dir() as td: 

87 ... f1 = td.resolve_inside("a") 

88 ... f1.ensure_file_exists() # gives False 

89 ... f2 = td.resolve_inside("b") 

90 ... f2.ensure_file_exists() # gives False 

91 ... d = td.resolve_inside("x") 

92 ... d.ensure_dir_exists() # gives False 

93 ... f3 = d.resolve_inside("a") 

94 ... f3.ensure_file_exists() 

95 ... g = __get_source(td) 

96 ... f4 = d.resolve_inside("x") 

97 ... f4.ensure_file_exists() # gives False 

98 ... e = td.resolve_inside("y") 

99 ... e.ensure_dir_exists() # gives False 

100 ... f5 = e.resolve_inside("a") 

101 ... f5.ensure_file_exists() 

102 ... f3.is_file() # gives True - should be preserved 

103 ... f4.is_file() # gives True - will be deleted 

104 ... f5.is_file() # gives True - will be deleted 

105 ... d.is_dir() # True - will be preserved 

106 ... e.is_dir() # True - will be deleted 

107 ... __keep_only_source(td, g) 

108 ... f3.is_file() # gives True - was preserved 

109 ... f4.is_file() # gives False 

110 ... f5.is_file() # gives False 

111 ... d.is_dir() # True - was preserved 

112 ... e.is_dir() # False 

113 False 

114 False 

115 False 

116 False 

117 False 

118 True 

119 True 

120 True 

121 True 

122 True 

123 True 

124 False 

125 False 

126 True 

127 False 

128 """ 

129 lst: list[Path] | None = None 

130 if __collect is None: 

131 lst = [] 

132 __collect = lst.append 

133 for f in source.list_dir(): 

134 if not keep(f): 

135 __collect(f) 

136 if f.is_dir(): 

137 __keep_only_source(f, keep, __collect) 

138 if lst: 

139 lst.sort(key=cast("Callable[[Path], int]", str.__len__), reverse=True) 

140 for k in lst: 

141 delete_path(k) 

142 

143 

144def __pygmentize(source: Path, info: BuildInfo, 

145 dest: Path | None = None) -> None: 

146 """ 

147 Pygmentize a source file to a destination. 

148 

149 :param source: the source file 

150 :param info: the information 

151 :param dest: the destination folder 

152 

153 >>> root = Path(__file__).up(4) 

154 >>> bf = BuildInfo(root, "pycommons", 

155 ... examples_dir=root.resolve_inside("examples"), 

156 ... tests_dir=root.resolve_inside("tests"), 

157 ... doc_source_dir=root.resolve_inside("docs/source"), 

158 ... doc_dest_dir=root.resolve_inside("docs/build")) 

159 

160 >>> from contextlib import redirect_stdout 

161 >>> from pycommons.io.temp import temp_dir 

162 >>> with temp_dir() as td: 

163 ... with redirect_stdout(None): 

164 ... __pygmentize(root.resolve_inside("README.md"), bf, td) 

165 ... readme = td.resolve_inside("README_md.html").is_file() 

166 ... __pygmentize(root.resolve_inside("setup.py"), bf, td) 

167 ... setuppy = td.resolve_inside("setup_py.html").is_file() 

168 ... __pygmentize(root.resolve_inside("setup.cfg"), bf, td) 

169 ... setup_cfg = td.resolve_inside("setup_cfg.html").is_file() 

170 ... __pygmentize(root.resolve_inside("make.sh"), bf, td) 

171 ... makefile = td.resolve_inside("make_sh.html").is_file() 

172 ... __pygmentize(root.resolve_inside("LICENSE"), bf, td) 

173 ... xlicense = td.resolve_inside("LICENSE.html").is_file() 

174 ... __pygmentize(root.resolve_inside("requirements.txt"), bf, td) 

175 ... req = td.resolve_inside("requirements_txt.html").is_file() 

176 ... try: 

177 ... __pygmentize(root.resolve_inside("LICENSE"), bf, td) 

178 ... except ValueError as ve: 

179 ... ver = str(ve) 

180 >>> readme 

181 True 

182 >>> setuppy 

183 True 

184 >>> setup_cfg 

185 True 

186 >>> makefile 

187 True 

188 >>> xlicense 

189 True 

190 >>> req 

191 True 

192 >>> "already exists" in ver 

193 True 

194 """ 

195 logger(f"Trying to pygmentize {source!r} to {dest!r}.") 

196 name: Final[str] = source.basename() 

197 language: str = "text" 

198 if name.endswith(".py"): 

199 language = "python3" 

200 elif name.endswith((".cfg", ".toml")): 

201 language = "INI" 

202 elif name.lower() == "makefile": 

203 language = "make" 

204 elif name.endswith(".sh"): 

205 language = "bash" 

206 if dest is None: 

207 dest = info.doc_dest_dir 

208 dest_file: Final[Path] = dest.resolve_inside( 

209 f"{name.replace('.', '_')}.html") 

210 if dest_file.exists(): 

211 raise ValueError(f"File {dest_file!r} already exists, " 

212 f"cannot pygmentize {source!r}.") 

213 info.command(("pygmentize", "-f", "html", "-l", language, "-O", "full", 

214 "-O", "style=default", "-o", dest_file, source)).execute() 

215 logger(f"Done pygmentizing {source!r} to {dest_file!r}.") 

216 

217 

218#: the default files to pygmentize 

219__PYGMENTIZE_DEFAULT: Final[tuple[str, ...]] = ( 

220 "conftest.py", "LICENSE", "make.sh", "Makefile", "pyproject.toml", 

221 "requirements.txt", "requirements-dev.txt", "setup.cfg", "setup.py", 

222) 

223 

224#: the possible styles 

225__STYLES: Final[tuple[str, ...]] = ("bizstyle.css", ) 

226 

227#: additions that should be included in the styles 

228__STYLE_ADDITIONS: Final[tuple[tuple[str, str], ...]] = ( 

229 ("bizstyle.css", 

230 "\n.sphinxsidebarwrapper span.pre{white-space:wrap;-epub-hyphens:auto;" 

231 "-webkit-hyphens:auto;-moz-hyphens:auto;hyphens: auto;}\n"), ) 

232 

233#: the html header 

234__HTML_HEADER: Final[str] = "<!DOCTYPE html><html><title>" 

235#: the top of the html body if styles are present 

236__HTML_BODY_STYLE_1: Final[str] =\ 

237 ('</title><link href={STYLE} rel="stylesheet">' 

238 '<body style="background-image:none"><div class="document">' 

239 '<div class="documentwrapper"><div class="bodywrapper">' 

240 '<div class="body" role="main"><section>') 

241#: the bottom of the html body if styles are present 

242__HTML_BODY_STYLE_2: Final[str] = \ 

243 "</section></div></div></div></div></body></html>" 

244#: the top of the html body if not styles are present 

245__HTML_BODY_NO_STYLE_1: Final[str] = "</title><body><section>" 

246#: the bottom of the html body if styles are present 

247__HTML_BODY_NO_STYLE_2: Final[str] = "</section></body></html>" 

248 

249 

250def __render_markdown(markdown: Path, info: BuildInfo, dest: Path | None, 

251 css: str | None, 

252 url_fixer: Callable[[str], str]) -> None: 

253 """ 

254 Render a markdown file. 

255 

256 :param markdown: the markdown file 

257 :param dest: the destination path 

258 :param info: the build info 

259 :param css: the relative path to the style sheet 

260 :param url_fixer: the URL fixer 

261 

262 >>> root = Path(__file__).up(4) 

263 >>> bf = BuildInfo(root, "pycommons", 

264 ... examples_dir=root.resolve_inside("examples"), 

265 ... tests_dir=root.resolve_inside("tests"), 

266 ... doc_source_dir=root.resolve_inside("docs/source"), 

267 ... doc_dest_dir=root.resolve_inside("docs/build")) 

268 

269 >>> from io import StringIO 

270 >>> from contextlib import redirect_stdout 

271 >>> from pycommons.io.temp import temp_dir 

272 >>> with temp_dir() as td: 

273 ... with redirect_stdout(None): 

274 ... __render_markdown(root.resolve_inside("README.md"), bf, 

275 ... td, None, lambda s: s) 

276 ... readme = td.resolve_inside("README_md.html").is_file() 

277 ... try: 

278 ... __render_markdown(root.resolve_inside("README.md"), bf, 

279 ... td, None, lambda s: s) 

280 ... except ValueError as ve: 

281 ... vstr = str(ve) 

282 ... __render_markdown(root.resolve_inside("CONTRIBUTING.md"), bf, 

283 ... td, "dummy.css", lambda s: s) 

284 ... cb = td.resolve_inside("CONTRIBUTING_md.html").is_file() 

285 >>> readme 

286 True 

287 >>> vstr.endswith("already exists.") 

288 True 

289 >>> cb 

290 True 

291 """ 

292 logger(f"Trying to render {markdown!r}.") 

293 

294 # find the destination file 

295 basename: Final[str] = markdown.basename() 

296 if dest is None: 

297 dest = info.doc_dest_dir 

298 dest_path: Final[Path] = dest.resolve_inside( 

299 f"{basename.replace('.', '_')}.html") 

300 if dest_path.exists(): 

301 raise ValueError(f"Destination path {dest_path!r} already exists.") 

302 

303 # get the title 

304 title, _ = extract_md_infos(markdown) 

305 title = str.strip(title).replace("`", "").replace("*", "") 

306 

307 # set up the body 

308 body_1: str = __HTML_BODY_NO_STYLE_1 

309 body_2: str = __HTML_BODY_NO_STYLE_2 

310 if css is not None: 

311 body_1 = __HTML_BODY_STYLE_1.replace("{STYLE}", css) 

312 body_2 = __HTML_BODY_STYLE_2 

313 

314 text: Final[str] = url_fixer(str.strip(Command(( 

315 "markdown_py", "-o", "html", markdown), 

316 stderr=STREAM_FORWARD, stdout=STREAM_CAPTURE, timeout=info.timeout, 

317 working_dir=info.base_dir).execute()[0])) 

318 dest_path.write_all_str(f"{__HTML_HEADER}{title}{body_1}{text}{body_2}") 

319 logger(f"Finished rendering {markdown!r} to {dest_path!r}.") 

320 

321 

322def __minify(file: Path) -> None: 

323 """ 

324 Minify the given HTML file. 

325 

326 :param file: the file 

327 

328 >>> root = Path(__file__).up(4) 

329 >>> bf = BuildInfo(root, "pycommons", 

330 ... examples_dir=root.resolve_inside("examples"), 

331 ... tests_dir=root.resolve_inside("tests"), 

332 ... doc_source_dir=root.resolve_inside("docs/source"), 

333 ... doc_dest_dir=root.resolve_inside("docs/build")) 

334 

335 >>> from io import StringIO 

336 >>> from contextlib import redirect_stdout 

337 >>> from pycommons.io.temp import temp_dir 

338 >>> with temp_dir() as td: 

339 ... with redirect_stdout(None): 

340 ... __render_markdown(root.resolve_inside("README.md"), bf, 

341 ... td, None, lambda s: s) 

342 ... readme = td.resolve_inside("README_md.html") 

343 ... long = str.__len__(readme.read_all_str()) 

344 ... __minify(readme) 

345 ... short = str.__len__(readme.read_all_str()) 

346 >>> 0 < short < long 

347 True 

348 """ 

349 logger(f"Minifying HTML in {file!r}.") 

350 text: str = str.strip(file.read_all_str()) 

351 text = str.strip(minify_html.minify( # pylint: disable=E1101 

352 text, 

353 allow_noncompliant_unquoted_attribute_values=False, 

354 allow_optimal_entities=True, 

355 allow_removing_spaces_between_attributes=True, 

356 keep_closing_tags=False, 

357 keep_comments=False, 

358 keep_html_and_head_opening_tags=False, 

359 keep_input_type_text_attr=False, 

360 keep_ssi_comments=False, 

361 minify_css=True, 

362 minify_doctype=False, 

363 minify_js=True, 

364 preserve_brace_template_syntax=False, 

365 preserve_chevron_percent_template_syntax=False, 

366 remove_bangs=True, 

367 remove_processing_instructions=True)) 

368 if "<pre" not in text: 

369 text = " ".join(map(str.strip, text.splitlines())) 

370 file.write_all_str(str.strip(text)) 

371 

372 

373def __minify_all(dest: Path, skip: Callable[[str], bool]) -> None: 

374 """ 

375 Minify all files in the given destination folder. 

376 

377 :param dest: the destination 

378 :param skip: the files to skip 

379 

380 >>> root = Path(__file__).up(4) 

381 >>> bf = BuildInfo(root, "pycommons", 

382 ... examples_dir=root.resolve_inside("examples"), 

383 ... tests_dir=root.resolve_inside("tests"), 

384 ... doc_source_dir=root.resolve_inside("docs/source"), 

385 ... doc_dest_dir=root.resolve_inside("docs/build")) 

386 

387 >>> from io import StringIO 

388 >>> from contextlib import redirect_stdout 

389 >>> from pycommons.io.temp import temp_dir 

390 >>> with temp_dir() as td: 

391 ... with redirect_stdout(None): 

392 ... __render_markdown(root.resolve_inside("README.md"), bf, 

393 ... td, None, lambda s: s) 

394 ... readme = td.resolve_inside("README_md.html") 

395 ... __render_markdown(root.resolve_inside("CONTRIBUTING.md"), bf, 

396 ... td, None, lambda s: s) 

397 ... cb = td.resolve_inside("CONTRIBUTING_md.html") 

398 ... longr = str.__len__(readme.read_all_str()) 

399 ... longc = str.__len__(cb.read_all_str()) 

400 ... __minify_all(td, lambda x: False) 

401 ... shortr = str.__len__(readme.read_all_str()) 

402 ... shortc = str.__len__(cb.read_all_str()) 

403 >>> 0 < shortr < longr 

404 True 

405 >>> 0 < shortc < longc 

406 True 

407 """ 

408 if skip(dest): 

409 return 

410 if dest.is_file(): 

411 if dest.endswith(".html"): 

412 __minify(dest) 

413 elif dest.is_dir(): 

414 for f in dest.list_dir(): 

415 __minify_all(f, skip) 

416 

417 

418def __put_nojekyll(dest: Path) -> None: 

419 """ 

420 Put a `.nojekyll` file into each directory. 

421 

422 :param dest: the destination path. 

423 

424 >>> from pycommons.io.temp import temp_dir, temp_file 

425 >>> with temp_dir() as td: 

426 ... x = td.resolve_inside("x") 

427 ... x.ensure_dir_exists() 

428 ... y = x.resolve_inside("y") 

429 ... y.ensure_dir_exists() 

430 ... __put_nojekyll(td) 

431 ... td.resolve_inside(".nojekyll").is_file() 

432 ... x.resolve_inside(".nojekyll").is_file() 

433 ... y.resolve_inside(".nojekyll").is_file() 

434 True 

435 True 

436 True 

437 

438 >>> with temp_file() as tf: 

439 ... __put_nojekyll(tf) # nothing happens 

440 """ 

441 if not dest.is_dir(): 

442 return 

443 dest.resolve_inside(".nojekyll").ensure_file_exists() 

444 for f in dest.list_dir(files=False): 

445 __put_nojekyll(f) 

446 

447 

448def make_documentation(info: BuildInfo) -> None: 

449 """ 

450 Make the documentation of the project. 

451 

452 :param info: the build information 

453 

454 >>> root = Path(__file__).up(4) 

455 >>> bf = BuildInfo(root, "pycommons", 

456 ... examples_dir=root.resolve_inside("examples"), 

457 ... tests_dir=root.resolve_inside("tests"), 

458 ... doc_source_dir=root.resolve_inside("docs/source"), 

459 ... doc_dest_dir=root.resolve_inside("docs/build")) 

460 >>> from io import StringIO 

461 >>> from contextlib import redirect_stdout 

462 >>> with redirect_stdout(None): 

463 ... make_documentation(bf) 

464 

465 >>> try: 

466 ... make_documentation(1) 

467 ... except TypeError as te: 

468 ... print(str(te)[-13:]) 

469 nt, namely 1. 

470 """ 

471 if not isinstance(info, BuildInfo): 

472 raise type_error(info, "info", BuildInfo) 

473 

474 source: Final[Path | None] = info.doc_source_dir 

475 dest: Final[Path | None] = info.doc_dest_dir 

476 if (source is None) or (dest is None): 

477 raise ValueError("Need both documentation source and " 

478 f"destination, but got {info}.") 

479 logger(f"Building documentation with setup {info}.") 

480 

481 logger(f"First clearing {dest!r}.") 

482 if dest.exists(): 

483 if not dest.is_dir(): 

484 raise ValueError(f"{dest!r} exists but is no directory?") 

485 delete_path(dest) 

486 dest.ensure_dir_exists() 

487 

488 logger("Collecting all documentation source files.") 

489 retain: Final[Callable[[Path], bool]] = __get_source(source) 

490 try: 

491 logger("Building the documentation files via Sphinx.") 

492 info.command(("sphinx-apidoc", "-M", "--ext-autodoc", 

493 "-o", source, info.sources_dir)).execute() 

494 info.command(("sphinx-build", "-W", "-a", "-E", "-b", "html", 

495 source, dest)).execute() 

496 logger("Finished the Sphinx executions.") 

497 finally: 

498 logger("Clearing all auto-generated files.") 

499 __keep_only_source(source, retain) 

500 

501 logger("Now building the additional files.") 

502 

503 if info.examples_dir is not None: 

504 examples_dest: Final[Path] = dest.resolve_inside("examples") 

505 examples_dest.ensure_dir_exists() 

506 logger(f"Now pygmentizing example files to {examples_dest!r}.") 

507 for f in info.examples_dir.list_dir(directories=False): 

508 if f.endswith(".py"): 

509 __pygmentize(f, info, examples_dest) 

510 

511 logger("Now pygmentizing default files.") 

512 for fn in __PYGMENTIZE_DEFAULT: 

513 f = info.base_dir.resolve_inside(fn) 

514 if f.is_file(): 

515 __pygmentize(f, info) 

516 

517 # now printing coverage information 

518 coverage_file: Final[Path] = info.base_dir.resolve_inside(".coverage") 

519 coverage_dest: Path | None = None 

520 if coverage_file.is_file(): 

521 logger(f"Generating coverage from file {coverage_file!r}.") 

522 coverage_dest = dest.resolve_inside("tc") 

523 coverage_dest.ensure_dir_exists() 

524 delete_path(coverage_dest) 

525 try: 

526 info.command(("coverage", "html", "-d", coverage_dest, 

527 f"--data-file={coverage_file}", 

528 f"--include={info.package_name}/*")).execute() 

529 except ValueError as ve: 

530 if coverage_dest.is_dir(): 

531 raise 

532 logger(f"No coverage to report: {ve}.") 

533 else: 

534 info.command(( 

535 "coverage-badge", "-o", coverage_dest.resolve_inside( 

536 "badge.svg"))).execute() 

537 gitignore: Path = coverage_dest.resolve_inside(".gitignore") 

538 if gitignore.is_file(): # remove gitignore 

539 logger(f"Found .gitignore file {gitignore!r} - deleting it.") 

540 osremove(gitignore) 

541 else: 

542 logger("No coverage data found.") 

543 

544 # find potential style sheet 

545 static: Final[Path] = dest.resolve_inside("_static") 

546 css: str | None = None 

547 if static.is_dir(): 

548 for sst in __STYLES: 

549 css_path: Path = static.resolve_inside(sst) 

550 if css_path.is_file(): 

551 css = css_path.relative_to(dest) 

552 # To some styles, we need to add minor modifications. 

553 for add_style, add_text in __STYLE_ADDITIONS: 

554 if add_style == sst: 

555 logger(f"Improving style {css_path!r}.") 

556 with open(css_path, "a", encoding=UTF8) as stream: 

557 stream.write(add_text) 

558 break 

559 break 

560 if css is None: 

561 logger("Found no static css style.") 

562 else: 

563 logger(f"Using style sheet {css!r}.") 

564 

565 setup_cfg: Final[Path] = info.base_dir.resolve_inside("setup.cfg") 

566 url_fixer: Callable[[str], str] = str.strip 

567 if setup_cfg.is_file(): 

568 logger("Loading documentation information.") 

569 doc_info: Final[DocInfo] = load_doc_info_from_setup_cfg(setup_cfg) 

570 url_fixer = make_url_replacer( 

571 {doc_info.doc_url: "./"}, for_markdown=False) 

572 

573 logger("Now rendering all markdown files in the root directory.") 

574 for f in info.base_dir.list_dir(directories=False): 

575 if f.is_file() and f.endswith(".md") and ( 

576 not f.basename().startswith("README")): 

577 __render_markdown(f, info, dest, css, url_fixer) 

578 

579 logger("Now minifying all generated html files.") 

580 __minify_all(dest, {coverage_dest}.__contains__) 

581 

582 logger("Now putting a .nojekyll file into each directory.") 

583 __put_nojekyll(dest) 

584 

585 logger(f"Finished building documentation with setup {info}.") 

586 

587 

588# Run documentation generation process if executed as script 

589if __name__ == "__main__": 

590 parser: Final[ArgumentParser] = pycommons_argparser( 

591 __file__, 

592 "Build the Documentation", 

593 "This utility uses sphinx to build the documentation.") 

594 make_documentation(parse_project_arguments(parser))