Abbreviation.py
Summary
- Lines:
147 of 154 covered (95.5%)
- Branches:
40 of 46 taken (87.0%), 4 lines partially
- Coverage:
93.5%
- Directory:
- 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:])