CondensedSource.py
Summary
- Lines:
166 of 170 covered (97.6%)
- Excluded lines:
2
- Branches:
69 of 74 taken (93.2%), 5 lines partially
- Coverage:
96.3%
- Directory:
- Missed lines:
187, 208, 262, 295
Source
pyTooling/Sphinx/CondensedSource.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 Python module's source, condensed to what its documentation coverage is about: the headers and doc-strings of its
33classes and functions, its variables and fields; a body is elided to ``...``.
34
35:class:`CondensedSource` holds the condensed lines, each marked by its documentation state; its classmethod
36:meth:`~CondensedSource.Parse` condenses a module's source. The pages of :mod:`pyTooling.Sphinx.DocCoverage` show
37it as a listing.
38
39.. seealso::
40
41 :ref:`DIR/DocCoverage/Pages`
42 |rarr| The pages and the condensed listing, with examples.
43"""
44from __future__ import annotations
45
46from ast import AST, AnnAssign, Assign, AsyncFunctionDef, ClassDef, Constant
47from ast import Expr, FunctionDef, If, Try, TypeAlias, iter_child_nodes
48from ast import parse, stmt
49from typing import TYPE_CHECKING, Generator, Iterable
50from typing import Optional as Nullable, Self, Union
51
52from sphinx.util.logging import getLogger
53
54from pyTooling.Common import getFullyQualifiedName
55from pyTooling.Decorators import export, readonly
56from pyTooling.MetaClasses import ExtendedType
57
58if TYPE_CHECKING: # pragma: no cover
59 # pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a package is analyzed.
60 from pyEDAA.Reports.DocumentationCoverage import CoverageState, Item
61
62
63__all__ = ["DEFINITIONS", "ASSIGNMENTS", "Definition"]
64
65#: The statements expected to have a doc-string: a class and a function or method.
66DEFINITIONS = (ClassDef, FunctionDef, AsyncFunctionDef)
67
68#: The statements declaring a variable or a class' field.
69ASSIGNMENTS = (Assign, AnnAssign, TypeAlias)
70
71type Definition = Union[ClassDef, FunctionDef, AsyncFunctionDef]
72"""A statement expected to have a doc-string."""
73
74
75@export
76def documentationState(coverageState: CoverageState) -> str:
77 """
78 Return the state a listing marks a line with, for a coverage state of pyEDAA.Reports' documentation coverage model.
79
80 The state is chosen as the model counts it: excluded before ignored, then documented - covered - or undocumented.
81 An item with nothing to document (``Empty``) isn't counted, so it gets no state.
82
83 :param coverageState: The model's coverage state.
84 :returns: The listing's state: ``documented``, ``undocumented``, ``ignored``, ``excluded`` or ``""``.
85 """
86 from pyEDAA.Reports.DocumentationCoverage import CoverageState
87
88 if CoverageState.Empty in coverageState:
89 return ""
90 elif CoverageState.Excluded in coverageState:
91 return "excluded"
92 elif CoverageState.Ignored in coverageState:
93 return "ignored"
94 elif CoverageState.Covered in coverageState:
95 return "documented"
96
97 return "undocumented"
98
99
100class _Condenser(metaclass=ExtendedType, slots=True):
101 """
102 The builder condensing a module's source into the lines of a :class:`CondensedSource`.
103
104 It walks the module's syntax tree once, taking each line as written or eliding it; the source, the items' states and
105 the position reached are its state while walking. It is private to :meth:`CondensedSource.Parse`, which hands the
106 resulting lines to the constructor.
107 """
108
109 _source: list[str] #: The source's lines, without line ends.
110 _module: Item #: The module's item, whose file lists the items declared in it.
111 _states: dict[int, str] #: Documentation state of an item, by its first line.
112 _lines: list[tuple[Nullable[int], str, str]] #: The condensed lines taken so far.
113 _last: int #: Number of the last source line taken or elided.
114
115 def __init__(self, source: str, module: Item) -> None:
116 """
117 Prepare condensing a module's source.
118
119 :param source: The module's source.
120 :param module: The module's or package's item.
121 """
122 self._source = source.split("\n")
123 self._module = module
124 self._states = {}
125 self._lines = []
126 self._last = 0
127
128 def Condense(self, path: str) -> list[tuple[Nullable[int], str, str]]:
129 """
130 Condense the module's source.
131
132 An analysis not matching the source - a class or function without an item of its name at its first line - is
133 logged as a warning, and no class, function, field or variable is marked.
134
135 :param path: The module's path, as the warning names it.
136 :returns: The condensed lines (see :attr:`CondensedSource.Lines`).
137 """
138 tree = parse("\n".join(self._source), filename=path)
139
140 items: dict[int, Item] = {}
141 if (file := self._module.File) is not None:
142 for item in file.Items:
143 if item.StartLine is not None:
144 items.setdefault(item.StartLine, item)
145
146 if all(
147 (declared := items.get(self._FirstLine(definition), None)) is not None and declared.Name == definition.name
148 for definition in self._Definitions(tree)
149 ):
150 self._states = {line: documentationState(item.Status) for line, item in items.items()}
151 else:
152 getLogger(__name__).warning(
153 f"The documentation coverage analysis of '{path}' doesn't match the source, so its items aren't marked."
154 )
155
156 body = tree.body
157 if len(body) > 0 and self._IsDocString(body[0]):
158 self._Emit(body[0].lineno, body[0].end_lineno, documentationState(self._module.Status))
159 body = body[1:]
160
161 self._Block(body, True)
162
163 return self._lines
164
165 def _Emit(self, first: int, last: int, state: str) -> None:
166 """
167 Take source lines as written, except those already taken.
168
169 :param first: Number of the first line.
170 :param last: Number of the last line.
171 :param state: The lines' documentation state.
172 """
173 for number in range(max(first, self._last + 1), last + 1):
174 self._lines.append((number, state, ""))
175
176 self._last = max(self._last, last)
177
178 def _Gap(self, last: int) -> None:
179 """
180 Skip the lines between the last line taken and a line: a blank line is taken, unless the line before is blank or
181 nothing was taken yet; a comment is left out.
182
183 :param last: Number of the last line to skip.
184 """
185 for number in range(self._last + 1, last + 1):
186 if self._source[number - 1].strip() != "" or len(self._lines) == 0:
187 continue
188
189 previousNumber = self._lines[-1][0]
190 if previousNumber is None or self._source[previousNumber - 1].strip() != "":
191 self._lines.append((number, "", ""))
192
193 self._last = max(self._last, last)
194
195 def _Block(self, statements: list[stmt], variables: bool) -> None:
196 """
197 Take a block's statements: classes and functions condensed, variables or fields as written, a statement holding
198 them with its clauses' headers, other statements elided - consecutive ones to a single ``...``.
199
200 :param statements: The block's statements, without a doc-string.
201 :param variables: Whether assignments are variables or fields - in a module or class - and taken.
202 """
203 elided = False
204 attributeState: Nullable[str] = None
205 for statement in statements:
206 # a statement behind a ';' on a line already taken
207 if statement.end_lineno <= self._last:
208 continue
209
210 docStringState = attributeState
211 attributeState = None
212 if isinstance(statement, DEFINITIONS):
213 self._Definition(statement)
214 elif variables and isinstance(statement, ASSIGNMENTS):
215 attributeState = self._states.get(statement.lineno, "")
216 first = statement.lineno
217 while first - 1 > self._last and self._source[first - 2].lstrip().startswith("#:"):
218 first -= 1
219
220 self._Gap(first - 1)
221 self._Emit(first, statement.end_lineno, attributeState)
222 elif docStringState is not None and self._IsDocString(statement):
223 self._Gap(statement.lineno - 1)
224 self._Emit(statement.lineno, statement.end_lineno, docStringState)
225 elif any(True for _ in self._OutermostDefinitions(statement)) or (
226 variables and self._HoldsAssignments(statement)
227 ):
228 self._Compound(statement, variables)
229 else:
230 if not elided:
231 self._Gap(statement.lineno - 1)
232 line = self._source[statement.lineno - 1]
233 self._lines.append((None, "", line[:len(line) - len(line.lstrip())]))
234
235 self._last = statement.end_lineno
236 elided = True
237 continue
238
239 elided = False
240
241 def _Definition(self, definition: Definition) -> None:
242 """
243 Take a class or function: its decorators, header and doc-string, then its body's statements.
244
245 :param definition: The class or function.
246 """
247 first = self._FirstLine(definition)
248 state = self._states.get(first, "")
249 self._Gap(first - 1)
250
251 body = definition.body
252 if self._source[body[0].lineno - 1].encode("utf-8")[:body[0].col_offset].strip() != b"":
253 # the body starts in the header's line: 'def f(): pass'
254 self._Emit(first, definition.end_lineno, state)
255 return
256
257 # the header ends before the body's first statement, without the blank lines and comments in between
258 headerEnd = self._FirstLine(body[0]) - 1
259 while headerEnd > definition.lineno and (
260 (line := self._source[headerEnd - 1].strip()) == "" or line.startswith("#")
261 ):
262 headerEnd -= 1
263
264 self._Emit(first, headerEnd, state)
265 if self._IsDocString(body[0]):
266 self._Gap(body[0].lineno - 1)
267 self._Emit(body[0].lineno, body[0].end_lineno, state)
268 body = body[1:]
269
270 self._Block(body, isinstance(definition, ClassDef))
271
272 def _Compound(self, statement: stmt, variables: bool) -> None:
273 """
274 Take a statement holding classes, functions, variables or fields: the headers of its clauses as written, each
275 clause's block like a body.
276
277 Such a statement is e.g. an ``if`` choosing between two definitions, or a ``try`` assigning a variable.
278
279 :param statement: The statement.
280 :param variables: Whether assignments are variables or fields - in a module or class - and taken.
281 """
282 self._Gap(statement.lineno - 1)
283 for block in (
284 getattr(statement, "body", []),
285 *(clause.body for clause in (*getattr(statement, "handlers", []), *getattr(statement, "cases", []))),
286 getattr(statement, "orelse", []),
287 getattr(statement, "finalbody", [])
288 ):
289 if len(block) == 0:
290 continue
291
292 # the clause's header - 'if ...:', 'else:', 'except ...:' -, without the blank lines and comments around it
293 for number in range(self._last + 1, self._FirstLine(block[0])):
294 if (line := self._source[number - 1].strip()) == "" or line.startswith("#"):
295 self._Gap(number)
296 else:
297 self._Emit(number, number, "")
298
299 self._Block(block, variables)
300
301 @staticmethod
302 def _FirstLine(statement: stmt) -> int:
303 """
304 Return a statement's first line: its first decorator's, if it has decorators.
305
306 :param statement: The statement.
307 :returns: The line number.
308 """
309 return min((decorator.lineno for decorator in getattr(statement, "decorator_list", ())), default=statement.lineno)
310
311 @staticmethod
312 def _IsDocString(statement: stmt) -> bool:
313 """
314 Check if a statement is a string literal: a doc-string at the start of a body, or behind a variable or field.
315
316 :param statement: The statement.
317 :returns: ``True``, if the statement is a string literal.
318 """
319 return (
320 isinstance(statement, Expr) and isinstance(statement.value, Constant) and isinstance(statement.value.value, str)
321 )
322
323 @classmethod
324 def _Definitions(cls, node: AST) -> Generator[Definition, None, None]:
325 """
326 Yield the classes and functions below a node, depth-first: each before the ones inside it.
327
328 :param node: The node.
329 :returns: A generator of the classes and functions.
330 """
331 for child in iter_child_nodes(node):
332 if isinstance(child, DEFINITIONS):
333 yield child
334
335 yield from cls._Definitions(child)
336
337 @classmethod
338 def _HoldsAssignments(cls, statement: stmt) -> bool:
339 """
340 Check if a statement is an ``if`` or ``try`` assigning a variable or field, which the analysis reads: in its
341 body or ``else`` block, or in such a statement nested in them.
342
343 :param statement: The statement.
344 :returns: ``True``, if the statement holds an assignment.
345 """
346 if not isinstance(statement, (If, Try)):
347 return False
348
349 return any(
350 isinstance(child, ASSIGNMENTS) or cls._HoldsAssignments(child) for child in (*statement.body, *statement.orelse)
351 )
352
353 @classmethod
354 def _OutermostDefinitions(cls, node: AST) -> Generator[Definition, None, None]:
355 """
356 Yield the classes and functions below a node, which aren't inside another class or function below it.
357
358 :param node: The node.
359 :returns: A generator of the classes and functions, in source order.
360 """
361 for child in iter_child_nodes(node):
362 if isinstance(child, DEFINITIONS):
363 yield child
364 else:
365 yield from cls._OutermostDefinitions(child)
366
367
368@export
369class CondensedSource(metaclass=ExtendedType, slots=True):
370 """
371 A Python module's source, condensed to what its documentation coverage is about.
372
373 A line is taken as written, at its line number, or replaced by a ``...`` line without a number:
374
375 * The module's doc-string is taken; its imports and other statements are elided.
376 * A class or function is taken with its decorators, its header up to the ``:`` - a signature spanning lines too -
377 and its doc-string. Its body follows: a class' members, a function's nested functions and classes; anything else
378 is elided to one ``...`` at the body's indentation. A definition written in one line is taken as written.
379 * A variable of the module and a field of a class is taken as written: with a ``#:`` comment on its line or in the
380 lines above it, and a doc-string literal following it.
381 * A statement holding a class or function - e.g. an ``if`` or a ``try`` -, and an ``if`` or ``try`` assigning a
382 variable or field, is taken with the headers of its clauses as written; each clause's block is taken like a body.
383 * Comments between statements are left out; consecutive blank lines become one.
384
385 A class', function's, field's or variable's lines are marked by the documentation state of its item in
386 pyEDAA.Reports' model: the item declared in the module's file, which starts at the definition's first line or at the
387 assignment. The module's doc-string is marked by the module's state. Every other line has no state, e.g. a repeated
388 assignment of a variable, as its item starts at the first one.
389 """
390
391 _lines: list[tuple[Nullable[int], str, str]] #: The condensed lines; see :attr:`Lines`.
392
393 def __init__(self, lines: Iterable[tuple[Nullable[int], str, str]]) -> None:
394 """
395 Initialize a condensed source from its lines.
396
397 :param lines: The condensed lines (see :attr:`Lines`).
398 :raises ValueError: If parameter ``lines`` is ``None``.
399 :raises TypeError: If parameter ``lines`` isn't iterable.
400 :raises TypeError: If a line isn't a tuple.
401 """
402 if lines is None:
403 raise ValueError("Parameter 'lines' is None.")
404 elif not isinstance(lines, Iterable):
405 ex = TypeError("Parameter 'lines' is not iterable.")
406 ex.add_note(f"Got type '{getFullyQualifiedName(lines)}'.")
407 raise ex
408
409 self._lines = []
410 for line in lines:
411 if not isinstance(line, tuple):
412 ex = TypeError("Parameter 'lines' contains an element, which is not of type 'tuple'.")
413 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
414 raise ex
415
416 self._lines.append(line)
417
418 @classmethod
419 def Parse(cls, source: str, path: str, module: Item) -> Self:
420 """
421 Condense a module's source.
422
423 An analysis not matching the source - a class or function without an item of its name at its first line - is
424 logged as a warning, and no class, function, field or variable is marked.
425
426 :param source: The module's source.
427 :param path: The module's path, as the warning names it.
428 :param module: The module's or package's item of pyEDAA.Reports' model; its file lists the items declared in it.
429 :returns: The condensed source.
430 """
431 return cls(_Condenser(source, module).Condense(path))
432
433 @readonly
434 def Lines(self) -> list[tuple[Nullable[int], str, str]]:
435 """
436 Read-only property to access the condensed lines (:attr:`_lines`).
437
438 :returns: A tuple per line: the line number and its state, and ``""``; or for an elided body, ``None``, ``""``
439 and the body's indentation.
440 """
441 return self._lines