Coverage for pyTooling/Documentation/Sphinx/CondensedClass.py: 19%
166 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
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.
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.
38.. code-block:: rest
40 .. condensed-class:: pyTooling.Stopwatch.Stopwatch
41 :caption: The interface of a stopwatch.
43renders the class line, its class variables, its methods and its properties, each with the signature it is declared
44with and ``...`` for a body:
46.. code-block:: python
48 @export
49 class Stopwatch(SlottedObject):
50 def __init__(self, name: Nullable[str] = None, started: bool = False) -> None:
51 ...
53 def Start(self) -> None:
54 ...
56 @readonly
57 def Duration(self) -> float:
58 ...
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.
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
78from docutils import nodes
79from docutils.parsers.rst import directives
80from sphinx.util.docutils import SphinxDirective
83#: Definition of a function or a method.
84Function = Union[FunctionDef, AsyncFunctionDef]
87class MemberKind(Flag):
88 """
89 The kinds of member the ``condensed-class`` directive can render.
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 """
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.
100 All = ClassVariables | Dunders | Methods | Properties #: Every kind of member.
103class CondensedClass(SphinxDirective):
104 """
105 The ``condensed-class`` directive: a class' public interface, rendered from its source.
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 """
112 #: Decorators marking a function as a property getter.
113 PROPERTY_DECORATORS: ClassVar[tuple[str, ...]] = ("property", "readonly", "cached_property")
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 }
127 def run(self) -> list[nodes.Node]:
128 """
129 Parse the class' module, render its interface and return it as a literal block.
131 :returns: A ``literal_block`` node, or an error node when the class couldn't be found.
132 """
133 dottedName = self.arguments[0].strip()
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 )]
148 self.env.note_dependency(str(sourceFile))
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"
156 if (caption := self.options.get("caption")) is not None:
157 node["caption"] = caption
159 return [node]
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.
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.
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
181 if spec is not None and spec.origin is not None and spec.origin.endswith(".py"):
182 return Path(spec.origin), parts[position:]
184 raise ValueError(f"No module of '{dottedName}' could be found.")
186 @staticmethod
187 def _ParseMemberKinds(members: Nullable[str]) -> MemberKind:
188 """
189 Read the ``:members:`` option.
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.
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
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}
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
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 )
220 return kinds
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.
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
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.")
244 if definition is None:
245 raise ValueError("No class was named.")
247 return definition
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.
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 ))
275 lines.append(f"class {definition.name}({inheritance}):" if inheritance != "" else f"class {definition.name}:")
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
287 members.append(self._FormatFunction(statement, indent, width, source))
289 if len(variables) > 0:
290 members.insert(0, variables)
292 if len(members) == 0:
293 lines.append(f"{indent}...")
294 else:
295 for member in members:
296 lines.append("")
297 lines.extend(member)
299 return "\n".join(lines)
301 def _IsSelected(self, function: Function, kinds: MemberKind) -> bool:
302 """
303 Check if a method is one of the selected kinds and is public.
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.
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 )
318 if isProperty:
319 return MemberKind.Properties in kinds
321 if function.name.startswith("__") and function.name.endswith("__"):
322 return MemberKind.Dunders in kinds
324 return MemberKind.Methods in kinds and not function.name.startswith("_")
326 @staticmethod
327 def _IsClassVariable(statement: AST) -> bool:
328 """
329 Check if a statement declares a public class variable rather than a field.
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.
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
345 return hasValue and isinstance(target, Name) and not target.id.startswith("_")
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.
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)}"
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.
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.
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}:"
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}:")
384 lines.append(f"{indent}{indent}...")
386 return lines
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.
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.
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.
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)
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("/")
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("*")
419 for argument, default in zip(arguments.kwonlyargs, arguments.kw_defaults):
420 rendered.append(self._FormatArgument(argument, default, source))
422 if arguments.kwarg is not None:
423 rendered.append(f"**{self._FormatArgument(arguments.kwarg, None, source)}")
425 return rendered
427 def _FormatArgument(self, argument: Any, default: Nullable[AST], source: str) -> str:
428 """
429 Render one parameter with its annotation and its default value.
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)}"
439 annotated = f"{argument.arg}: {self._Render(argument.annotation, source)}"
441 return annotated if default is None else f"{annotated} = {self._Render(default, source)}"
443 @staticmethod
444 def _DecoratorName(decorator: AST) -> str:
445 """
446 Return the name a decorator expression ends in.
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.
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 ""
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.
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.
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)
471 return unparse(node) if segment is None else re_sub(r"\s*\n\s*", " ", segment).strip()