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:

Sphinx

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