Pages.py

Summary

Lines:

206 of 213 covered (96.7%)

Branches:

31 of 34 taken (91.2%), 3 lines partially

Coverage:

96.0%

Directory:

Sphinx

Missed lines:

157-160, 167-168, 277, 591, 612

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 model: a document per object of the model - a testsuite, a testcase, a source file, a VHDL
33entity, ... - built without a source file, in the navigation below the directive showing the model's summary.
34
35:class:`GeneratedPages` holds what doesn't depend on the model or the domain: the document names, the registration and
36generation of the documents, and the tables of contents. :class:`ReportPages` adds the objects of domain ``report``:
37an entry per page, which a role of :data:`~pyTooling.Sphinx.REPORT_ROLES` refers to. Another domain - e.g. a VHDL
38domain - derives from :class:`GeneratedPages`, names the type of its objects as the type argument
39(``GeneratedPages[Library | DesignUnit]``), names its domain in :attr:`GeneratedPages._DOMAIN`, and registers its
40objects in :meth:`GeneratedPages._Registered`; a page may also be written as reStructuredText, see
41:meth:`GeneratedPages._Fill`.
42
43A report declared with the key ``pages`` gets a document per object, named below that key's document name by the
44names of the objects from the report's root down, e.g. ``<pages>/<testsuite>/<testcase>``. A name's characters other
45than letters, digits, ``_``, ``-`` and ``.`` - e.g. the brackets of a parametrized pytest testcase - are replaced by
46``_``, and a name colliding with a sibling's (also when differing only in case) gets the suffix ``-2``, ``-3``, ...
47
48The pages are built as docutils node trees and handed to Sphinx as if they were read from a file: their names are
49registered when the builder is initialized and again before the documents are read -
50:meth:`sphinx.project.Project.discover` forgets them in between -, and the file Sphinx sees as their source is the
51report - or a file a derived class names per page, e.g. a module's source -, so a changed report rebuilds them.
52A page carries the metadata ``:orphan:``. A page with children lists them in a hidden table of contents, as the
53directive showing the report's summary lists the top-level pages, so the pages are in the navigation below that
54directive's document; a page's own sections aren't listed there. Every object with a page is an object of domain
55``report``, referred to by a role of :data:`~pyTooling.Sphinx.REPORT_ROLES`.
56
57.. seealso::
58
59   :mod:`pyTooling.Sphinx.UnittestPages`
60      |rarr| The pages of a unit test report: a page per testsuite and testcase.
61"""
62from __future__                     import annotations
63
64from pathlib                        import Path
65from re                             import compile as re_compile
66from time                           import time_ns
67from typing                         import Any, ClassVar, Generic, Iterable, Optional as Nullable, TypeVar
68
69from docutils                       import nodes
70from docutils.frontend              import get_default_settings
71from docutils.readers.standalone    import Reader
72from docutils.transforms.references import DanglingReferences
73from docutils.utils                 import DependencyList
74from sphinx                         import addnodes
75from sphinx.addnodes                import pending_xref
76from sphinx.application             import Sphinx
77from sphinx.domains                 import Domain
78from sphinx.environment             import BuildEnvironment
79from sphinx.transforms              import DoctreeReadEvent, SphinxTransformer
80from sphinx.util.docutils           import new_document, sphinx_domains
81from sphinx.util.logging            import getLogger
82
83from pyTooling.Common               import getFullyQualifiedName
84from pyTooling.Decorators           import export, readonly
85from pyTooling.MetaClasses          import ExtendedType, abstractmethod
86
87from pyTooling.Sphinx               import ReportDomain, ReportEntry, ReportExtensionError
88
89
90__all__ = ["UNSAFE_CHARACTERS", "EntityType"]
91
92UNSAFE_CHARACTERS = re_compile(r"[^A-Za-z0-9_.\-]+")
93"""Characters of an object's name, which are replaced in its document name."""
94
95EntityType = TypeVar("EntityType")
96"""The type of the objects a model's pages are generated for."""
97
98
99@export
100class GeneratedPages(Generic[EntityType], metaclass=ExtendedType, slots=True):
101	"""
102	Base-class of the pages generated for one model - a report, a design, ...: a document name per object.
103
104	The document names are computed once, when the pages are created, so a summary, the roles and the pages agree on
105	them. A derived class names the type of the model's objects as the type argument :data:`EntityType`, names an
106	object, lists its children, builds its page, and names the domain of the pages' objects in :attr:`_DOMAIN`; it
107	declares its own class variable :attr:`_pages`, the registry of its models' pages.
108	"""
109
110	_DOMAIN:    ClassVar[str]                                  #: Name of the domain, which the pages' objects belong to.
111	_pages:     ClassVar[dict[str, GeneratedPages[Any]]] = {}  #: The pages of every model of this kind, by pages ID.
112	_separator: ClassVar[str] = "."                            #: Separator of the names in an object's qualified name.
113
114	_pagesID:   str                                #: Identifier of the pages, e.g. a report's identifier.
115	_source:    Path                               #: The file Sphinx regards as the pages' source, e.g. the report file.
116	_paths:     dict[int, tuple[str, ...]]         #: Names from the model's root down to an object, by its ``id()``.
117	_docNames:  dict[int, str]                     #: Document names, by the ``id()`` of their object.
118	_documents: dict[str, tuple[str, EntityType]]  #: Role name and object, by document name.
119	_topLevel:  list[str]                          #: Document names of the top-level objects' pages.
120
121	def __init__(self, pagesID: str, prefix: str, source: Path, topLevel: Iterable[tuple[str, EntityType]]) -> None:
122		"""
123		Compute the document names of a model's objects.
124
125		An object whose qualified name is already taken by another of the same kind gets no page; this is logged as a
126		warning.
127
128		:param pagesID:  Identifier of the pages, e.g. a report's identifier.
129		:param prefix:   Document name the pages are generated below.
130		:param source:   The file Sphinx regards as the pages' source, e.g. the report file.
131		:param topLevel: The top-level objects as ``(role name, object)`` pairs, in the order of their pages.
132		"""
133		self._pagesID =   pagesID
134		self._source =    source.resolve()
135		self._paths =     {}
136		self._docNames =  {}
137		self._documents = {}
138
139		qualifiedNames: dict[str, set[str]] = {}
140
141		def addPages(children: Iterable[tuple[str, EntityType]], path: tuple[str, ...], directory: str) -> list[str]:
142			"""
143			Nested function adding a document name and entry per object, recursing into the objects' children.
144
145			:param children:  The objects of one parent as ``(role name, object)`` pairs.
146			:param path:      The parent's path below the report's root.
147			:param directory: The parent's document name, the directory of its children's documents.
148			:returns:         The document names of the objects, which got a page.
149			"""
150			siblings: set[str] = set()
151			docNames: list[str] = []
152			for roleName, child in children:
153				name = self._Name(child)
154				childPath = (*path, *self._PathElements(name))
155				qualifiedName = self._separator.join(childPath)
156				if qualifiedName in (names := qualifiedNames.setdefault(roleName, set())):
157					getLogger(__name__).warning(
158						f"Pages '{pagesID}': the qualified name '{qualifiedName}' is not unique, so the second one gets no page."
159					)
160					continue
161
162				names.add(qualifiedName)
163
164				baseName = UNSAFE_CHARACTERS.sub("_", name).strip("_.") or "_"
165				fileName, counter = baseName, 1
166				while fileName.lower() in siblings:
167					counter += 1
168					fileName = f"{baseName}-{counter}"
169				siblings.add(fileName.lower())
170
171				docName = f"{directory}/{fileName}"
172				self._paths[id(child)] =    childPath
173				self._docNames[id(child)] = docName
174				self._documents[docName] =  (roleName, child)
175				docNames.append(docName)
176
177				addPages(self._Children(roleName, child), childPath, docName)
178
179			return docNames
180
181		self._topLevel = addPages(topLevel, (), prefix)
182
183	@staticmethod
184	def CheckPagesConfiguration(configurationName: str, pages: Any) -> str:
185		"""
186		Check the document name a report's pages are generated below - the value of a report's key ``pages`` -, and
187		normalize it.
188
189		:param configurationName:     Name of the report's configuration, as an error message names it.
190		:param pages:                 The value of key ``pages``.
191		:returns:                     The document name without leading and trailing ``/``.
192		:raises ReportExtensionError: If ``pages`` isn't a string.
193		:raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
194		                              Use a name like 'unittests/src', separated by '/', without '.' or '..'.
195		"""
196		if not isinstance(pages, str):
197			ex = ReportExtensionError(f"{configurationName}.pages: Document name is not a string.")
198			ex.add_note(f"Got type '{getFullyQualifiedName(pages)}'.")
199			raise ex
200
201		parts = pages.strip("/").split("/")
202		if "\\" in pages or any(part in ("", ".", "..") for part in parts):
203			ex = ReportExtensionError(f"{configurationName}.pages: '{pages}' is not a relative document name.")
204			ex.add_note("Use a name like 'unittests/src', separated by '/', without '.' or '..'.")
205			raise ex
206
207		return "/".join(parts)
208
209	@classmethod
210	def HasPages(cls, pagesID: str) -> bool:
211		"""
212		Check if a model has pages.
213
214		:param pagesID: Identifier of the pages, e.g. a report's identifier.
215		:returns:       ``True``, if the model was declared with pages, e.g. a report with the key ``pages``.
216		"""
217		return pagesID in cls._pages
218
219	@classmethod
220	def GetPages(cls, pagesID: str) -> GeneratedPages[Any]:
221		"""
222		Return the pages of a model.
223
224		:param pagesID:   Identifier of the pages, e.g. a report's identifier.
225		:returns:         The model's pages.
226		:raises KeyError: If the model has no pages.
227		"""
228		try:
229			return cls._pages[pagesID]
230		except KeyError as ex:
231			raise KeyError(f"'{pagesID}' has no pages.") from ex
232
233	@classmethod
234	def GenerateAll(cls, sphinxApplication: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None:
235		"""
236		Call-back for Sphinx' ``env-before-read-docs`` event: generate the pages of every model of this kind.
237
238		:param sphinxApplication: Sphinx application instance.
239		:param env:               The build environment.
240		:param docnames:          The documents to read, from which the generated pages are removed.
241		"""
242		for pages in cls._pages.values():
243			pages.Generate(sphinxApplication)
244
245			docnames[:] = [docname for docname in docnames if docname not in pages._documents]
246
247	@classmethod
248	def HideSource(
249		cls,
250		sphinxApplication: Sphinx,
251		pagename: str,
252		templatename: str,
253		context: dict[str, object],
254		doctree: Nullable[nodes.document]
255	) -> None:
256		"""
257		Call-back for Sphinx' ``html-page-context`` event: a generated page has no source to copy or link.
258
259		Without it, the HTML builder would copy the pages' source - e.g. the report file - per page.
260
261		:param sphinxApplication: Sphinx application instance.
262		:param pagename:          Name of the page.
263		:param templatename:      Name of the page's template.
264		:param context:           The template's context.
265		:param doctree:           The page's doctree.
266		"""
267		if any(pagename in pages._documents for pages in cls._pages.values()):
268			context["sourcename"] = ""
269
270	@readonly
271	def PagesID(self) -> str:
272		"""
273		Read-only property to access the pages' identifier (:attr:`_pagesID`).
274
275		:returns: The identifier of the pages, e.g. a report's identifier.
276		"""
277		return self._pagesID
278
279	@readonly
280	def DocNames(self) -> tuple[str, ...]:
281		"""
282		Read-only property to return the names of the generated documents.
283
284		:returns: The document names, an object before its children.
285		"""
286		return tuple(self._documents)
287
288	def DocName(self, entity: EntityType) -> Nullable[str]:
289		"""
290		Return the name of an object's document.
291
292		:param entity: The object.
293		:returns:      The document name, or ``None`` if the object has no page.
294		"""
295		return self._docNames.get(id(entity), None)
296
297	def Register(self, env: BuildEnvironment) -> None:
298		"""
299		Register the document names, each with its source (see :meth:`_Source`).
300
301		:param env: The build environment.
302		"""
303		for docName, (_, entity) in self._documents.items():
304			env.project.docnames.add(docName)
305			env.project._docname_to_path[docName] = self._Source(entity)
306
307	def Generate(self, sphinxApplication: Sphinx) -> None:
308		"""
309		Build the doctree of every page, have the environment collect it, and store it as if it was read.
310
311		The page's content is added by :meth:`_Fill`, while the page is the environment's current document - so a
312		directive of a page written as reStructuredText registers its objects for that page. The event ``doctree-read``
313		is emitted per page, so the environment's collectors record its title, table of contents and metadata.
314		Afterwards, the page's table of contents keeps the pages it lists, and loses its sections, so the navigation shows
315		the model's objects only. Each object is then registered in its domain, see :meth:`_Registered`.
316
317		:param sphinxApplication: Sphinx application instance.
318		"""
319		env = sphinxApplication.env
320		domain = env.domains[self._DOMAIN]
321
322		self.Register(env)
323		for docName, (roleName, entity) in self._documents.items():
324			document = new_document(str(self._Source(entity)))
325			document.settings.env = env
326			document.settings.record_dependencies = DependencyList()
327			document += nodes.docinfo("", nodes.field("", nodes.field_name("", "orphan"), nodes.field_body()))
328
329			env.prepare_settings(docName)
330			try:
331				self._Fill(sphinxApplication, document, docName, roleName, entity)
332				sphinxApplication.events.emit("doctree-read", document)
333			finally:
334				env.prepare_settings("")
335				env.ref_context.clear()
336
337			pageEntry = env.tocs[docName][0]
338			for entries in pageEntry[1:]:
339				entries[:] = [entry for entry in entries if isinstance(entry, addnodes.toctree)]
340				if len(entries) == 0:
341					pageEntry.remove(entries)
342
343			env.all_docs[docName] = time_ns() // 1_000
344			self._Registered(domain, docName, roleName, entity)
345			sphinxApplication.builder.write_doctree(docName, document)
346
347	def TableOfContents(self, docName: str) -> nodes.compound:
348		"""
349		Create the hidden table of contents of the model's top-level pages, for the document showing its summary.
350
351		The pages are then below that document in the navigation.
352
353		:param docName: Name of the document showing the report's summary.
354		:returns:       The table of contents.
355		"""
356		return self._TableOfContents(docName, self._topLevel)
357
358	@staticmethod
359	def _TableOfContents(docName: str, children: list[str]) -> nodes.compound:
360		"""
361		Create a hidden table of contents listing pages.
362
363		:param docName:  Name of the document holding the table of contents.
364		:param children: Names of the listed pages' documents.
365		:returns:        The table of contents.
366		"""
367		toctree = addnodes.toctree(
368			parent=docName, entries=[(None, child) for child in children], includefiles=children, maxdepth=1,
369			caption=None, glob=False, hidden=True, includehidden=False, titlesonly=True, numbered=0
370		)
371		return nodes.compound("", toctree, classes=["toctree-wrapper"])
372
373	@staticmethod
374	def _Field(fieldList: nodes.field_list, name: str, *content: nodes.Node) -> None:
375		"""
376		Add a field to a page's summary.
377
378		:param fieldList: The summary's field list.
379		:param name:      The field's name.
380		:param content:   The field's value.
381		"""
382		fieldList += nodes.field("", nodes.field_name(name, name), nodes.field_body("", nodes.paragraph("", "", *content)))
383
384	@staticmethod
385	def _Table(columns: list[tuple[str, int]], classes: list[str]) -> tuple[nodes.table, nodes.tbody]:
386		"""
387		Create a table with a header row.
388
389		:param columns: One ``(title, width)`` pair per column.
390		:param classes: CSS classes of the table.
391		:returns:       The table, and its body the rows are added to.
392		"""
393		table = nodes.table("", classes=classes)
394		table += (tableGroup := nodes.tgroup(cols=len(columns)))
395		for _, width in columns:
396			tableGroup += nodes.colspec(colwidth=width)
397
398		tableGroup += (tableHeader := nodes.thead())
399		tableHeader += (headerRow := nodes.row())
400		for columnTitle, _ in columns:
401			headerRow += nodes.entry("", nodes.paragraph(columnTitle, columnTitle))
402
403		tableGroup += (tableBody := nodes.tbody())
404		return table, tableBody
405
406	@abstractmethod
407	def _Name(self, entity: EntityType) -> str:
408		"""
409		Return an object's name, the last element of its path.
410
411		:param entity: The object.
412		:returns:      The name.
413		"""
414
415	def _PathElements(self, name: str) -> tuple[str, ...]:
416		"""
417		Return the elements an object's name adds to its path: the name itself, unless a derived class splits it.
418
419		:param name: The object's name.
420		:returns:    The path elements.
421		"""
422		return (name, )
423
424	def _Source(self, entity: EntityType) -> Path:
425		"""
426		Return the file Sphinx regards as the source of an object's page: the pages' source, unless a derived class names
427		another one.
428
429		:param entity: The object.
430		:returns:      The page's source file.
431		"""
432		return self._source
433
434	@abstractmethod
435	def _Children(self, roleName: str, entity: EntityType) -> Iterable[tuple[str, EntityType]]:
436		"""
437		Return an object's children, in the order of their pages.
438
439		:param roleName: The role referring to the object.
440		:param entity:   The object.
441		:returns:        The children as ``(role name, object)`` pairs.
442		"""
443
444	def _Fill(
445		self,
446		sphinxApplication: Sphinx,
447		document:          nodes.document,
448		docName:           str,
449		roleName:          str,
450		entity:            EntityType
451	) -> None:
452		"""
453		Add an object's page to its document: the top section built by :meth:`_Page`.
454
455		A derived class writing its pages as reStructuredText overrides it, and parses the text into the document by
456		:meth:`_ParseReST` - the page is the environment's current document then, so its directives register their
457		objects for the page.
458
459		:param sphinxApplication: Sphinx application instance.
460		:param document:          The page's document, holding the metadata ``:orphan:``.
461		:param docName:           Name of the page's document.
462		:param roleName:          The role referring to the object.
463		:param entity:            The object.
464		"""
465		document += self._Page(docName, roleName, entity)
466
467	@staticmethod
468	def _ParseReST(sphinxApplication: Sphinx, document: nodes.document, text: str) -> None:
469		"""
470		Parse reStructuredText into a page's document, as Sphinx reads a source file: with the directives and roles of
471		every domain.
472
473		The document gets the parser's default settings it lacks, and the settings Sphinx reads a source file with. The
474		transforms of Sphinx' reading are applied - e.g. a label is attached to its section -, except the one emitting
475		``doctree-read``, which :meth:`Generate` emits.
476
477		:param sphinxApplication: Sphinx application instance.
478		:param document:          The page's document, which the parsed nodes are appended to.
479		:param text:              The reStructuredText.
480		"""
481		parser = sphinxApplication.registry.create_source_parser(
482			"restructuredtext", config=sphinxApplication.config, env=sphinxApplication.env
483		)
484
485		settings = document.settings
486		for name, value in vars(get_default_settings(parser)).items():
487			if not hasattr(settings, name):
488				setattr(settings, name, value)
489
490		for name, value in sphinxApplication.env.settings.items():
491			setattr(settings, name, value)
492
493		transformer = SphinxTransformer(document)
494		transformer.set_environment(sphinxApplication.env)
495		transformer.add_transforms(
496			[transform for transform in Reader().get_transforms() if transform is not DanglingReferences]
497		)
498		transformer.add_transforms(parser.get_transforms())
499		transformer.add_transforms(
500			[transform for transform in sphinxApplication.registry.get_transforms() if transform is not DoctreeReadEvent]
501		)
502
503		with sphinx_domains(sphinxApplication.env):
504			parser.parse(text, document)
505			transformer.apply_transforms()
506
507	@abstractmethod
508	def _Page(self, docName: str, roleName: str, entity: EntityType) -> nodes.section:
509		"""
510		Build an object's page.
511
512		:param docName:  Name of the page's document.
513		:param roleName: The role referring to the object.
514		:param entity:   The object.
515		:returns:        The page's top section.
516		"""
517
518	def _Registered(self, domain: Domain, docName: str, roleName: str, entity: EntityType) -> None:
519		"""
520		Register an object's page in the pages' domain, after the page was generated.
521
522		Nothing by default: a page written as reStructuredText registers its objects by its directives.
523
524		:param domain:   The domain named by :attr:`_DOMAIN`.
525		:param docName:  Name of the page's document.
526		:param roleName: The role referring to the object.
527		:param entity:   The object.
528		"""
529
530
531@export
532class ReportPages(GeneratedPages[EntityType]):
533	"""
534	Base-class of the pages of one report: a document name and an entry of domain ``report`` per object.
535
536	A derived class names an object, writes its title, lists its children, and builds its page; it declares its own
537	class variable :attr:`~GeneratedPages._pages`, the registry of its reports' pages.
538	"""
539
540	_DOMAIN: ClassVar[str] = ReportDomain.name  #: The pages' objects belong to domain ``report``.
541
542	_entries: dict[int, ReportEntry]  #: The entries, by the ``id()`` of their object.
543
544	def __init__(self, reportID: str, prefix: str, reportFile: Path, topLevel: Iterable[tuple[str, EntityType]]) -> None:
545		"""
546		Compute the document names and entries of a report's objects.
547
548		An object whose qualified name is already taken by another of the same kind gets no page; this is logged as a
549		warning.
550
551		:param reportID:   Identifier of the report.
552		:param prefix:     Document name the pages are generated below.
553		:param reportFile: The report file, the pages' source for Sphinx.
554		:param topLevel:   The top-level objects as ``(role name, object)`` pairs, in the order of their pages.
555		"""
556		super().__init__(reportID, prefix, reportFile, topLevel)
557
558		self._entries = {
559			id(entity): ReportEntry(docName, reportID, self._paths[id(entity)], self._separator, self._Title(entity))
560			for docName, (_, entity) in self._documents.items()
561		}
562
563	@classmethod
564	def CreateReference(cls, roleName: str, entry: ReportEntry, docname: str, text: Nullable[str] = None) -> pending_xref:
565		"""
566		Create a reference to an object's page, showing its name or a text.
567
568		:param roleName: The role referring to the object, e.g. ``tc`` for a testcase.
569		:param entry:    The object.
570		:param docname:  Name of the document holding the reference.
571		:param text:     Optional, the text shown instead of the object's name. Default: ``None``.
572		:returns:        The reference, resolved by domain ``report``.
573		"""
574		reference = pending_xref(
575			"", refdomain=cls._DOMAIN, reftype=roleName, reftarget=f"{entry.reportID}:{entry.QualifiedName}",
576			refexplicit=text is not None, refwarn=True, refdoc=docname
577		)
578		text = entry.Name if text is None else text
579		classes = ["xref", cls._DOMAIN, f"{cls._DOMAIN}-{roleName}"]
580		reference += nodes.inline(text, text, classes=classes)
581
582		return reference
583
584	@readonly
585	def ReportID(self) -> str:
586		"""
587		Read-only property to access the report's identifier (:attr:`~GeneratedPages._pagesID`).
588
589		:returns: The identifier of the report.
590		"""
591		return self._pagesID
592
593	def Entry(self, entity: EntityType) -> Nullable[ReportEntry]:
594		"""
595		Return the entry of an object of this report.
596
597		:param entity: The object.
598		:returns:      Its entry, or ``None`` if it has no page.
599		"""
600		return self._entries.get(id(entity), None)
601
602	def _Reference(self, roleName: str, entity: EntityType, docName: str) -> nodes.Node:
603		"""
604		Create a reference to an object's page, or its name if it has no page.
605
606		:param roleName: The role referring to the object.
607		:param entity:   The object.
608		:param docName:  Name of the document holding the reference.
609		:returns:        The reference, or the name as text.
610		"""
611		if (entry := self.Entry(entity)) is None:
612			return nodes.Text(self._Name(entity))
613
614		return self.CreateReference(roleName, entry, docName)
615
616	@abstractmethod
617	def _Title(self, entity: EntityType) -> Nullable[str]:
618		"""
619		Return an object's title written for a reader, if it differs from its name.
620
621		:param entity: The object.
622		:returns:      The title, or ``None``.
623		"""
624
625	def _Registered(self, domain: Domain, docName: str, roleName: str, entity: EntityType) -> None:
626		"""
627		Register an object's entry in domain ``report``, after its page was generated.
628
629		:param domain:   Domain ``report``.
630		:param docName:  Name of the page's document.
631		:param roleName: The role referring to the object, e.g. ``tc`` for a testcase.
632		:param entity:   The object.
633		"""
634		domain.AddEntry(roleName, self._entries[id(entity)])  # type: ignore[attr-defined]