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
|
# ==================================================================================================================== #
# _____ _ _ ____ _ _ #
# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
# |_| |___/ |___/ |_| #
# ==================================================================================================================== #
# 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 Python module's source, condensed to what its documentation coverage is about: the headers and doc-strings of its
classes and functions, its variables and fields; a body is elided to ``...``.
:class:`CondensedSource` holds the condensed lines, each marked by its documentation state; its classmethod
:meth:`~CondensedSource.Parse` condenses a module's source. The pages of :mod:`pyTooling.Sphinx.DocCoverage` show
it as a listing.
.. seealso::
:ref:`DIR/DocCoverage/Pages`
|rarr| The pages and the condensed listing, with examples.
"""
from __future__ import annotations
from ast import AST, AnnAssign, Assign, AsyncFunctionDef, ClassDef, \
Constant, Expr, FunctionDef, If, Try, TypeAlias, \
iter_child_nodes, parse, stmt
from typing import TYPE_CHECKING, Generator, Iterable, Optional as Nullable, \
Self, Union
from sphinx.util.logging import getLogger
from pyTooling.Common import getFullyQualifiedName
from pyTooling.Decorators import export, readonly
from pyTooling.MetaClasses import ExtendedType
if TYPE_CHECKING: # pragma: no cover
# pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a package is analyzed.
from pyEDAA.Reports.DocumentationCoverage import CoverageState, Item
__all__ = ["DEFINITIONS", "ASSIGNMENTS", "Definition"]
#: The statements expected to have a doc-string: a class and a function or method.
DEFINITIONS = (ClassDef, FunctionDef, AsyncFunctionDef)
#: The statements declaring a variable or a class' field.
ASSIGNMENTS = (Assign, AnnAssign, TypeAlias)
type Definition = Union[ClassDef, FunctionDef, AsyncFunctionDef]
"""A statement expected to have a doc-string."""
@export
def documentationState(coverageState: CoverageState) -> str:
"""
Return the state a listing marks a line with, for a coverage state of pyEDAA.Reports' documentation coverage model.
The state is chosen as the model counts it: excluded before ignored, then documented - covered - or undocumented.
An item with nothing to document (``Empty``) isn't counted, so it gets no state.
:param coverageState: The model's coverage state.
:returns: The listing's state: ``documented``, ``undocumented``, ``ignored``, ``excluded`` or ``""``.
"""
from pyEDAA.Reports.DocumentationCoverage import CoverageState
if CoverageState.Empty in coverageState:
return ""
elif CoverageState.Excluded in coverageState:
return "excluded"
elif CoverageState.Ignored in coverageState:
return "ignored"
elif CoverageState.Covered in coverageState:
return "documented"
return "undocumented"
class _Condenser(metaclass=ExtendedType, slots=True):
"""
The builder condensing a module's source into the lines of a :class:`CondensedSource`.
It walks the module's syntax tree once, taking each line as written or eliding it; the source, the items' states and
the position reached are its state while walking. It is private to :meth:`CondensedSource.Parse`, which hands the
resulting lines to the constructor.
"""
_source: list[str] #: The source's lines, without line ends.
_module: Item #: The module's item, whose file lists the items declared in it.
_states: dict[int, str] #: Documentation state of an item, by its first line.
_lines: list[tuple[Nullable[int], str, str]] #: The condensed lines taken so far.
_last: int #: Number of the last source line taken or elided.
def __init__(self, source: str, module: Item) -> None:
"""
Prepare condensing a module's source.
:param source: The module's source.
:param module: The module's or package's item.
"""
self._source = source.split("\n")
self._module = module
self._states = {}
self._lines = []
self._last = 0
def Condense(self, path: str) -> list[tuple[Nullable[int], str, str]]:
"""
Condense the module's source.
An analysis not matching the source - a class or function without an item of its name at its first line - is
logged as a warning, and no class, function, field or variable is marked.
:param path: The module's path, as the warning names it.
:returns: The condensed lines (see :attr:`CondensedSource.Lines`).
"""
tree = parse("\n".join(self._source), filename=path)
items: dict[int, Item] = {}
if (file := self._module.File) is not None:
for item in file.Items:
if item.StartLine is not None:
items.setdefault(item.StartLine, item)
if all(
(declared := items.get(self._FirstLine(definition), None)) is not None and declared.Name == definition.name
for definition in self._Definitions(tree)
):
self._states = {line: documentationState(item.Status) for line, item in items.items()}
else:
getLogger(__name__).warning(
f"The documentation coverage analysis of '{path}' doesn't match the source, so its items aren't marked."
)
body = tree.body
if len(body) > 0 and self._IsDocString(body[0]):
self._Emit(body[0].lineno, body[0].end_lineno, documentationState(self._module.Status))
body = body[1:]
self._Block(body, True)
return self._lines
def _Emit(self, first: int, last: int, state: str) -> None:
"""
Take source lines as written, except those already taken.
:param first: Number of the first line.
:param last: Number of the last line.
:param state: The lines' documentation state.
"""
for number in range(max(first, self._last + 1), last + 1):
self._lines.append((number, state, ""))
self._last = max(self._last, last)
def _Gap(self, last: int) -> None:
"""
Skip the lines between the last line taken and a line: a blank line is taken, unless the line before is blank or
nothing was taken yet; a comment is left out.
:param last: Number of the last line to skip.
"""
for number in range(self._last + 1, last + 1):
if self._source[number - 1].strip() != "" or len(self._lines) == 0:
continue
previousNumber = self._lines[-1][0]
if previousNumber is None or self._source[previousNumber - 1].strip() != "":
self._lines.append((number, "", ""))
self._last = max(self._last, last)
def _Block(self, statements: list[stmt], variables: bool) -> None:
"""
Take a block's statements: classes and functions condensed, variables or fields as written, a statement holding
them with its clauses' headers, other statements elided - consecutive ones to a single ``...``.
:param statements: The block's statements, without a doc-string.
:param variables: Whether assignments are variables or fields - in a module or class - and taken.
"""
elided = False
attributeState: Nullable[str] = None
for statement in statements:
# a statement behind a ';' on a line already taken
if statement.end_lineno <= self._last:
continue
docStringState = attributeState
attributeState = None
if isinstance(statement, DEFINITIONS):
self._Definition(statement)
elif variables and isinstance(statement, ASSIGNMENTS):
attributeState = self._states.get(statement.lineno, "")
first = statement.lineno
while first - 1 > self._last and self._source[first - 2].lstrip().startswith("#:"):
first -= 1
self._Gap(first - 1)
self._Emit(first, statement.end_lineno, attributeState)
elif docStringState is not None and self._IsDocString(statement):
self._Gap(statement.lineno - 1)
self._Emit(statement.lineno, statement.end_lineno, docStringState)
elif any(True for _ in self._OutermostDefinitions(statement)) or (
variables and self._HoldsAssignments(statement)
):
self._Compound(statement, variables)
else:
if not elided:
self._Gap(statement.lineno - 1)
line = self._source[statement.lineno - 1]
self._lines.append((None, "", line[:len(line) - len(line.lstrip())]))
self._last = statement.end_lineno
elided = True
continue
elided = False
def _Definition(self, definition: Definition) -> None:
"""
Take a class or function: its decorators, header and doc-string, then its body's statements.
:param definition: The class or function.
"""
first = self._FirstLine(definition)
state = self._states.get(first, "")
self._Gap(first - 1)
body = definition.body
if self._source[body[0].lineno - 1].encode("utf-8")[:body[0].col_offset].strip() != b"":
# the body starts in the header's line: 'def f(): pass'
self._Emit(first, definition.end_lineno, state)
return
# the header ends before the body's first statement, without the blank lines and comments in between
headerEnd = self._FirstLine(body[0]) - 1
while headerEnd > definition.lineno and (
(line := self._source[headerEnd - 1].strip()) == "" or line.startswith("#")
):
headerEnd -= 1
self._Emit(first, headerEnd, state)
if self._IsDocString(body[0]):
self._Gap(body[0].lineno - 1)
self._Emit(body[0].lineno, body[0].end_lineno, state)
body = body[1:]
self._Block(body, isinstance(definition, ClassDef))
def _Compound(self, statement: stmt, variables: bool) -> None:
"""
Take a statement holding classes, functions, variables or fields: the headers of its clauses as written, each
clause's block like a body.
Such a statement is e.g. an ``if`` choosing between two definitions, or a ``try`` assigning a variable.
:param statement: The statement.
:param variables: Whether assignments are variables or fields - in a module or class - and taken.
"""
self._Gap(statement.lineno - 1)
for block in (
getattr(statement, "body", []),
*(clause.body for clause in (*getattr(statement, "handlers", []), *getattr(statement, "cases", []))),
getattr(statement, "orelse", []),
getattr(statement, "finalbody", [])
):
if len(block) == 0:
continue
# the clause's header - 'if ...:', 'else:', 'except ...:' -, without the blank lines and comments around it
for number in range(self._last + 1, self._FirstLine(block[0])):
if (line := self._source[number - 1].strip()) == "" or line.startswith("#"):
self._Gap(number)
else:
self._Emit(number, number, "")
self._Block(block, variables)
@staticmethod
def _FirstLine(statement: stmt) -> int:
"""
Return a statement's first line: its first decorator's, if it has decorators.
:param statement: The statement.
:returns: The line number.
"""
return min((decorator.lineno for decorator in getattr(statement, "decorator_list", ())), default=statement.lineno)
@staticmethod
def _IsDocString(statement: stmt) -> bool:
"""
Check if a statement is a string literal: a doc-string at the start of a body, or behind a variable or field.
:param statement: The statement.
:returns: ``True``, if the statement is a string literal.
"""
return (
isinstance(statement, Expr) and isinstance(statement.value, Constant) and isinstance(statement.value.value, str)
)
@classmethod
def _Definitions(cls, node: AST) -> Generator[Definition, None, None]:
"""
Yield the classes and functions below a node, depth-first: each before the ones inside it.
:param node: The node.
:returns: A generator of the classes and functions.
"""
for child in iter_child_nodes(node):
if isinstance(child, DEFINITIONS):
yield child
yield from cls._Definitions(child)
@classmethod
def _HoldsAssignments(cls, statement: stmt) -> bool:
"""
Check if a statement is an ``if`` or ``try`` assigning a variable or field, which the analysis reads: in its
body or ``else`` block, or in such a statement nested in them.
:param statement: The statement.
:returns: ``True``, if the statement holds an assignment.
"""
if not isinstance(statement, (If, Try)):
return False
return any(
isinstance(child, ASSIGNMENTS) or cls._HoldsAssignments(child) for child in (*statement.body, *statement.orelse)
)
@classmethod
def _OutermostDefinitions(cls, node: AST) -> Generator[Definition, None, None]:
"""
Yield the classes and functions below a node, which aren't inside another class or function below it.
:param node: The node.
:returns: A generator of the classes and functions, in source order.
"""
for child in iter_child_nodes(node):
if isinstance(child, DEFINITIONS):
yield child
else:
yield from cls._OutermostDefinitions(child)
@export
class CondensedSource(metaclass=ExtendedType, slots=True):
"""
A Python module's source, condensed to what its documentation coverage is about.
A line is taken as written, at its line number, or replaced by a ``...`` line without a number:
* The module's doc-string is taken; its imports and other statements are elided.
* A class or function is taken with its decorators, its header up to the ``:`` - a signature spanning lines too -
and its doc-string. Its body follows: a class' members, a function's nested functions and classes; anything else
is elided to one ``...`` at the body's indentation. A definition written in one line is taken as written.
* A variable of the module and a field of a class is taken as written: with a ``#:`` comment on its line or in the
lines above it, and a doc-string literal following it.
* A statement holding a class or function - e.g. an ``if`` or a ``try`` -, and an ``if`` or ``try`` assigning a
variable or field, is taken with the headers of its clauses as written; each clause's block is taken like a body.
* Comments between statements are left out; consecutive blank lines become one.
A class', function's, field's or variable's lines are marked by the documentation state of its item in
pyEDAA.Reports' model: the item declared in the module's file, which starts at the definition's first line or at the
assignment. The module's doc-string is marked by the module's state. Every other line has no state, e.g. a repeated
assignment of a variable, as its item starts at the first one.
"""
_lines: list[tuple[Nullable[int], str, str]] #: The condensed lines; see :attr:`Lines`.
def __init__(self, lines: Iterable[tuple[Nullable[int], str, str]]) -> None:
"""
Initialize a condensed source from its lines.
:param lines: The condensed lines (see :attr:`Lines`).
:raises ValueError: If parameter ``lines`` is ``None``.
:raises TypeError: If parameter ``lines`` isn't iterable.
:raises TypeError: If a line isn't a tuple.
"""
if lines is None:
raise ValueError("Parameter 'lines' is None.")
elif not isinstance(lines, Iterable):
ex = TypeError("Parameter 'lines' is not iterable.")
ex.add_note(f"Got type '{getFullyQualifiedName(lines)}'.")
raise ex
self._lines = []
for line in lines:
if not isinstance(line, tuple):
ex = TypeError("Parameter 'lines' contains an element, which is not of type 'tuple'.")
ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
raise ex
self._lines.append(line)
@classmethod
def Parse(cls, source: str, path: str, module: Item) -> Self:
"""
Condense a module's source.
An analysis not matching the source - a class or function without an item of its name at its first line - is
logged as a warning, and no class, function, field or variable is marked.
:param source: The module's source.
:param path: The module's path, as the warning names it.
:param module: The module's or package's item of pyEDAA.Reports' model; its file lists the items declared in it.
:returns: The condensed source.
"""
return cls(_Condenser(source, module).Condense(path))
@readonly
def Lines(self) -> list[tuple[Nullable[int], str, str]]:
"""
Read-only property to access the condensed lines (:attr:`_lines`).
:returns: A tuple per line: the line number and its state, and ``""``; or for an elided body, ``None``, ``""``
and the body's indentation.
"""
return self._lines
|