CondensedClass.py
Summary
- Lines:
44 of 166 covered (26.5%)
- Branches:
0 of 62 taken (0.0%), 0 lines partially
- Coverage:
19.3%
- Directory:
- 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()