Pages.py
Summary
- Lines:
161 of 167 covered (96.4%)
- Branches:
25 of 28 taken (89.3%), 3 lines partially
- Coverage:
95.4%
- Directory:
- Missed lines:
137-141, 148-149, 278, 382
Source
pyTooling/Sphinx/Pages.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32Pages generated from a report: a document per object of the report - a testsuite, a testcase, a source file, ... -
33built without a source file, in the navigation below the directive showing the report's summary.
34
35A report declared with the key ``pages`` gets a document per object, named below that key's document name by the
36names of the objects from the report's root down, e.g. ``<pages>/<testsuite>/<testcase>``. A name's characters other
37than letters, digits, ``_``, ``-`` and ``.`` - e.g. the brackets of a parametrized pytest testcase - are replaced by
38``_``, and a name colliding with a sibling's (also when differing only in case) gets the suffix ``-2``, ``-3``, ...
39
40The pages are built as docutils node trees and handed to Sphinx as if they were read from a file: their names are
41registered when the builder is initialized and again before the documents are read -
42:meth:`sphinx.project.Project.discover` forgets them in between -, and the file Sphinx sees as their source is the
43report - or a file a derived class names per page, e.g. a module's source -, so a changed report rebuilds them.
44A page carries the metadata ``:orphan:``. A page with children lists them in a hidden table of contents, as the
45directive showing the report's summary lists the top-level pages, so the pages are in the navigation below that
46directive's document; a page's own sections aren't listed there. Every object with a page is an object of domain
47``report``, referred to by a role of :data:`~pyTooling.Sphinx.REPORT_ROLES`.
48
49.. seealso::
50
51 :mod:`pyTooling.Sphinx.UnittestPages`
52 |rarr| The pages of a unit test report: a page per testsuite and testcase.
53"""
54from __future__ import annotations
55
56from pathlib import Path
57from re import compile as re_compile
58from time import time_ns
59from typing import Any, ClassVar, Iterable, Optional as Nullable
60
61from docutils import nodes
62from docutils.utils import DependencyList
63from sphinx import addnodes
64from sphinx.addnodes import pending_xref
65from sphinx.application import Sphinx
66from sphinx.environment import BuildEnvironment
67from sphinx.util.docutils import new_document
68from sphinx.util.logging import getLogger
69
70from pyTooling.Common import getFullyQualifiedName
71from pyTooling.Decorators import export, readonly
72from pyTooling.MetaClasses import ExtendedType, abstractmethod
73
74from pyTooling.Sphinx import ReportDomain, ReportEntry, ReportExtensionError
75
76
77__all__ = ["UNSAFE_CHARACTERS"]
78
79#: Characters of an object's name, which are replaced in its document name.
80UNSAFE_CHARACTERS = re_compile(r"[^A-Za-z0-9_.\-]+")
81
82
83@export
84class ReportPages(metaclass=ExtendedType, slots=True):
85 """
86 Base-class of the pages of one report: a document name and an entry of domain ``report`` per object.
87
88 The document names are computed once, when the report's pages are created, so the summary table, the roles and the
89 pages agree on them. A derived class names an object, lists its children, and builds its page; it declares its own
90 class variable :attr:`_reportPages`, the registry of its reports' pages.
91 """
92
93 _reportPages: ClassVar[dict[str, ReportPages]] = {} #: The pages of every report of this kind, by report ID.
94 _separator: ClassVar[str] = "." #: Separator of the names in an object's qualified name.
95
96 _reportID: str #: Identifier of the report.
97 _reportFile: Path #: The report file, the pages' source for Sphinx.
98 _entries: dict[int, ReportEntry] #: The entries, by the ``id()`` of their object.
99 _documents: dict[str, tuple[str, Any]] #: Role name and object, by document name.
100 _topLevel: list[str] #: Document names of the top-level objects' pages.
101
102 def __init__(self, reportID: str, prefix: str, reportFile: Path, topLevel: Iterable[tuple[str, Any]]) -> None:
103 """
104 Compute the document names and entries of a report's objects.
105
106 An object whose qualified name is already taken by another of the same kind gets no page; this is logged as a
107 warning.
108
109 :param reportID: Identifier of the report.
110 :param prefix: Document name the pages are generated below.
111 :param reportFile: The report file.
112 :param topLevel: The top-level objects as ``(role name, object)`` pairs, in the order of their pages.
113 """
114 self._reportID = reportID
115 self._reportFile = reportFile.resolve()
116 self._entries = {}
117 self._documents = {}
118
119 qualifiedNames: dict[str, set[str]] = {}
120
121 def addPages(children: Iterable[tuple[str, Any]], path: tuple[str, ...], directory: str) -> list[str]:
122 """
123 Nested function adding a document name and entry per object, recursing into the objects' children.
124
125 :param children: The objects of one parent as ``(role name, object)`` pairs.
126 :param path: The parent's path below the report's root.
127 :param directory: The parent's document name, the directory of its children's documents.
128 :returns: The document names of the objects, which got a page.
129 """
130 siblings: set[str] = set()
131 docNames: list[str] = []
132 for roleName, child in children:
133 name = self._Name(child)
134 childPath = (*path, *self._PathElements(name))
135 qualifiedName = self._separator.join(childPath)
136 if qualifiedName in (names := qualifiedNames.setdefault(roleName, set())):
137 getLogger(__name__).warning(
138 f"Report '{reportID}': the qualified name '{qualifiedName}' is not unique, "
139 f"so the second one gets no page."
140 )
141 continue
142
143 names.add(qualifiedName)
144
145 baseName = UNSAFE_CHARACTERS.sub("_", name).strip("_.") or "_"
146 fileName, counter = baseName, 1
147 while fileName.lower() in siblings:
148 counter += 1
149 fileName = f"{baseName}-{counter}"
150 siblings.add(fileName.lower())
151
152 docName = f"{directory}/{fileName}"
153 self._entries[id(child)] = ReportEntry(docName, reportID, childPath, self._separator, self._Title(child))
154 self._documents[docName] = (roleName, child)
155 docNames.append(docName)
156
157 addPages(self._Children(roleName, child), childPath, docName)
158
159 return docNames
160
161 self._topLevel = addPages(topLevel, (), prefix)
162
163 @staticmethod
164 def CheckPagesConfiguration(configurationName: str, pages: Any) -> str:
165 """
166 Check the document name a report's pages are generated below - the value of a report's key ``pages`` -, and
167 normalize it.
168
169 :param configurationName: Name of the report's configuration, as an error message names it.
170 :param pages: The value of key ``pages``.
171 :returns: The document name without leading and trailing ``/``.
172 :raises ReportExtensionError: If ``pages`` isn't a string.
173 :raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
174 Use a name like 'unittests/src', separated by '/', without '.' or '..'.
175 """
176 if not isinstance(pages, str):
177 ex = ReportExtensionError(f"{configurationName}.pages: Document name is not a string.")
178 ex.add_note(f"Got type '{getFullyQualifiedName(pages)}'.")
179 raise ex
180
181 parts = pages.strip("/").split("/")
182 if "\\" in pages or any(part in ("", ".", "..") for part in parts):
183 ex = ReportExtensionError(f"{configurationName}.pages: '{pages}' is not a relative document name.")
184 ex.add_note("Use a name like 'unittests/src', separated by '/', without '.' or '..'.")
185 raise ex
186
187 return "/".join(parts)
188
189 @classmethod
190 def HasPages(cls, reportID: str) -> bool:
191 """
192 Check if a report has pages.
193
194 :param reportID: Identifier of the report.
195 :returns: ``True``, if the report was declared with the key ``pages``.
196 """
197 return reportID in cls._reportPages
198
199 @classmethod
200 def GetPages(cls, reportID: str) -> ReportPages:
201 """
202 Return the pages of a report.
203
204 :param reportID: Identifier of the report.
205 :returns: The report's pages.
206 :raises KeyError: If the report has no pages.
207 """
208 try:
209 return cls._reportPages[reportID]
210 except KeyError as ex:
211 raise KeyError(f"Report '{reportID}' has no pages.") from ex
212
213 @classmethod
214 def GenerateAll(cls, sphinxApplication: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None:
215 """
216 Call-back for Sphinx' ``env-before-read-docs`` event: generate the pages of every report of this kind.
217
218 :param sphinxApplication: Sphinx application instance.
219 :param env: The build environment.
220 :param docnames: The documents to read, from which the generated pages are removed.
221 """
222 for pages in cls._reportPages.values():
223 pages.Generate(sphinxApplication)
224
225 docnames[:] = [docname for docname in docnames if docname not in pages._documents]
226
227 @classmethod
228 def HideSource(
229 cls,
230 sphinxApplication: Sphinx,
231 pagename: str,
232 templatename: str,
233 context: dict[str, object],
234 doctree: Nullable[nodes.document]
235 ) -> None:
236 """
237 Call-back for Sphinx' ``html-page-context`` event: a generated page has no source to copy or link.
238
239 Without it, the HTML builder would copy the report file - the page's source for Sphinx - per page.
240
241 :param sphinxApplication: Sphinx application instance.
242 :param pagename: Name of the page.
243 :param templatename: Name of the page's template.
244 :param context: The template's context.
245 :param doctree: The page's doctree.
246 """
247 if any(pagename in pages._documents for pages in cls._reportPages.values()):
248 context["sourcename"] = ""
249
250 @staticmethod
251 def CreateReference(roleName: str, entry: ReportEntry, docname: str, text: Nullable[str] = None) -> pending_xref:
252 """
253 Create a reference to an object's page, showing its name or a text.
254
255 :param roleName: The role referring to the object, e.g. ``tc`` for a testcase.
256 :param entry: The object.
257 :param docname: Name of the document holding the reference.
258 :param text: Optional, the text shown instead of the object's name. Default: ``None``.
259 :returns: The reference, resolved by domain ``report``.
260 """
261 reference = pending_xref(
262 "", refdomain=ReportDomain.name, reftype=roleName, reftarget=f"{entry.reportID}:{entry.QualifiedName}",
263 refexplicit=text is not None, refwarn=True, refdoc=docname
264 )
265 text = entry.Name if text is None else text
266 classes = ["xref", ReportDomain.name, f"{ReportDomain.name}-{roleName}"]
267 reference += nodes.inline(text, text, classes=classes)
268
269 return reference
270
271 @readonly
272 def ReportID(self) -> str:
273 """
274 Read-only property to access the report's identifier (:attr:`_reportID`).
275
276 :returns: The identifier of the report.
277 """
278 return self._reportID
279
280 @readonly
281 def DocNames(self) -> tuple[str, ...]:
282 """
283 Read-only property to return the names of the generated documents.
284
285 :returns: The document names, an object before its children.
286 """
287 return tuple(self._documents)
288
289 def Entry(self, entity: Any) -> Nullable[ReportEntry]:
290 """
291 Return the entry of an object of this report.
292
293 :param entity: The object.
294 :returns: Its entry, or ``None`` if it has no page.
295 """
296 return self._entries.get(id(entity), None)
297
298 def Register(self, env: BuildEnvironment) -> None:
299 """
300 Register the document names, each with its source (see :meth:`_Source`).
301
302 :param env: The build environment.
303 """
304 for docName, (_, entity) in self._documents.items():
305 env.project.docnames.add(docName)
306 env.project._docname_to_path[docName] = self._Source(entity)
307
308 def Generate(self, sphinxApplication: Sphinx) -> None:
309 """
310 Build the doctree of every page, have the environment collect it, and store it as if it was read.
311
312 The event ``doctree-read`` is emitted per page, so the environment's collectors record its title, table of
313 contents and metadata. Afterwards, the page's table of contents keeps the pages it lists, and loses its sections,
314 so the navigation shows the report's objects only.
315
316 :param sphinxApplication: Sphinx application instance.
317 """
318 env = sphinxApplication.env
319 domain: ReportDomain = env.domains[ReportDomain.name] # type: ignore[assignment]
320
321 self.Register(env)
322 for docName, (roleName, entity) in self._documents.items():
323 document = new_document(str(self._Source(entity)))
324 document.settings.env = env
325 document.settings.record_dependencies = DependencyList()
326 document += nodes.docinfo("", nodes.field("", nodes.field_name("", "orphan"), nodes.field_body()))
327 document += self._Page(docName, roleName, entity)
328
329 env.prepare_settings(docName)
330 try:
331 sphinxApplication.events.emit("doctree-read", document)
332 finally:
333 env.prepare_settings("")
334 env.ref_context.clear()
335
336 pageEntry = env.tocs[docName][0]
337 for entries in pageEntry[1:]:
338 entries[:] = [entry for entry in entries if isinstance(entry, addnodes.toctree)]
339 if len(entries) == 0:
340 pageEntry.remove(entries)
341
342 env.all_docs[docName] = time_ns() // 1_000
343 domain.AddEntry(roleName, self._entries[id(entity)])
344 sphinxApplication.builder.write_doctree(docName, document)
345
346 def TableOfContents(self, docName: str) -> nodes.compound:
347 """
348 Create the hidden table of contents of the report's top-level pages, for the document showing its summary.
349
350 The pages are then below that document in the navigation.
351
352 :param docName: Name of the document showing the report's summary.
353 :returns: The table of contents.
354 """
355 return self._TableOfContents(docName, self._topLevel)
356
357 @staticmethod
358 def _TableOfContents(docName: str, children: list[str]) -> nodes.compound:
359 """
360 Create a hidden table of contents listing pages.
361
362 :param docName: Name of the document holding the table of contents.
363 :param children: Names of the listed pages' documents.
364 :returns: The table of contents.
365 """
366 toctree = addnodes.toctree(
367 parent=docName, entries=[(None, child) for child in children], includefiles=children, maxdepth=1,
368 caption=None, glob=False, hidden=True, includehidden=False, titlesonly=True, numbered=0
369 )
370 return nodes.compound("", toctree, classes=["toctree-wrapper"])
371
372 def _Reference(self, roleName: str, entity: Any, docName: str) -> nodes.Node:
373 """
374 Create a reference to an object's page, or its name if it has no page.
375
376 :param roleName: The role referring to the object.
377 :param entity: The object.
378 :param docName: Name of the document holding the reference.
379 :returns: The reference, or the name as text.
380 """
381 if (entry := self.Entry(entity)) is None:
382 return nodes.Text(self._Name(entity))
383
384 return self.CreateReference(roleName, entry, docName)
385
386 @staticmethod
387 def _Field(fieldList: nodes.field_list, name: str, *content: nodes.Node) -> None:
388 """
389 Add a field to a page's summary.
390
391 :param fieldList: The summary's field list.
392 :param name: The field's name.
393 :param content: The field's value.
394 """
395 fieldList += nodes.field("", nodes.field_name(name, name), nodes.field_body("", nodes.paragraph("", "", *content)))
396
397 @staticmethod
398 def _Table(columns: list[tuple[str, int]], classes: list[str]) -> tuple[nodes.table, nodes.tbody]:
399 """
400 Create a table with a header row.
401
402 :param columns: One ``(title, width)`` pair per column.
403 :param classes: CSS classes of the table.
404 :returns: The table, and its body the rows are added to.
405 """
406 table = nodes.table("", classes=classes)
407 table += (tableGroup := nodes.tgroup(cols=len(columns)))
408 for _, width in columns:
409 tableGroup += nodes.colspec(colwidth=width)
410
411 tableGroup += (tableHeader := nodes.thead())
412 tableHeader += (headerRow := nodes.row())
413 for columnTitle, _ in columns:
414 headerRow += nodes.entry("", nodes.paragraph(columnTitle, columnTitle))
415
416 tableGroup += (tableBody := nodes.tbody())
417 return table, tableBody
418
419 @abstractmethod
420 def _Name(self, entity: Any) -> str:
421 """
422 Return an object's name, the last element of its path.
423
424 :param entity: The object.
425 :returns: The name.
426 """
427
428 def _PathElements(self, name: str) -> tuple[str, ...]:
429 """
430 Return the elements an object's name adds to its path: the name itself, unless a derived class splits it.
431
432 :param name: The object's name.
433 :returns: The path elements.
434 """
435 return (name, )
436
437 def _Source(self, entity: Any) -> Path:
438 """
439 Return the file Sphinx regards as the source of an object's page: the report file, unless a derived class names
440 another one.
441
442 :param entity: The object.
443 :returns: The page's source file.
444 """
445 return self._reportFile
446
447 @abstractmethod
448 def _Title(self, entity: Any) -> Nullable[str]:
449 """
450 Return an object's title written for a reader, if it differs from its name.
451
452 :param entity: The object.
453 :returns: The title, or ``None``.
454 """
455
456 @abstractmethod
457 def _Children(self, roleName: str, entity: Any) -> Iterable[tuple[str, Any]]:
458 """
459 Return an object's children, in the order of their pages.
460
461 :param roleName: The role referring to the object.
462 :param entity: The object.
463 :returns: The children as ``(role name, object)`` pairs.
464 """
465
466 @abstractmethod
467 def _Page(self, docName: str, roleName: str, entity: Any) -> nodes.section:
468 """
469 Build an object's page.
470
471 :param docName: Name of the page's document.
472 :param roleName: The role referring to the object.
473 :param entity: The object.
474 :returns: The page's top section.
475 """