127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
|
|
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
|
|
|
|
"""Find functions and classes in Python code."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import dataclasses
|
|
|
|
from typing import cast
|
|
|
|
from coverage.plugin import CodeRegion
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class Context:
|
|
"""The nested named context of a function or class."""
|
|
name: str
|
|
kind: str
|
|
lines: set[int]
|
|
|
|
|
|
class RegionFinder:
|
|
"""An ast visitor that will find and track regions of code.
|
|
|
|
Functions and classes are tracked by name. Results are in the .regions
|
|
attribute.
|
|
|
|
"""
|
|
def __init__(self) -> None:
|
|
self.regions: list[CodeRegion] = []
|
|
self.context: list[Context] = []
|
|
|
|
def parse_source(self, source: str) -> None:
|
|
"""Parse `source` and walk the ast to populate the .regions attribute."""
|
|
self.handle_node(ast.parse(source))
|
|
|
|
def fq_node_name(self) -> str:
|
|
"""Get the current fully qualified name we're processing."""
|
|
return ".".join(c.name for c in self.context)
|
|
|
|
def handle_node(self, node: ast.AST) -> None:
|
|
"""Recursively handle any node."""
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
self.handle_FunctionDef(node)
|
|
elif isinstance(node, ast.ClassDef):
|
|
self.handle_ClassDef(node)
|
|
else:
|
|
self.handle_node_body(node)
|
|
|
|
def handle_node_body(self, node: ast.AST) -> None:
|
|
"""Recursively handle the nodes in this node's body, if any."""
|
|
for body_node in getattr(node, "body", ()):
|
|
self.handle_node(body_node)
|
|
|
|
def handle_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
"""Called for `def` or `async def`."""
|
|
lines = set(range(node.body[0].lineno, cast(int, node.body[-1].end_lineno) + 1))
|
|
if self.context and self.context[-1].kind == "class":
|
|
# Function bodies are part of their enclosing class.
|
|
self.context[-1].lines |= lines
|
|
# Function bodies should be excluded from the nearest enclosing function.
|
|
for ancestor in reversed(self.context):
|
|
if ancestor.kind == "function":
|
|
ancestor.lines -= lines
|
|
break
|
|
self.context.append(Context(node.name, "function", lines))
|
|
self.regions.append(
|
|
CodeRegion(
|
|
kind="function",
|
|
name=self.fq_node_name(),
|
|
start=node.lineno,
|
|
lines=lines,
|
|
)
|
|
)
|
|
self.handle_node_body(node)
|
|
self.context.pop()
|
|
|
|
def handle_ClassDef(self, node: ast.ClassDef) -> None:
|
|
"""Called for `class`."""
|
|
# The lines for a class are the lines in the methods of the class.
|
|
# We start empty, and count on visit_FunctionDef to add the lines it
|
|
# finds.
|
|
lines: set[int] = set()
|
|
self.context.append(Context(node.name, "class", lines))
|
|
self.regions.append(
|
|
CodeRegion(
|
|
kind="class",
|
|
name=self.fq_node_name(),
|
|
start=node.lineno,
|
|
lines=lines,
|
|
)
|
|
)
|
|
self.handle_node_body(node)
|
|
self.context.pop()
|
|
# Class bodies should be excluded from the enclosing classes.
|
|
for ancestor in reversed(self.context):
|
|
if ancestor.kind == "class":
|
|
ancestor.lines -= lines
|
|
|
|
|
|
def code_regions(source: str) -> list[CodeRegion]:
|
|
"""Find function and class regions in source code.
|
|
|
|
Analyzes the code in `source`, and returns a list of :class:`CodeRegion`
|
|
objects describing functions and classes as regions of the code::
|
|
|
|
[
|
|
CodeRegion(kind="function", name="func1", start=8, lines={10, 11, 12}),
|
|
CodeRegion(kind="function", name="MyClass.method", start=30, lines={34, 35, 36}),
|
|
CodeRegion(kind="class", name="MyClass", start=25, lines={34, 35, 36}),
|
|
]
|
|
|
|
The line numbers will include comments and blank lines. Later processing
|
|
will need to ignore those lines as needed.
|
|
|
|
Nested functions and classes are excluded from their enclosing region. No
|
|
line should be reported as being part of more than one function, or more
|
|
than one class. Lines in methods are reported as being in a function and
|
|
in a class.
|
|
|
|
"""
|
|
rf = RegionFinder()
|
|
rf.parse_source(source)
|
|
return rf.regions
|