Abbreviation.py

Summary

Lines:

147 of 154 covered (95.5%)

Branches:

40 of 46 taken (87.0%), 4 lines partially

Coverage:

93.5%

Directory:

Sphinx

Missed lines:

159, 299-302, 429, 443, 445

Source

pyTooling/Sphinx/Abbreviation.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"""
32A list of abbreviations, and roles referring to an abbreviation in the form they name.
33
34The directive ``abbreviations`` holds the list - a definition list of abbreviations, each with its long form and,
35optionally, a description like a glossary's:
36
37.. code-block:: ReST
38
39   .. abbreviations::
40
41      FSM
42         :long:        finite-state machine
43         :long-plural: finite-state machines
44
45         A machine in exactly one of a finite number of states at a time.
46
47      HDL
48         :long: hardware description language
49
50A role refers to an abbreviation from any document, in the form it names, as LaTeX' ``acro`` package does:
51``:acs:`FSM``` writes *FSM*, ``:acl:`FSM``` *finite-state machine*, ``:acf:`FSM``` *finite-state machine (FSM)*,
52and ``:acsp:``, ``:aclp:`` and ``:acfp:`` the plurals. ``:ac:`` and ``:acp:`` are ``:acs:`` and ``:acsp:``: a reader
53doesn't read a page linearly, so there is no first use spelling an abbreviation out. A text written as the role's
54title, ``:acs:`FSM's <FSM>```, replaces the form. Every reference links to the abbreviation in the list; in HTML, a
55short form shows its long form in a box on hover.
56
57.. seealso::
58
59   :ref:`DIR/Abbreviations`
60      |rarr| The directive and the roles, with rendered examples.
61   :mod:`pyTooling.Sphinx`
62      |rarr| The extension this belongs to, and what else it brings.
63"""
64from typing                  import Any, ClassVar, Iterable, Iterator, Literal, NamedTuple, Optional as Nullable
65
66from docutils                import nodes
67from sphinx.addnodes         import pending_xref
68from sphinx.builders         import Builder
69from sphinx.domains          import Domain, ObjType
70from sphinx.environment      import BuildEnvironment
71from sphinx.roles            import XRefRole
72from sphinx.util.logging     import getLogger
73from sphinx.util.nodes       import make_id, make_refnode
74
75from pyTooling.Decorators    import export, readonly
76from pyTooling.Documentation import splitDocString
77from pyTooling.Sphinx        import BaseDirective, SphinxExtensionError, strip, stripAndNormalize
78from pyTooling.Sphinx.Node   import Abbreviation
79
80
81__all__ = ["DOMAIN_NAME", "CONFIG_PREFIX", "ROLES", "FIELDS"]
82
83#: Name of the domain collecting the abbreviations of all documents.
84DOMAIN_NAME = "abbreviation"
85
86#: Prefix of the configuration values the directive adds to :file:`conf.py`.
87CONFIG_PREFIX = "pyTooling_Abbreviation"
88
89#: The roles, mapping a role's name to the form it writes and whether it is the plural.
90ROLES = {
91	"ac":   ("short", False),
92	"acs":  ("short", False),
93	"acl":  ("long",  False),
94	"acf":  ("full",  False),
95	"acp":  ("short", True),
96	"acsp": ("short", True),
97	"aclp": ("long",  True),
98	"acfp": ("full",  True),
99}
100
101#: The fields an abbreviation states its forms in, and whether one is required.
102FIELDS = {
103	"long":        True,
104	"plural":      False,
105	"long-plural": False,
106}
107
108
109@export
110class AbbreviationEntry(NamedTuple):
111	"""An abbreviation of the list: where it is written, and its forms as plain text."""
112
113	docName:    str       #: Name of the document holding the list.
114	anchor:     str       #: Identifier of the abbreviation's entry in that document.
115	short:      str       #: The abbreviation itself, e.g. ``FSM``.
116	long:       str       #: The long form, e.g. ``finite-state machine``.
117	plural:     str       #: The plural of the short form, e.g. ``FSMs``.
118	longPlural: str       #: The plural of the long form, e.g. ``finite-state machines``.
119	summary:    str = ""  #: The description's first paragraph as plain text; empty without a description.
120
121	def Form(self, form: str, plural: bool) -> str:
122		"""
123		Return one form of the abbreviation as plain text.
124
125		:param form:   ``short``, ``long`` or ``full``, the long form followed by the short one in parentheses.
126		:param plural: Whether the plural is asked for.
127		:returns:      The form's text.
128		"""
129		short = self.plural if plural else self.short
130		long = self.longPlural if plural else self.long
131		if form == "short":
132			return short
133		elif form == "long":
134			return long
135
136		return f"{long} ({short})"
137
138
139@export
140class AbbreviationRole(XRefRole):
141	"""
142	A role referring to an abbreviation in the domain :data:`DOMAIN_NAME`, whatever name it is registered by.
143
144	Sphinx registers a role outside a domain without one, so the reference would be resolved by nobody; this role puts
145	its own domain on the reference, and uses its name as the reference's type.
146	"""
147
148	def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
149		"""
150		Create the reference, or only the text if the role is written with ``!``.
151
152		:returns: The nodes and messages a role returns.
153		"""
154		self.refdomain = DOMAIN_NAME
155		self.reftype = self.name.rpartition(":")[2]
156		self.classes = ["xref", DOMAIN_NAME, f"{DOMAIN_NAME}-{self.reftype}"]
157
158		if self.disabled:
159			return self.create_non_xref_node()
160
161		return self.create_xref_node()
162
163
164@export
165class AbbreviationDomain(Domain):
166	"""
167	The domain collecting the abbreviations of all documents, and resolving the references to them.
168
169	An abbreviation is registered once in the whole documentation; it gets no entry in the general index.
170	"""
171
172	name =  DOMAIN_NAME     #: Name of the domain.
173	label = "Abbreviation"  #: Name of the domain shown to a reader.
174
175	object_types = {"abbreviation": ObjType("abbreviation", *ROLES)}  #: The one kind of object, referred to by every role.
176	initial_data: dict[str, Any] = {"abbreviations": {}}              #: The data of an empty documentation: no abbreviation.
177	roles = {
178		roleName: AbbreviationRole(innernodeclass=nodes.inline, warn_dangling=True) for roleName in ROLES
179	}  #: The roles, also as ``:abbreviation:<role>:``.
180	dangling_warnings = {
181		roleName: "abbreviation '%(target)s' isn't in a list of abbreviations" for roleName in ROLES
182	}  #: The warning for a reference to an unknown abbreviation, per role.
183
184	@readonly
185	def Abbreviations(self) -> dict[str, AbbreviationEntry]:
186		"""
187		Read-only property to return the abbreviations of all documents, from the domain's data.
188
189		:returns: The abbreviations, keyed by their short form.
190		"""
191		return self.data["abbreviations"]
192
193	def Register(self, entry: AbbreviationEntry, location: nodes.Node) -> None:
194		"""
195		Register an abbreviation, or warn that it is registered in a list already.
196
197		:param entry:    The abbreviation.
198		:param location: Node the warning points to.
199		"""
200		if (registered := self.Abbreviations.get(entry.short)) is not None:
201			getLogger(__name__).warning(
202				f"abbreviation '{entry.short}' is in the list of document '{registered.docName}' already",
203				location=location
204			)
205			return
206
207		self.Abbreviations[entry.short] = entry
208
209	def clear_doc(self, docname: str) -> None:
210		"""
211		Forget the abbreviations of a document, before it is read again.
212
213		:param docname: Name of the document.
214		"""
215		for short in [short for short, entry in self.Abbreviations.items() if entry.docName == docname]:
216			del self.Abbreviations[short]
217
218	def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None:
219		"""
220		Take over the abbreviations a parallel reader collected for its documents.
221
222		:param docnames:  Names of the documents the other reader read.
223		:param otherdata: The other reader's domain data.
224		"""
225		documents = set(docnames)
226		for short, entry in otherdata["abbreviations"].items():
227			if entry.docName in documents:
228				self.Abbreviations[short] = entry
229
230	def resolve_xref(
231		self,
232		env: BuildEnvironment,
233		fromdocname: str,
234		builder: Builder,
235		typ: str,
236		target: str,
237		node: pending_xref,
238		contnode: nodes.Element
239	) -> Nullable[nodes.reference]:
240		"""
241		Resolve a reference to an abbreviation into a link to its entry, showing the form the role names.
242
243		A short form is an :class:`~pyTooling.Sphinx.Node.Abbreviation`, so HTML explains it on hover; a full form holds
244		one. A title written in the role replaces the form's text; the box still explains the abbreviation. With
245		``pyTooling_Abbreviation_ShowSummary`` set, the box also shows the summary of the abbreviation's description.
246
247		:param env:         The build environment.
248		:param fromdocname: Name of the document holding the reference.
249		:param builder:     The builder writing the documents.
250		:param typ:         The role's name.
251		:param target:      The abbreviation referred to.
252		:param node:        The reference.
253		:param contnode:    The reference's text.
254		:returns:           The link, or ``None`` if the abbreviation isn't in a list.
255		"""
256		if (entry := self.Abbreviations.get(target)) is None:
257			return None
258
259		form, plural = ROLES[typ]
260		short, long = entry.Form("short", plural), entry.Form("long", plural)
261		summary = entry.summary if env.config[f"{CONFIG_PREFIX}_ShowSummary"] else ""
262		if node.get("refexplicit", False):
263			text = contnode.astext()
264			content: list[nodes.Node] = [
265				Abbreviation(text, text, short=short, long=long, summary=summary) if form == "short" else nodes.Text(text)
266			]
267		elif form == "short":
268			content = [Abbreviation(short, short, short=short, long=long, summary=summary)]
269		elif form == "long":
270			content = [nodes.Text(long)]
271		else:
272			content = [
273				nodes.Text(f"{long} ("), Abbreviation(short, short, short=short, long=long, summary=summary), nodes.Text(")")
274			]
275
276		inline = nodes.inline("", "", *content, classes=contnode["classes"])
277		return make_refnode(builder, fromdocname, entry.docName, entry.anchor, inline)
278
279	def resolve_any_xref(
280		self,
281		env: BuildEnvironment,
282		fromdocname: str,
283		builder: Builder,
284		target: str,
285		node: pending_xref,
286		contnode: nodes.Element
287	) -> list[tuple[str, nodes.reference]]:
288		"""
289		Resolve a reference of role ``:any:`` to an abbreviation, which shows its short form.
290
291		:param env:         The build environment.
292		:param fromdocname: Name of the document holding the reference.
293		:param builder:     The builder writing the documents.
294		:param target:      The text referred to.
295		:param node:        The reference.
296		:param contnode:    The reference's text.
297		:returns:           The role and link, if the text is an abbreviation; otherwise nothing.
298		"""
299		if (reference := self.resolve_xref(env, fromdocname, builder, "acs", target, node, contnode)) is None:
300			return []
301
302		return [(f"{DOMAIN_NAME}:acs", reference)]
303
304	def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]:
305		"""
306		Name the abbreviations, for the search and for other documentations referring to them.
307
308		:returns: A tuple per abbreviation: name, display name, type, document, anchor and search priority.
309		"""
310		for short, entry in self.Abbreviations.items():
311			yield short, short, "abbreviation", entry.docName, entry.anchor, 1
312
313
314@export
315class Abbreviations(BaseDirective):
316	"""
317	The ``abbreviations`` directive: a list of abbreviations, which the roles in :data:`ROLES` refer to.
318
319	The content is a definition list. A term is an abbreviation; its definition starts with a field list stating the
320	forms - :data:`FIELDS` - and may continue with a description. The list is shown in alphabetical order, unless
321	``:ordered:`` is ``no``; ``:class:`` puts additional CSS classes on the list.
322	"""
323
324	directiveName: str = "abbreviations"  #: Name the directive is invoked by.
325
326	#: The configuration values this directive adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is
327	#: registered with :data:`CONFIG_PREFIX` as prefix, e.g. ``pyTooling_Abbreviation_ShowSummary``.
328	#:
329	#: ``ShowSummary`` adds the summary of an abbreviation's description - its first paragraph - to the box HTML shows
330	#: on hover.
331	configValues: ClassVar[dict[str, tuple[Any, Literal["env"], Any]]] = {
332		"ShowSummary": (False, "env", bool),
333	}
334
335	has_content =               True   #: A boolean; ``True`` if content is allowed.
336	required_arguments =        0      #: Number of required directive arguments.
337	optional_arguments =        0      #: Number of optional arguments after the required ones.
338	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
339	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
340	# spelling of this override a conflict with one of them
341	#: Mapping of option names to validator functions.
342	option_spec: dict[str, Any] = {  # type: ignore[misc]
343		"class":   strip,
344		"ordered": stripAndNormalize,
345	}
346
347	def run(self) -> list[nodes.Node]:
348		"""
349		Render the list, and register its abbreviations in the domain.
350
351		:returns: The list as a definition list, or the message of whatever the content got wrong.
352		"""
353		container = nodes.container()
354		self.state.nested_parse(self.content, self.content_offset, container)
355
356		try:
357			ordered = self._ParseBooleanOption("ordered", True)
358		except SphinxExtensionError as ex:
359			return [self.state.document.reporter.error(str(ex), line=self.lineno)]
360
361		try:
362			definitionList = self._DefinitionList(container)
363			entries = [self._ReadItem(item) for item in definitionList.children]
364		except SphinxExtensionError as ex:
365			return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
366
367		if ordered:
368			items = sorted(zip(definitionList.children, entries), key=lambda pair: pair[1][0].casefold())
369			definitionList[:] = [item for item, _ in items]
370			entries = [entry for _, entry in items]
371
372		domain: AbbreviationDomain = self.env.get_domain(DOMAIN_NAME)  # type: ignore[assignment]
373		for item, (short, forms, description) in zip(definitionList.children, entries):
374			anchor = make_id(self.env, self.state.document, "abbreviation", short)
375			term = item[0]
376			term["ids"].append(anchor)
377			self.state.document.note_explicit_target(term)
378
379			long, plural, longPlural = forms["long"], forms.get("plural"), forms.get("long-plural")
380			entry = AbbreviationEntry(
381				docName=self.env.docname,
382				anchor=anchor,
383				short=short,
384				long=long.astext(),
385				plural=f"{short}s" if plural is None else plural.astext(),
386				longPlural=f"{long.astext()}s" if longPlural is None else longPlural.astext(),
387				summary=splitDocString("\n\n".join(node.astext() for node in description), maxSummaryLength=0)[0]
388			)
389			domain.Register(entry, item)
390
391			definition = nodes.definition()
392			definition += nodes.paragraph("", "", *long.children, classes=["abbreviation-long"])
393			definition += description
394			item.replace(item[1], definition)
395
396		definitionList["classes"] += ["pytooling-abbreviations"] + self.options.get("class", "").split()
397
398		return [definitionList]
399
400	@staticmethod
401	def _DefinitionList(container: nodes.Element) -> nodes.definition_list:
402		"""
403		Return the definition list the content has to consist of.
404
405		:param container:             The parsed content.
406		:returns:                     The definition list.
407		:raises SphinxExtensionError: If the content is anything but one definition list.
408		"""
409		if len(container.children) != 1 or not isinstance(container[0], nodes.definition_list):
410			raise SphinxExtensionError("The directive's content isn't a definition list of abbreviations.")
411
412		return container[0]
413
414	@staticmethod
415	def _ReadItem(item: nodes.definition_list_item) -> tuple[str, dict[str, nodes.paragraph], list[nodes.Node]]:
416		"""
417		Read an abbreviation: its term, the fields stating its forms, and the description following them.
418
419		:param item:                  An item of the definition list.
420		:returns:                     The abbreviation, its forms keyed by field name, and the description.
421		:raises SphinxExtensionError: If the term has a classifier.
422		:raises SphinxExtensionError: If the definition doesn't start with a field list.
423		:raises SphinxExtensionError: If a field is unknown, given twice, or not a single paragraph.
424		:raises SphinxExtensionError: If a required field is missing.
425		"""
426		term, definition = item[0], item[-1]
427		short = term.astext().strip()
428		if len(item.children) != 2:
429			raise SphinxExtensionError(f"Abbreviation '{short}' has a classifier, which isn't supported.")
430
431		if len(definition.children) == 0 or not isinstance(definition[0], nodes.field_list):
432			raise SphinxExtensionError(f"Abbreviation '{short}' doesn't start with a field list, e.g. ':long:'.")
433
434		forms: dict[str, nodes.paragraph] = {}
435		for field in definition[0].children:
436			name = field[0].astext().strip()
437			body = field[1]
438			if name not in FIELDS:
439				ex = SphinxExtensionError(f"Abbreviation '{short}' has an unknown field ':{name}:'.")
440				ex.add_note(f"Known fields: {', '.join(f':{field}:' for field in FIELDS)}")
441				raise ex
442			elif name in forms:
443				raise SphinxExtensionError(f"Abbreviation '{short}' states field ':{name}:' twice.")
444			elif len(body.children) != 1 or not isinstance(body[0], nodes.paragraph):
445				raise SphinxExtensionError(f"Field ':{name}:' of abbreviation '{short}' isn't a single paragraph.")
446
447			forms[name] = body[0]
448
449		for name in (name for name, required in FIELDS.items() if required and name not in forms):
450			raise SphinxExtensionError(f"Abbreviation '{short}' has no field ':{name}:'.")
451
452		return short, forms, list(definition.children[1:])