Coverage for pycommons / dev / doc / doc_info.py: 100%

148 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 03:04 +0000

1""" 

2The documentation information. 

3 

4The :class:`~pycommons.dev.doc.doc_info.DocInfo` holds the basic data needed 

5to generate documentation in a unified way. For now, this data is loaded 

6from the `setup.cfg` file, in which the process expects to find references 

7to a `version.py` and `README.md` file. Then, it loads the information from 

8these files as well. This spares us the trouble of defining the information 

9in several places and keeping it synchronized. 

10""" 

11from configparser import ConfigParser 

12from dataclasses import dataclass 

13from typing import Final 

14 

15from pycommons.io.console import logger 

16from pycommons.io.path import UTF8, Path, file_path 

17from pycommons.net.url import URL 

18from pycommons.types import check_int_range, check_to_int_range 

19 

20 

21@dataclass(frozen=True, init=False, order=False, eq=False) 

22class DocInfo: 

23 """ 

24 A class that represents information about documentation. 

25 

26 >>> di = DocInfo(__file__, "a", "b", "c", "1", 12, "https://example.com") 

27 >>> di.doc_url 

28 'https://example.com' 

29 >>> di.last_major_section_index 

30 12 

31 >>> di.project 

32 'a' 

33 >>> di.author 

34 'b' 

35 >>> di.title 

36 'c' 

37 >>> di.readme_md_file[-11:] 

38 'doc_info.py' 

39 

40 >>> di = DocInfo(__file__, "a", "b", "c", "1", None, 

41 ... "https://example.com") 

42 >>> di.doc_url 

43 'https://example.com' 

44 >>> print(di.last_major_section_index) 

45 None 

46 >>> di.title 

47 'c' 

48 >>> di.readme_md_file[-11:] 

49 'doc_info.py' 

50 

51 >>> try: 

52 ... DocInfo(None, "a", "b", "c", "1", 12, "https://example.com") 

53 ... except TypeError as te: 

54 ... print(te) 

55 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

56 

57 >>> try: 

58 ... DocInfo(1, "a", "b", "c", "1", 12, "https://example.com") 

59 ... except TypeError as te: 

60 ... print(te) 

61 descriptor '__len__' requires a 'str' object but received a 'int' 

62 

63 >>> try: 

64 ... DocInfo(__file__, None, "b", "c", "1", 12, "https://example.com") 

65 ... except TypeError as te: 

66 ... print(te) 

67 descriptor 'strip' for 'str' objects doesn't apply to a 'NoneType' object 

68 

69 >>> try: 

70 ... DocInfo(__file__, 1, "b", "c", "1", 12, "https://example.com") 

71 ... except TypeError as te: 

72 ... print(te) 

73 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

74 

75 >>> try: 

76 ... DocInfo(__file__, " ", "b", "c", "1", 12, "https://example.com") 

77 ... except ValueError as ve: 

78 ... print(ve) 

79 Invalid project name ' '. 

80 

81 >>> try: 

82 ... DocInfo(__file__, "a", None, "c", "1", 12, "https://example.com") 

83 ... except TypeError as te: 

84 ... print(te) 

85 descriptor 'strip' for 'str' objects doesn't apply to a 'NoneType' object 

86 

87 >>> try: 

88 ... DocInfo(__file__, "a", 1, "c", "1", 12, "https://example.com") 

89 ... except TypeError as te: 

90 ... print(te) 

91 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

92 

93 >>> try: 

94 ... DocInfo(__file__, "a", " ", "c", "1", 12, "https://example.com") 

95 ... except ValueError as ve: 

96 ... print(ve) 

97 Invalid author name ' '. 

98 

99 >>> try: 

100 ... DocInfo(__file__, "a", "b", None, "1", 12, "https://example.com") 

101 ... except TypeError as te: 

102 ... print(te) 

103 descriptor 'strip' for 'str' objects doesn't apply to a 'NoneType' object 

104 

105 >>> try: 

106 ... DocInfo(__file__, "a", "b", 1, "1", 12, "https://example.com") 

107 ... except TypeError as te: 

108 ... print(te) 

109 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

110 

111 >>> try: 

112 ... DocInfo(__file__, "b", "c", " ", "1", 12, "https://example.com") 

113 ... except ValueError as ve: 

114 ... print(ve) 

115 Invalid title ' '. 

116 

117 >>> try: 

118 ... DocInfo(__file__, "a", "b", "c", None, 12, "https://example.com") 

119 ... except TypeError as te: 

120 ... print(te) 

121 descriptor 'strip' for 'str' objects doesn't apply to a 'NoneType' object 

122 

123 >>> try: 

124 ... DocInfo(__file__, "a", "b", "c", 1, 12, "https://example.com") 

125 ... except TypeError as te: 

126 ... print(te) 

127 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

128 

129 >>> try: 

130 ... DocInfo(__file__, "a", "b", "c", "x", 12, "https://example.com") 

131 ... except ValueError as ve: 

132 ... print(str(ve)[:64]) 

133 Invalid version 'x': Cannot convert version='x' to int, let alon 

134 

135 >>> try: 

136 ... DocInfo(__file__, "a", "b", "c", " ", 12, "https://example.com") 

137 ... except ValueError as ve: 

138 ... print(str(ve)[:64]) 

139 Invalid version ' ': empty or only white space. 

140 

141 

142 >>> try: 

143 ... DocInfo(__file__, "a", "b", "c", "1.x", 12, "https://example.com") 

144 ... except ValueError as ve: 

145 ... print(str(ve)[:64]) 

146 Invalid version '1.x': Cannot convert version='x' to int, let al 

147 

148 >>> try: 

149 ... DocInfo(__file__, "a", "b", "c", "-1", 12, "https://example.com") 

150 ... except ValueError as ve: 

151 ... print(str(ve)[:64]) 

152 Invalid version '-1': version=-1 is invalid, must be in 0..10000 

153 

154 >>> try: 

155 ... DocInfo(__file__, "a", "b", "c", "0.-1", 12, "https://example.com") 

156 ... except ValueError as ve: 

157 ... print(str(ve)[:64]) 

158 Invalid version '0.-1': version=-1 is invalid, must be in 0..100 

159 

160 >>> try: 

161 ... DocInfo(__file__, "a", "b", "c", "1.2", "x", 

162 ... "https://example.com") 

163 ... except TypeError as te: 

164 ... print(str(te)[:60]) 

165 last_major_section_index should be an instance of int but is 

166 

167 >>> try: 

168 ... DocInfo(__file__, "a", "b", "c", "1.2", 0, "https://example.com") 

169 ... except ValueError as ve: 

170 ... print(ve) 

171 last_major_section_index=0 is invalid, must be in 1..1000000. 

172 

173 >>> try: 

174 ... DocInfo(__file__, "a", "b", "c", "1.2", 12, None) 

175 ... except TypeError as te: 

176 ... print(te) 

177 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

178 

179 >>> try: 

180 ... DocInfo(__file__, "a", "b", "c", "1.2", 12, 1) 

181 ... except TypeError as te: 

182 ... print(te) 

183 descriptor '__len__' requires a 'str' object but received a 'int' 

184 

185 >>> try: 

186 ... DocInfo(__file__, "a", "b", "c", "1.2", 12, "dfg") 

187 ... except ValueError as ve: 

188 ... print(ve) 

189 URL part '' has invalid length 0. 

190 """ 

191 

192 #: The readme.md file. 

193 readme_md_file: Path 

194 #: The project name. 

195 project: str 

196 #: The author name. 

197 author: str 

198 #: The documentation title. 

199 title: str 

200 #: The version string. 

201 version: str 

202 #: The index of the last major section 

203 last_major_section_index: int | None 

204 #: the base URL of the documentation 

205 doc_url: URL 

206 

207 def __init__(self, readme_md: Path, project: str, author: str, 

208 title: str, version: str, 

209 last_major_section_index: int, doc_url: str) -> None: 

210 """ 

211 Create the documentation information class. 

212 

213 :param readme_md: the path to the `README.md` file 

214 :param project: the project name 

215 :param author: the author name 

216 :param title: the title string 

217 :param version: the version string 

218 :param last_major_section_index: the index of the last major section, 

219 or `None` 

220 :param doc_url: the base URL of the documentation 

221 """ 

222 object.__setattr__(self, "readme_md_file", file_path(readme_md)) 

223 

224 uproject = str.strip(project) 

225 if str.__len__(uproject) <= 0: 

226 raise ValueError(f"Invalid project name {project!r}.") 

227 object.__setattr__(self, "project", uproject) 

228 

229 uauthor = str.strip(author) 

230 if str.__len__(uauthor) <= 0: 

231 raise ValueError(f"Invalid author name {author!r}.") 

232 object.__setattr__(self, "author", uauthor) 

233 

234 utitle = str.strip(title) 

235 if str.__len__(utitle) <= 0: 

236 raise ValueError(f"Invalid title {title!r}.") 

237 object.__setattr__(self, "title", utitle) 

238 

239 uversion = str.strip(version) 

240 if str.__len__(uversion) <= 0: 

241 raise ValueError( 

242 f"Invalid version {version!r}: empty or only white space.") 

243 for v in uversion.split("."): # noqa: PERF203 

244 try: # noqa: PERF203 

245 check_to_int_range(v, "version", 0, 1_000_000_000) 

246 except ValueError as ve: # noqa: PERF203 

247 raise ValueError( 

248 f"Invalid version {version!r}: " 

249 f"{str(ve).removesuffix('.')}.") from ve 

250 object.__setattr__(self, "version", uversion) 

251 

252 object.__setattr__( 

253 self, "last_major_section_index", None 

254 if last_major_section_index is None else check_int_range( 

255 last_major_section_index, "last_major_section_index", 1, 

256 1_000_000)) 

257 object.__setattr__(self, "doc_url", URL(doc_url)) 

258 

259 def __str__(self) -> str: 

260 """ 

261 Convert this object to a string. 

262 

263 :returns: the string version of this object. 

264 

265 >>> print(str(DocInfo(__file__, "a", "b", "c", "1", 

266 ... 12, "https://example.com"))[:40]) 

267 'c' project 'a' by 'b', version '1', wit 

268 """ 

269 return (f"{self.title!r} project {self.project!r} by " 

270 f"{self.author!r}, version {self.version!r}, " 

271 f"with readme file {self.readme_md_file!r} having the last " 

272 f"section {self.last_major_section_index} and documentation " 

273 f"url {self.doc_url}.") 

274 

275 

276def extract_md_infos(readme_md_file: str) -> tuple[str, int | None]: 

277 """ 

278 Parse a `README.md` file and find the title and last section index. 

279 

280 :param readme_md_file: the path to the `README.md` 

281 :returns: a tuple of the title (headline starting with `"# "` (but without 

282 the `"# "`), and the last section index, if any) 

283 

284 >>> from os.path import join, dirname 

285 >>> from contextlib import redirect_stdout 

286 >>> with redirect_stdout(None): 

287 ... t = extract_md_infos(join(dirname(dirname(dirname(dirname( 

288 ... __file__)))), "README.md")) 

289 >>> print(t) 

290 ('*pycommons:* Common Utility Functions for Python Projects.', 5) 

291 """ 

292 readme_md: Final[Path] = file_path(readme_md_file) 

293 logger(f"Now parsing markdown file {readme_md!r}.") 

294 

295 # load both the title and the last index 

296 title: str | None = None 

297 last_idx: int | None = None 

298 in_code: bool = False 

299 with (readme_md.open_for_read() as rd): # noqa: PERF203 

300 for orig_line in rd: 

301 line: str = str.strip(orig_line) # force string 

302 # skip all code snippets in the file 

303 if line.startswith("```"): 

304 in_code = not in_code 

305 continue 

306 if in_code: 

307 continue 

308 # ok, we are not in code 

309 if line.startswith("# "): # top-level headline 

310 if title is not None: # only 1 top-level headline permitted 

311 raise ValueError( 

312 f"Already have title {title!r} but now found " 

313 f"{line[2:]!r} in {readme_md!r}.") 

314 title = str.strip(line[2:]) 

315 elif line.startswith("## "): # second-level headline 

316 doti: int = line.find(".") # gather numeric index, if any 

317 if doti <= 3: 

318 if last_idx is not None: 

319 raise ValueError(f"Got {line!r} after having index.") 

320 else: 

321 idx_str = str.strip(line[3:doti]) 

322 try: # noqa: PERF203 

323 index = check_to_int_range(idx_str, "s", 1, 1000) 

324 except ValueError as ve: # noqa: PERF203 

325 index = None 

326 if last_idx is not None: 

327 raise ValueError( 

328 f"Got {line!r} and finding index " 

329 f"{last_idx} in {readme_md!r} causing " 

330 f"{str(ve).removesuffix('.')}.") from ve 

331 if index is not None: 

332 if (last_idx is not None) and (last_idx >= index): 

333 raise ValueError( 

334 f"Found index {index} in line {line!r} " 

335 f"after index {last_idx} in " 

336 f"{readme_md!r}.") 

337 last_idx = index 

338 

339 if title is None: 

340 raise ValueError(f"No title in {readme_md!r}.") 

341 

342 logger(f"Finished parsing markdown file {readme_md!r}, got " 

343 f"title {title!r} and last section index {last_idx}.") 

344 return title, last_idx 

345 

346 

347def parse_version_py(version_file: str, 

348 version_attr: str = "__version__") -> str: 

349 """ 

350 Parse a `version.py` file and return the version string. 

351 

352 :param version_file: the path to the version file 

353 :param version_attr: the version attribute 

354 :returns: the version string 

355 

356 >>> from os.path import join, dirname 

357 >>> from contextlib import redirect_stdout 

358 >>> with redirect_stdout(None): 

359 ... s = parse_version_py(join(dirname(dirname(dirname(__file__))), 

360 ... "version.py")) 

361 >>> print(s[:s.rindex(".")]) 

362 0.8 

363 

364 >>> try: 

365 ... parse_version_py(None, "v") 

366 ... except TypeError as te: 

367 ... print(te) 

368 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

369 

370 >>> try: 

371 ... parse_version_py(1, "v") 

372 ... except TypeError as te: 

373 ... print(te) 

374 descriptor '__len__' requires a 'str' object but received a 'int' 

375 

376 >>> try: 

377 ... parse_version_py(__file__, None) 

378 ... except TypeError as te: 

379 ... print(te) 

380 descriptor 'strip' for 'str' objects doesn't apply to a 'NoneType' object 

381 

382 >>> try: 

383 ... parse_version_py(__file__, 1) 

384 ... except TypeError as te: 

385 ... print(te) 

386 descriptor 'strip' for 'str' objects doesn't apply to a 'int' object 

387 

388 >>> try: 

389 ... parse_version_py(__file__, "") 

390 ... except ValueError as ve: 

391 ... print(ve) 

392 Invalid version attr ''. 

393 

394 >>> from contextlib import redirect_stdout 

395 >>> from io import StringIO 

396 >>> try: 

397 ... with redirect_stdout(None): 

398 ... parse_version_py(__file__, "xyz") 

399 ... except ValueError as ve: 

400 ... print(str(ve)[:36]) 

401 Did not find version attr 'xyz' in ' 

402 """ 

403 version_path: Final[Path] = file_path(version_file) 

404 uversion_attr = str.strip(version_attr) 

405 if str.__len__(uversion_attr) <= 0: 

406 raise ValueError(f"Invalid version attr {version_attr!r}.") 

407 logger(f"Now parsing version file {version_path!r}, looking for " 

408 f"attribute {uversion_attr!r}.") 

409 

410 version_str: str | None = None 

411 # load the version string 

412 with version_path.open_for_read() as rd: 

413 for orig_line in rd: 

414 line = str.strip(orig_line) 

415 lst: list[str] = [str.strip(item) for sublist in 

416 line.split("=") for item in sublist.split(":")] 

417 if lst[0] == uversion_attr: 

418 if version_str is not None: 

419 raise ValueError( 

420 f"Version defined as {version_str!r} in " 

421 f"{version_path!r} but encountered {orig_line!r}?") 

422 if list.__len__(lst) <= 1: 

423 raise ValueError(f"Strange version string {orig_line!r} " 

424 f"in {version_path!r}.") 

425 version_tst: str = lst[-1] 

426 for se in ("'", '"'): 

427 if version_tst.startswith(se): 

428 if not version_tst.endswith(se): 

429 raise ValueError( 

430 f"Incorrect string limits for {orig_line!r}" 

431 f" in version file {version_path!r}.") 

432 version_str = str.strip(version_tst[1:-1]) 

433 break 

434 if version_str is None: 

435 raise ValueError(f"Undelimited string in {orig_line!r} in" 

436 f" version file {version_path!r}?") 

437 if version_str is None: 

438 raise ValueError(f"Did not find version attr {uversion_attr!r} in " 

439 f"{version_path!r}.") 

440 

441 logger(f"Found version string {version_str!r} in file {version_path!r}.") 

442 return version_str 

443 

444 

445def load_doc_info_from_setup_cfg(setup_cfg_file: str) -> DocInfo: 

446 """ 

447 Load the documentation information from the `setup.cfg` file. 

448 

449 :param setup_cfg_file: the path to the `setup.cfg` file. 

450 :returns: the documentation information 

451 

452 >>> from os.path import dirname, join 

453 >>> from contextlib import redirect_stdout 

454 >>> with redirect_stdout(None): 

455 ... r = load_doc_info_from_setup_cfg(join(dirname(dirname(dirname( 

456 ... dirname(__file__)))), "setup.cfg")) 

457 >>> r.title 

458 '*pycommons:* Common Utility Functions for Python Projects.' 

459 >>> r.doc_url 

460 'https://thomasweise.github.io/pycommons' 

461 >>> r.project 

462 'pycommons' 

463 >>> r.author 

464 'Thomas Weise' 

465 """ 

466 setup_cfg: Final[Path] = file_path(setup_cfg_file) 

467 logger(f"Now loading documentation info from {setup_cfg!r}.") 

468 cfg: Final[ConfigParser] = ConfigParser() 

469 cfg.read(setup_cfg, UTF8) 

470 root_path: Final[Path] = setup_cfg.up(1) 

471 

472 # first get version string 

473 version_attr: Final[str] = str.strip(cfg.get("metadata", "version")) 

474 if str.__len__(version_attr) <= 0: 

475 raise ValueError(f"Invalid version attribute {version_attr!r}.") 

476 version_str: str | None = None 

477 if version_attr.startswith("attr: "): 

478 version_splt: list[str] = str.split(str.strip(version_attr[6:]), ".") 

479 version_file = root_path 

480 for f in version_splt[:-2]: # find file 

481 version_file = version_file.resolve_inside(f) 

482 version_str = parse_version_py( 

483 version_file.resolve_inside(version_splt[-2] + ".py"), 

484 version_splt[-1]) 

485 else: 

486 version_str = version_attr 

487 

488 # now load data from readme md 

489 long_desc_attr: Final[str] = str.strip(cfg.get( 

490 "metadata", "long_description")) 

491 if str.__len__(long_desc_attr) <= 0: 

492 raise ValueError( 

493 f"Invalid long_description attribute {long_desc_attr!r}.") 

494 if not long_desc_attr.startswith("file:"): 

495 raise ValueError(f"long_description {long_desc_attr!r} does " 

496 f"not point to file.") 

497 readme_md_file: Final[Path] = root_path.resolve_inside( 

498 str.strip(long_desc_attr[6:])) 

499 title, last_sec = extract_md_infos(readme_md_file) 

500 

501 # get the documentation URL 

502 docu_url: str | None = None 

503 for url in str.splitlines(str.strip(cfg.get( 

504 "metadata", "project_urls"))): 

505 splt: list[str] = url.split("=") 

506 if list.__len__(splt) != 2: 

507 raise ValueError(f"Strange URL line {url!r}.") 

508 if str.strip(splt[0]).lower() == "documentation": 

509 if docu_url is not None: 

510 raise ValueError("Two docu URLs found?") 

511 docu_url = str.strip(splt[1]) 

512 if docu_url is None: 

513 docu_url = cfg.get("metadata", "url") 

514 

515 res: Final[DocInfo] = DocInfo( 

516 readme_md_file, str.strip(cfg.get("metadata", "name")), 

517 str.strip(cfg.get("metadata", "author")), title, version_str, 

518 last_sec, docu_url) 

519 

520 logger("Finished loading documentation info " 

521 f"from {setup_cfg!r}, found {res}") 

522 return res