CondensedClass.py

Summary

Lines:

44 of 166 covered (26.5%)

Branches:

0 of 62 taken (0.0%), 0 lines partially

Coverage:

19.3%

Directory:

Sphinx

Missed lines:

133-159, 173-184, 198-220, 232-247, 269-299, 312-324, 338-345, 356, 371-386, 403-425, 436-441, 454, 469-471

Source

pyTooling/Sphinx/CondensedClass.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 Sphinx directive rendering a class' **public interface** as a condensed code block.
33
34A reader arriving at a page wants to see the shape of a type before reading about it: what it is derived from, what
35can be called on it, and what can be read from it. The API reference answers that in several screens of prose; a
36hand-written summary answers it in one - and drifts from the code the day the class changes.
37
38.. code-block:: rest
39
40   .. condensed-class:: pyTooling.Stopwatch.Stopwatch
41      :caption: The interface of a stopwatch.
42
43renders the class line, its class variables, its methods and its properties, each with the signature it is declared
44with and ``...`` for a body:
45
46.. code-block:: python
47
48   @export
49   class Stopwatch(SlottedObject):
50     def __init__(self, name: Nullable[str] = None, started: bool = False) -> None:
51       ...
52
53     def Start(self) -> None:
54       ...
55
56     @readonly
57     def Duration(self) -> float:
58       ...
59
60**The source is parsed, not imported.** Three things follow from that: the annotations appear as they are *written*
61(``Nullable[str]``, not the ``Optional[str]`` an import would resolve it to), the declaration order is the order in
62the file, and a metaclass such as :class:`~pyTooling.MetaClasses.ExtendedType` can't hide a member behind a
63descriptor it installed. The file is registered as a dependency of the page, so editing the class rebuilds the page.
64
65**What is left out** is what the surrounding text is for: bodies, doc-strings, and the annotated attributes that
66make up a slotted class' fields - those are implementation. Class *variables* are kept, because a name with a value
67at class level is part of what a caller may read.
68"""
69from ast                  import AST, AnnAssign, Assign, AsyncFunctionDef, ClassDef, FunctionDef, Module, Name
70from ast                  import get_source_segment, parse, unparse
71from enum                 import Flag, auto
72from re                   import sub as re_sub
73from importlib.util       import find_spec
74from pathlib              import Path
75from collections.abc      import Sequence
76from typing               import Any, ClassVar, Optional as Nullable, Union
77
78from docutils             import nodes
79from docutils.parsers.rst import directives
80from sphinx.util.docutils import SphinxDirective
81
82
83#: Definition of a function or a method.
84Function = Union[FunctionDef, AsyncFunctionDef]
85
86
87class MemberKind(Flag):
88	"""
89	The kinds of member the ``condensed-class`` directive can render.
90
91	A document selects them by name in ``:members:``, spelled in any case: ``:members: ClassVariables, Properties``.
92	The order the members are declared in is the order they are rendered in.
93	"""
94
95	ClassVariables = auto()  #: A name assigned a value at class level.
96	Dunders =        auto()  #: A method with leading and trailing double underscores.
97	Methods =        auto()  #: A public method.
98	Properties =     auto()  #: A method decorated as a property's getter, setter or deleter.
99
100	All = ClassVariables | Dunders | Methods | Properties  #: Every kind of member.
101
102
103class CondensedClass(SphinxDirective):
104	"""
105	The ``condensed-class`` directive: a class' public interface, rendered from its source.
106
107	One argument, the dotted name of the class. ``:members:`` selects the :class:`kinds <MemberKind>` to render,
108	``:exclude-members:`` drops names by name, ``:indent:`` sets the width of one indentation level, ``:width:`` the
109	column a long signature is wrapped at, and ``:caption:`` puts a caption under the block.
110	"""
111
112	#: Decorators marking a function as a property getter.
113	PROPERTY_DECORATORS: ClassVar[tuple[str, ...]] = ("property", "readonly", "cached_property")
114
115	has_content =               False  #: A boolean; ``True`` if content is allowed.
116	required_arguments =        1      #: Number of required directive arguments: the dotted name.
117	optional_arguments =        0      #: Number of optional arguments after the required ones.
118	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
119	option_spec:                dict[str, Any] = {  #: Mapping of option names to validator functions.
120		"members":         directives.unchanged,
121		"exclude-members": directives.unchanged,
122		"indent":          directives.positive_int,
123		"width":           directives.positive_int,
124		"caption":         directives.unchanged,
125	}
126
127	def run(self) -> list[nodes.Node]:
128		"""
129		Parse the class' module, render its interface and return it as a literal block.
130
131		:returns: A ``literal_block`` node, or an error node when the class couldn't be found.
132		"""
133		dottedName = self.arguments[0].strip()
134
135		try:
136			sourceFile, classPath = self._SplitDottedName(dottedName)
137			kinds =    self._ParseMemberKinds(self.options.get("members", None))
138			excluded = frozenset(
139				name.strip() for name in self.options.get("exclude-members", "").split(",") if name.strip() != ""
140			)
141			source = sourceFile.read_text(encoding="utf-8")
142			definition = self._FindClass(parse(source), classPath)
143		except (OSError, SyntaxError, ValueError) as cause:
144			return [self.state.document.reporter.error(
145				f"condensed-class: {cause}", line=self.lineno
146			)]
147
148		self.env.note_dependency(str(sourceFile))
149
150		code = self._RenderClass(
151			definition, kinds, excluded, " " * self.options.get("indent", 2), self.options.get("width", 100), source
152		)
153		node = nodes.literal_block(code, code)
154		node["language"] = "Python"
155
156		if (caption := self.options.get("caption")) is not None:
157			node["caption"] = caption
158
159		return [node]
160
161	@staticmethod
162	def _SplitDottedName(dottedName: str) -> tuple[Path, list[str]]:
163		"""
164		Split a dotted name into the file its module lives in and the path of class names within it.
165
166		The longest prefix that names a module wins, so a class nested in a class is reachable and a module named like a
167		class is not mistaken for one.
168
169		:param dottedName:  Dotted name of the class, e.g. ``pyTooling.Stopwatch.Stopwatch``.
170		:returns:           Tuple of the module's source file and the class names leading to the class.
171		:raises ValueError: If no prefix of the name is an importable module with a source file.
172		"""
173		parts = dottedName.split(".")
174		for position in range(len(parts) - 1, 0, -1):
175			moduleName = ".".join(parts[:position])
176			try:
177				spec = find_spec(moduleName)
178			except (ImportError, ValueError):
179				continue
180
181			if spec is not None and spec.origin is not None and spec.origin.endswith(".py"):
182				return Path(spec.origin), parts[position:]
183
184		raise ValueError(f"No module of '{dottedName}' could be found.")
185
186	@staticmethod
187	def _ParseMemberKinds(members: Nullable[str]) -> MemberKind:
188		"""
189		Read the ``:members:`` option.
190
191		The names are matched against :class:`MemberKind`'s members without regard to case, so a document may spell
192		them the way it reads best.
193
194		:param members:     The option's value, or ``None`` if it wasn't given.
195		:returns:           The kinds of member to render.
196		:raises ValueError: If a name is not a member of :class:`MemberKind`.
197		"""
198		if members is None:
199			return MemberKind.All
200
201		# a Flag's 'name' is typed optional, because a combination of members has none
202		byName = {kind.name.lower(): kind for kind in MemberKind if kind.name is not None}
203
204		kinds = MemberKind(0)
205		unknown = []
206		for name in (name.strip() for name in members.split(",")):
207			if name == "":
208				continue
209			elif (kind := byName.get(name.lower())) is None:
210				unknown.append(name)
211			else:
212				kinds |= kind
213
214		if len(unknown) > 0:
215			raise ValueError(
216				f"Unknown member kind(s): {', '.join(sorted(unknown))}. "
217				f"Known are: {', '.join(kind.name for kind in MemberKind if kind.name is not None)}."
218			)
219
220		return kinds
221
222	@staticmethod
223	def _FindClass(tree: Module, classPath: list[str]) -> ClassDef:
224		"""
225		Find a class definition in a parsed module, descending into nested classes.
226
227		:param tree:        The parsed module.
228		:param classPath:   The class names leading to the class, outermost first.
229		:returns:           The class definition.
230		:raises ValueError: If a name of the path is not a class of its parent.
231		"""
232		body: Sequence[AST] = tree.body
233		definition: Nullable[ClassDef] = None
234
235		for name in classPath:
236			for statement in body:
237				if isinstance(statement, ClassDef) and statement.name == name:
238					definition = statement
239					body = statement.body
240					break
241			else:
242				raise ValueError(f"'{name}' is no class of the module or of the class containing it.")
243
244		if definition is None:
245			raise ValueError("No class was named.")
246
247		return definition
248
249	def _RenderClass(
250		self,
251		definition: ClassDef,
252		kinds: MemberKind,
253		excluded: frozenset[str],
254		indent: str,
255		width: int,
256		source: str
257	) -> str:
258		"""
259		Render a class' public interface as Python source.
260
261		:param definition: The class to render.
262		:param kinds:      The kinds of member to render.
263		:param excluded:   Names not to render.
264		:param indent:     Indentation of one level.
265		:param width:      Column a signature is wrapped at.
266		:param source:     The source text the class was parsed from.
267		:returns:          The condensed class, ready for a literal block.
268		"""
269		lines = [f"@{self._Render(decorator, source)}" for decorator in definition.decorator_list]
270		inheritance = ", ".join((
271			*(self._Render(base, source) for base in definition.bases),
272			*(f"{keyword.arg}={self._Render(keyword.value, source)}" for keyword in definition.keywords),
273		))
274
275		lines.append(f"class {definition.name}({inheritance}):" if inheritance != "" else f"class {definition.name}:")
276
277		members: list[list[str]] = []
278		variables: list[str] = []
279		for statement in definition.body:
280			if (MemberKind.ClassVariables in kinds and isinstance(statement, (AnnAssign, Assign))
281					and self._IsClassVariable(statement)):
282				variables.append(self._FormatClassVariable(statement, indent, source))
283			elif isinstance(statement, (FunctionDef, AsyncFunctionDef)):
284				if statement.name in excluded or not self._IsSelected(statement, kinds):
285					continue
286
287				members.append(self._FormatFunction(statement, indent, width, source))
288
289		if len(variables) > 0:
290			members.insert(0, variables)
291
292		if len(members) == 0:
293			lines.append(f"{indent}...")
294		else:
295			for member in members:
296				lines.append("")
297				lines.extend(member)
298
299		return "\n".join(lines)
300
301	def _IsSelected(self, function: Function, kinds: MemberKind) -> bool:
302		"""
303		Check if a method is one of the selected kinds and is public.
304
305		A property is decided by its decorators - ``@property``, ``@readonly`` or a ``@<name>.setter`` - and everything
306		else is a method. A name with one leading underscore is implementation and is never rendered; a dunder is not.
307
308		:param function: The method to classify.
309		:param kinds:    The kinds of member to render.
310		:returns:        ``True``, if the method is to be rendered.
311		"""
312		decorators = [self._DecoratorName(decorator) for decorator in function.decorator_list]
313		isProperty = (
314			any(decorator in self.PROPERTY_DECORATORS for decorator in decorators)
315			or any(decorator in ("setter", "deleter") for decorator in decorators)
316		)
317
318		if isProperty:
319			return MemberKind.Properties in kinds
320
321		if function.name.startswith("__") and function.name.endswith("__"):
322			return MemberKind.Dunders in kinds
323
324		return MemberKind.Methods in kinds and not function.name.startswith("_")
325
326	@staticmethod
327	def _IsClassVariable(statement: AST) -> bool:
328		"""
329		Check if a statement declares a public class variable rather than a field.
330
331		A class' **fields** are annotated without a value - that is what ``ExtendedType(slots=True)`` reads them from -
332		and are implementation. A name that is *assigned* at class level is a class variable, and a public one is part
333		of what a caller may read.
334
335		:param statement: The statement to classify.
336		:returns:         ``True``, if the statement assigns a public name at class level.
337		"""
338		if isinstance(statement, AnnAssign):
339			target, hasValue = statement.target, statement.value is not None
340		elif isinstance(statement, Assign) and len(statement.targets) == 1:
341			target, hasValue = statement.targets[0], True   # type: ignore[assignment]
342		else:
343			return False
344
345		return hasValue and isinstance(target, Name) and not target.id.startswith("_")
346
347	def _FormatClassVariable(self, statement: Union[AnnAssign, Assign], indent: str, source: str) -> str:
348		"""
349		Render a class variable with its annotation and its value.
350
351		:param statement: The assignment to render.
352		:param indent:    Indentation of one level.
353		:param source:    The source text the assignment was parsed from.
354		:returns:         The class variable as it is declared.
355		"""
356		return f"{indent}{self._Render(statement, source)}"
357
358	def _FormatFunction(self, function: Function, indent: str, width: int, source: str) -> list[str]:
359		"""
360		Render a method as its decorators, its signature and an elided body.
361
362		A signature longer than ``width`` is broken after each parameter, the way it would be written by hand - a
363		``__exit__`` with three annotated parameters doesn't fit on any page.
364
365		:param function: The method to render.
366		:param indent:   Indentation of one level.
367		:param width:    Column the signature is wrapped at.
368		:param source:   The source text the method was parsed from.
369		:returns:        The lines the method is rendered as.
370		"""
371		lines = [f"{indent}@{self._Render(decorator, source)}" for decorator in function.decorator_list]
372		prefix =    "async def" if isinstance(function, AsyncFunctionDef) else "def"
373		returns =   "" if function.returns is None else f" -> {self._Render(function.returns, source)}"
374		arguments = self._FormatArguments(function, source)
375		signature = f"{indent}{prefix} {function.name}({', '.join(arguments)}){returns}:"
376
377		if len(signature) <= width or len(arguments) == 0:
378			lines.append(signature)
379		else:
380			lines.append(f"{indent}{prefix} {function.name}(")
381			lines.extend(f"{indent}{indent}{argument}," for argument in arguments)
382			lines.append(f"{indent}){returns}:")
383
384		lines.append(f"{indent}{indent}...")
385
386		return lines
387
388	def _FormatArguments(self, function: Function, source: str) -> list[str]:
389		"""
390		Render a function's parameters the way they are declared, one string each.
391
392		The parameters are returned **as a list** rather than joined: an annotation may contain a comma of its own -
393		``dict[str, int]``, ``tuple[int, ...]``, ``Union[int, str]`` - so a joined string cannot be split back into
394		parameters, which is what wrapping a long signature needs to do.
395
396		:class:`ast.unparse` also writes ``name: str=None`` for an annotated parameter with a default; PEP 8 spaces that
397		as ``name: str = None``, which is how the sources are written, so the parts are assembled here instead.
398
399		:param function: The function or method to render the parameters of.
400		:param source:   The source text the function was parsed from.
401		:returns:        One string per parameter, without the enclosing parentheses.
402		"""
403		arguments = function.args
404		positional = [*arguments.posonlyargs, *arguments.args]
405		# a default belongs to the *last* parameters, so the list is padded at the front
406		defaults = [None] * (len(positional) - len(arguments.defaults)) + list(arguments.defaults)
407
408		rendered = []
409		for position, (argument, default) in enumerate(zip(positional, defaults)):
410			rendered.append(self._FormatArgument(argument, default, source))
411			if len(arguments.posonlyargs) > 0 and position == len(arguments.posonlyargs) - 1:
412				rendered.append("/")
413
414		if arguments.vararg is not None:
415			rendered.append(f"*{self._FormatArgument(arguments.vararg, None, source)}")
416		elif len(arguments.kwonlyargs) > 0:
417			rendered.append("*")
418
419		for argument, default in zip(arguments.kwonlyargs, arguments.kw_defaults):
420			rendered.append(self._FormatArgument(argument, default, source))
421
422		if arguments.kwarg is not None:
423			rendered.append(f"**{self._FormatArgument(arguments.kwarg, None, source)}")
424
425		return rendered
426
427	def _FormatArgument(self, argument: Any, default: Nullable[AST], source: str) -> str:
428		"""
429		Render one parameter with its annotation and its default value.
430
431		:param argument: The parameter to render.
432		:param default:  The parameter's default value, or ``None`` if it has none.
433		:param source:   The source text the parameter was parsed from.
434		:returns:        The parameter as it is declared.
435		"""
436		if argument.annotation is None:
437			return argument.arg if default is None else f"{argument.arg}={self._Render(default, source)}"
438
439		annotated = f"{argument.arg}: {self._Render(argument.annotation, source)}"
440
441		return annotated if default is None else f"{annotated} = {self._Render(default, source)}"
442
443	@staticmethod
444	def _DecoratorName(decorator: AST) -> str:
445		"""
446		Return the name a decorator expression ends in.
447
448		``@readonly`` is a :class:`~ast.Name`, ``@functools.cached_property`` an :class:`~ast.Attribute` and
449		``@Duration.setter`` an attribute of a name - all three are answered by their last component.
450
451		:param decorator: The decorator expression.
452		:returns:         The name the expression ends in, or an empty string for anything else.
453		"""
454		return getattr(decorator, "id", None) or getattr(decorator, "attr", None) or ""
455
456	@staticmethod
457	def _Render(node: AST, source: str) -> str:
458		"""
459		Render an expression the way it is **written**, not the way :func:`ast.unparse` would spell it.
460
461		:func:`ast.unparse` normalizes as it goes: ``1.5e-3`` comes back as ``0.0015`` and a double-quoted string comes
462		back single-quoted. Reading the source segment instead keeps the literal a reader would find in the file, which
463		is the point of rendering from the source at all. An expression spanning several lines is folded onto one.
464
465		:param node:   The expression to render.
466		:param source: The source text the expression was parsed from.
467		:returns:      The expression as it is written, or :func:`ast.unparse`'s spelling if the segment can't be read.
468		"""
469		segment = get_source_segment(source, node)
470
471		return unparse(node) if segment is None else re_sub(r"\s*\n\s*", " ", segment).strip()