pyTooling.Sphinx.CondensedClass

pyTooling/Sphinx/CondensedClass.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# ==================================================================================================================== #
#             _____           _ _               ____        _     _                                                    #
#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
# |_|    |___/                          |___/        |_|                                                               #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany                                                             #
#                                                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
# you may not use this file except in compliance with the License.                                                     #
# You may obtain a copy of the License at                                                                              #
#                                                                                                                      #
#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
#                                                                                                                      #
# Unless required by applicable law or agreed to in writing, software                                                  #
# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
# See the License for the specific language governing permissions and                                                  #
# limitations under the License.                                                                                       #
#                                                                                                                      #
# SPDX-License-Identifier: Apache-2.0                                                                                  #
# ==================================================================================================================== #
#
"""
A Sphinx directive rendering a class' **public interface** as a condensed code block.

A reader arriving at a page wants to see the shape of a type before reading about it: what it is derived from, what
can be called on it, and what can be read from it. The API reference answers that in several screens of prose; a
hand-written summary answers it in one - and drifts from the code the day the class changes.

.. code-block:: rest

   .. condensed-class:: pyTooling.Stopwatch.Stopwatch
      :caption: The interface of a stopwatch.

renders the class line, its class variables, its methods and its properties, each with the signature it is declared
with and ``...`` for a body:

.. code-block:: python

   @export
   class Stopwatch(SlottedObject):
     def __init__(self, name: Nullable[str] = None, started: bool = False) -> None:
       ...

     def Start(self) -> None:
       ...

     @readonly
     def Duration(self) -> float:
       ...

**The source is parsed, not imported.** Three things follow from that: the annotations appear as they are *written*
(``Nullable[str]``, not the ``Optional[str]`` an import would resolve it to), the declaration order is the order in
the file, and a metaclass such as :class:`~pyTooling.MetaClasses.ExtendedType` can't hide a member behind a
descriptor it installed. The file is registered as a dependency of the page, so editing the class rebuilds the page.

**What is left out** is what the surrounding text is for: bodies, doc-strings, and the annotated attributes that
make up a slotted class' fields - those are implementation. Class *variables* are kept, because a name with a value
at class level is part of what a caller may read.
"""
from ast                  import AST, AnnAssign, Assign, AsyncFunctionDef, ClassDef, FunctionDef, Module, Name
from ast                  import get_source_segment, parse, unparse
from enum                 import Flag, auto
from re                   import sub as re_sub
from importlib.util       import find_spec
from pathlib              import Path
from collections.abc      import Sequence
from typing               import Any, ClassVar, Optional as Nullable, Union

from docutils             import nodes
from docutils.parsers.rst import directives
from sphinx.util.docutils import SphinxDirective


#: Definition of a function or a method.
Function = Union[FunctionDef, AsyncFunctionDef]


class MemberKind(Flag):
	"""
	The kinds of member the ``condensed-class`` directive can render.

	A document selects them by name in ``:members:``, spelled in any case: ``:members: ClassVariables, Properties``.
	The order the members are declared in is the order they are rendered in.
	"""

	ClassVariables = auto()  #: A name assigned a value at class level.
	Dunders =        auto()  #: A method with leading and trailing double underscores.
	Methods =        auto()  #: A public method.
	Properties =     auto()  #: A method decorated as a property's getter, setter or deleter.

	All = ClassVariables | Dunders | Methods | Properties  #: Every kind of member.


class CondensedClass(SphinxDirective):
	"""
	The ``condensed-class`` directive: a class' public interface, rendered from its source.

	One argument, the dotted name of the class. ``:members:`` selects the :class:`kinds <MemberKind>` to render,
	``:exclude-members:`` drops names by name, ``:indent:`` sets the width of one indentation level, ``:width:`` the
	column a long signature is wrapped at, and ``:caption:`` puts a caption under the block.
	"""

	#: Decorators marking a function as a property getter.
	PROPERTY_DECORATORS: ClassVar[tuple[str, ...]] = ("property", "readonly", "cached_property")

	has_content =               False  #: A boolean; ``True`` if content is allowed.
	required_arguments =        1      #: Number of required directive arguments: the dotted name.
	optional_arguments =        0      #: Number of optional arguments after the required ones.
	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
	option_spec:                dict[str, Any] = {  #: Mapping of option names to validator functions.
		"members":         directives.unchanged,
		"exclude-members": directives.unchanged,
		"indent":          directives.positive_int,
		"width":           directives.positive_int,
		"caption":         directives.unchanged,
	}

	def run(self) -> list[nodes.Node]:
		"""
		Parse the class' module, render its interface and return it as a literal block.

		:returns: A ``literal_block`` node, or an error node when the class couldn't be found.
		"""
		dottedName = self.arguments[0].strip()

		try:
			sourceFile, classPath = self._SplitDottedName(dottedName)
			kinds =    self._ParseMemberKinds(self.options.get("members", None))
			excluded = frozenset(
				name.strip() for name in self.options.get("exclude-members", "").split(",") if name.strip() != ""
			)
			source = sourceFile.read_text(encoding="utf-8")
			definition = self._FindClass(parse(source), classPath)
		except (OSError, SyntaxError, ValueError) as cause:
			return [self.state.document.reporter.error(
				f"condensed-class: {cause}", line=self.lineno
			)]

		self.env.note_dependency(str(sourceFile))

		code = self._RenderClass(
			definition, kinds, excluded, " " * self.options.get("indent", 2), self.options.get("width", 100), source
		)
		node = nodes.literal_block(code, code)
		node["language"] = "Python"

		if (caption := self.options.get("caption")) is not None:
			node["caption"] = caption

		return [node]

	@staticmethod
	def _SplitDottedName(dottedName: str) -> tuple[Path, list[str]]:
		"""
		Split a dotted name into the file its module lives in and the path of class names within it.

		The longest prefix that names a module wins, so a class nested in a class is reachable and a module named like a
		class is not mistaken for one.

		:param dottedName:  Dotted name of the class, e.g. ``pyTooling.Stopwatch.Stopwatch``.
		:returns:           Tuple of the module's source file and the class names leading to the class.
		:raises ValueError: If no prefix of the name is an importable module with a source file.
		"""
		parts = dottedName.split(".")
		for position in range(len(parts) - 1, 0, -1):
			moduleName = ".".join(parts[:position])
			try:
				spec = find_spec(moduleName)
			except (ImportError, ValueError):
				continue

			if spec is not None and spec.origin is not None and spec.origin.endswith(".py"):
				return Path(spec.origin), parts[position:]

		raise ValueError(f"No module of '{dottedName}' could be found.")

	@staticmethod
	def _ParseMemberKinds(members: Nullable[str]) -> MemberKind:
		"""
		Read the ``:members:`` option.

		The names are matched against :class:`MemberKind`'s members without regard to case, so a document may spell
		them the way it reads best.

		:param members:     The option's value, or ``None`` if it wasn't given.
		:returns:           The kinds of member to render.
		:raises ValueError: If a name is not a member of :class:`MemberKind`.
		"""
		if members is None:
			return MemberKind.All

		# a Flag's 'name' is typed optional, because a combination of members has none
		byName = {kind.name.lower(): kind for kind in MemberKind if kind.name is not None}

		kinds = MemberKind(0)
		unknown = []
		for name in (name.strip() for name in members.split(",")):
			if name == "":
				continue
			elif (kind := byName.get(name.lower())) is None:
				unknown.append(name)
			else:
				kinds |= kind

		if len(unknown) > 0:
			raise ValueError(
				f"Unknown member kind(s): {', '.join(sorted(unknown))}. "
				f"Known are: {', '.join(kind.name for kind in MemberKind if kind.name is not None)}."
			)

		return kinds

	@staticmethod
	def _FindClass(tree: Module, classPath: list[str]) -> ClassDef:
		"""
		Find a class definition in a parsed module, descending into nested classes.

		:param tree:        The parsed module.
		:param classPath:   The class names leading to the class, outermost first.
		:returns:           The class definition.
		:raises ValueError: If a name of the path is not a class of its parent.
		"""
		body: Sequence[AST] = tree.body
		definition: Nullable[ClassDef] = None

		for name in classPath:
			for statement in body:
				if isinstance(statement, ClassDef) and statement.name == name:
					definition = statement
					body = statement.body
					break
			else:
				raise ValueError(f"'{name}' is no class of the module or of the class containing it.")

		if definition is None:
			raise ValueError("No class was named.")

		return definition

	def _RenderClass(
		self,
		definition: ClassDef,
		kinds: MemberKind,
		excluded: frozenset[str],
		indent: str,
		width: int,
		source: str
	) -> str:
		"""
		Render a class' public interface as Python source.

		:param definition: The class to render.
		:param kinds:      The kinds of member to render.
		:param excluded:   Names not to render.
		:param indent:     Indentation of one level.
		:param width:      Column a signature is wrapped at.
		:param source:     The source text the class was parsed from.
		:returns:          The condensed class, ready for a literal block.
		"""
		lines = [f"@{self._Render(decorator, source)}" for decorator in definition.decorator_list]
		inheritance = ", ".join((
			*(self._Render(base, source) for base in definition.bases),
			*(f"{keyword.arg}={self._Render(keyword.value, source)}" for keyword in definition.keywords),
		))

		lines.append(f"class {definition.name}({inheritance}):" if inheritance != "" else f"class {definition.name}:")

		members: list[list[str]] = []
		variables: list[str] = []
		for statement in definition.body:
			if (MemberKind.ClassVariables in kinds and isinstance(statement, (AnnAssign, Assign))
					and self._IsClassVariable(statement)):
				variables.append(self._FormatClassVariable(statement, indent, source))
			elif isinstance(statement, (FunctionDef, AsyncFunctionDef)):
				if statement.name in excluded or not self._IsSelected(statement, kinds):
					continue

				members.append(self._FormatFunction(statement, indent, width, source))

		if len(variables) > 0:
			members.insert(0, variables)

		if len(members) == 0:
			lines.append(f"{indent}...")
		else:
			for member in members:
				lines.append("")
				lines.extend(member)

		return "\n".join(lines)

	def _IsSelected(self, function: Function, kinds: MemberKind) -> bool:
		"""
		Check if a method is one of the selected kinds and is public.

		A property is decided by its decorators - ``@property``, ``@readonly`` or a ``@<name>.setter`` - and everything
		else is a method. A name with one leading underscore is implementation and is never rendered; a dunder is not.

		:param function: The method to classify.
		:param kinds:    The kinds of member to render.
		:returns:        ``True``, if the method is to be rendered.
		"""
		decorators = [self._DecoratorName(decorator) for decorator in function.decorator_list]
		isProperty = (
			any(decorator in self.PROPERTY_DECORATORS for decorator in decorators)
			or any(decorator in ("setter", "deleter") for decorator in decorators)
		)

		if isProperty:
			return MemberKind.Properties in kinds

		if function.name.startswith("__") and function.name.endswith("__"):
			return MemberKind.Dunders in kinds

		return MemberKind.Methods in kinds and not function.name.startswith("_")

	@staticmethod
	def _IsClassVariable(statement: AST) -> bool:
		"""
		Check if a statement declares a public class variable rather than a field.

		A class' **fields** are annotated without a value - that is what ``ExtendedType(slots=True)`` reads them from -
		and are implementation. A name that is *assigned* at class level is a class variable, and a public one is part
		of what a caller may read.

		:param statement: The statement to classify.
		:returns:         ``True``, if the statement assigns a public name at class level.
		"""
		if isinstance(statement, AnnAssign):
			target, hasValue = statement.target, statement.value is not None
		elif isinstance(statement, Assign) and len(statement.targets) == 1:
			target, hasValue = statement.targets[0], True   # type: ignore[assignment]
		else:
			return False

		return hasValue and isinstance(target, Name) and not target.id.startswith("_")

	def _FormatClassVariable(self, statement: Union[AnnAssign, Assign], indent: str, source: str) -> str:
		"""
		Render a class variable with its annotation and its value.

		:param statement: The assignment to render.
		:param indent:    Indentation of one level.
		:param source:    The source text the assignment was parsed from.
		:returns:         The class variable as it is declared.
		"""
		return f"{indent}{self._Render(statement, source)}"

	def _FormatFunction(self, function: Function, indent: str, width: int, source: str) -> list[str]:
		"""
		Render a method as its decorators, its signature and an elided body.

		A signature longer than ``width`` is broken after each parameter, the way it would be written by hand - a
		``__exit__`` with three annotated parameters doesn't fit on any page.

		:param function: The method to render.
		:param indent:   Indentation of one level.
		:param width:    Column the signature is wrapped at.
		:param source:   The source text the method was parsed from.
		:returns:        The lines the method is rendered as.
		"""
		lines = [f"{indent}@{self._Render(decorator, source)}" for decorator in function.decorator_list]
		prefix =    "async def" if isinstance(function, AsyncFunctionDef) else "def"
		returns =   "" if function.returns is None else f" -> {self._Render(function.returns, source)}"
		arguments = self._FormatArguments(function, source)
		signature = f"{indent}{prefix} {function.name}({', '.join(arguments)}){returns}:"

		if len(signature) <= width or len(arguments) == 0:
			lines.append(signature)
		else:
			lines.append(f"{indent}{prefix} {function.name}(")
			lines.extend(f"{indent}{indent}{argument}," for argument in arguments)
			lines.append(f"{indent}){returns}:")

		lines.append(f"{indent}{indent}...")

		return lines

	def _FormatArguments(self, function: Function, source: str) -> list[str]:
		"""
		Render a function's parameters the way they are declared, one string each.

		The parameters are returned **as a list** rather than joined: an annotation may contain a comma of its own -
		``dict[str, int]``, ``tuple[int, ...]``, ``Union[int, str]`` - so a joined string cannot be split back into
		parameters, which is what wrapping a long signature needs to do.

		:class:`ast.unparse` also writes ``name: str=None`` for an annotated parameter with a default; PEP 8 spaces that
		as ``name: str = None``, which is how the sources are written, so the parts are assembled here instead.

		:param function: The function or method to render the parameters of.
		:param source:   The source text the function was parsed from.
		:returns:        One string per parameter, without the enclosing parentheses.
		"""
		arguments = function.args
		positional = [*arguments.posonlyargs, *arguments.args]
		# a default belongs to the *last* parameters, so the list is padded at the front
		defaults = [None] * (len(positional) - len(arguments.defaults)) + list(arguments.defaults)

		rendered = []
		for position, (argument, default) in enumerate(zip(positional, defaults)):
			rendered.append(self._FormatArgument(argument, default, source))
			if len(arguments.posonlyargs) > 0 and position == len(arguments.posonlyargs) - 1:
				rendered.append("/")

		if arguments.vararg is not None:
			rendered.append(f"*{self._FormatArgument(arguments.vararg, None, source)}")
		elif len(arguments.kwonlyargs) > 0:
			rendered.append("*")

		for argument, default in zip(arguments.kwonlyargs, arguments.kw_defaults):
			rendered.append(self._FormatArgument(argument, default, source))

		if arguments.kwarg is not None:
			rendered.append(f"**{self._FormatArgument(arguments.kwarg, None, source)}")

		return rendered

	def _FormatArgument(self, argument: Any, default: Nullable[AST], source: str) -> str:
		"""
		Render one parameter with its annotation and its default value.

		:param argument: The parameter to render.
		:param default:  The parameter's default value, or ``None`` if it has none.
		:param source:   The source text the parameter was parsed from.
		:returns:        The parameter as it is declared.
		"""
		if argument.annotation is None:
			return argument.arg if default is None else f"{argument.arg}={self._Render(default, source)}"

		annotated = f"{argument.arg}: {self._Render(argument.annotation, source)}"

		return annotated if default is None else f"{annotated} = {self._Render(default, source)}"

	@staticmethod
	def _DecoratorName(decorator: AST) -> str:
		"""
		Return the name a decorator expression ends in.

		``@readonly`` is a :class:`~ast.Name`, ``@functools.cached_property`` an :class:`~ast.Attribute` and
		``@Duration.setter`` an attribute of a name - all three are answered by their last component.

		:param decorator: The decorator expression.
		:returns:         The name the expression ends in, or an empty string for anything else.
		"""
		return getattr(decorator, "id", None) or getattr(decorator, "attr", None) or ""

	@staticmethod
	def _Render(node: AST, source: str) -> str:
		"""
		Render an expression the way it is **written**, not the way :func:`ast.unparse` would spell it.

		:func:`ast.unparse` normalizes as it goes: ``1.5e-3`` comes back as ``0.0015`` and a double-quoted string comes
		back single-quoted. Reading the source segment instead keeps the literal a reader would find in the file, which
		is the point of rendering from the source at all. An expression spanning several lines is folded onto one.

		:param node:   The expression to render.
		:param source: The source text the expression was parsed from.
		:returns:      The expression as it is written, or :func:`ast.unparse`'s spelling if the segment can't be read.
		"""
		segment = get_source_segment(source, node)

		return unparse(node) if segment is None else re_sub(r"\s*\n\s*", " ", segment).strip()