Pages.py

Summary

Lines:

161 of 167 covered (96.4%)

Branches:

25 of 28 taken (89.3%), 3 lines partially

Coverage:

95.4%

Directory:

Sphinx

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		"""