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:

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, \
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