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
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 03:04 +0000
1"""
2The documentation information.
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
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
21@dataclass(frozen=True, init=False, order=False, eq=False)
22class DocInfo:
23 """
24 A class that represents information about documentation.
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'
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'
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'
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'
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
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
75 >>> try:
76 ... DocInfo(__file__, " ", "b", "c", "1", 12, "https://example.com")
77 ... except ValueError as ve:
78 ... print(ve)
79 Invalid project name ' '.
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
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
93 >>> try:
94 ... DocInfo(__file__, "a", " ", "c", "1", 12, "https://example.com")
95 ... except ValueError as ve:
96 ... print(ve)
97 Invalid author name ' '.
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
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
111 >>> try:
112 ... DocInfo(__file__, "b", "c", " ", "1", 12, "https://example.com")
113 ... except ValueError as ve:
114 ... print(ve)
115 Invalid title ' '.
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
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
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
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.
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
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
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
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
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.
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'
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'
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 """
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
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.
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))
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)
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)
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)
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)
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))
259 def __str__(self) -> str:
260 """
261 Convert this object to a string.
263 :returns: the string version of this object.
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}.")
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.
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)
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}.")
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
339 if title is None:
340 raise ValueError(f"No title in {readme_md!r}.")
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
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.
352 :param version_file: the path to the version file
353 :param version_attr: the version attribute
354 :returns: the version string
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
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'
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'
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
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
388 >>> try:
389 ... parse_version_py(__file__, "")
390 ... except ValueError as ve:
391 ... print(ve)
392 Invalid version attr ''.
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}.")
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}.")
441 logger(f"Found version string {version_str!r} in file {version_path!r}.")
442 return version_str
445def load_doc_info_from_setup_cfg(setup_cfg_file: str) -> DocInfo:
446 """
447 Load the documentation information from the `setup.cfg` file.
449 :param setup_cfg_file: the path to the `setup.cfg` file.
450 :returns: the documentation information
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)
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
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)
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")
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)
520 logger("Finished loading documentation info "
521 f"from {setup_cfg!r}, found {res}")
522 return res