CondensedSource.py
Summary
- Lines:
165 of 169 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, \
47 Constant, Expr, FunctionDef, If, Try, TypeAlias, \
48 iter_child_nodes, parse, stmt
49from typing import TYPE_CHECKING, Generator, Iterable, Optional as Nullable, \
50 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
65DEFINITIONS = (ClassDef, FunctionDef, AsyncFunctionDef)
66"""The statements expected to have a doc-string: a class and a function or method."""
67
68ASSIGNMENTS = (Assign, AnnAssign, TypeAlias)
69"""The statements declaring a variable or a class' field."""
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