pytest-everyfunc 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_everyfunc-0.1.0/PKG-INFO +7 -0
- pytest_everyfunc-0.1.0/README.rst +40 -0
- pytest_everyfunc-0.1.0/pyproject.toml +13 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc/__init__.py +0 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc/plugin.py +191 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc.egg-info/PKG-INFO +7 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc.egg-info/SOURCES.txt +10 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc.egg-info/dependency_links.txt +1 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc.egg-info/entry_points.txt +2 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc.egg-info/requires.txt +2 -0
- pytest_everyfunc-0.1.0/pytest_everyfunc.egg-info/top_level.txt +1 -0
- pytest_everyfunc-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
pytest-everyfunc
|
|
2
|
+
================
|
|
3
|
+
|
|
4
|
+
A pytest plugin to detect completely untested functions using coverage.
|
|
5
|
+
|
|
6
|
+
Motivation
|
|
7
|
+
----------
|
|
8
|
+
|
|
9
|
+
If you add pytest-everyfunc to your CI, you can prevent yourself from
|
|
10
|
+
checking in code that adds functions without tests.
|
|
11
|
+
|
|
12
|
+
Installation
|
|
13
|
+
------------
|
|
14
|
+
::
|
|
15
|
+
$ pip install pytest-everyfunc pytest-cov coverage
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
-----
|
|
19
|
+
::
|
|
20
|
+
$ pytest --cov=mypackage --fail-on-untested
|
|
21
|
+
...
|
|
22
|
+
tests/test_script.py ........ [100%]
|
|
23
|
+
mypackage/foo.py:253: untested function: rv_logpdf
|
|
24
|
+
mypackage/bar.py:717: untested function: norms
|
|
25
|
+
mypackage/baz.py:86: untested function: prior_predictive_check_plot
|
|
26
|
+
Exit: Untested functions found.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
The output shows the functions that were not called.
|
|
30
|
+
|
|
31
|
+
If --fail-on-untested is set, then the exit code is 32 (regardless whether tests succeed).
|
|
32
|
+
|
|
33
|
+
::
|
|
34
|
+
$ echo $?
|
|
35
|
+
32
|
|
36
|
+
|
|
37
|
+
git hook
|
|
38
|
+
--------
|
|
39
|
+
|
|
40
|
+
add to .git/hooks/pre-commit the command above.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-everyfunc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A pytest plugin to detect completely untested functions using coverage"
|
|
9
|
+
authors = [{name = "Johannes Buchner", email = "johannes.buchner.acad@gmx.com"}]
|
|
10
|
+
dependencies = ["pytest", "coverage"]
|
|
11
|
+
|
|
12
|
+
[project.entry-points.pytest11]
|
|
13
|
+
pytest_everyfunc = "pytest_everyfunc.plugin"
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
import coverage
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_missed_lines(missing_str: str) -> List[int]:
|
|
13
|
+
"""Parse lines missing in coverage.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
missing_str: str
|
|
18
|
+
'<num>-<num>' or '<num>'
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
lines: list
|
|
23
|
+
integers in the range
|
|
24
|
+
"""
|
|
25
|
+
lines = []
|
|
26
|
+
for part in missing_str.split(","):
|
|
27
|
+
if "-" in part:
|
|
28
|
+
start, end = map(int, part.split("-"))
|
|
29
|
+
lines.extend(range(start, end + 1))
|
|
30
|
+
else:
|
|
31
|
+
lines.append(int(part))
|
|
32
|
+
return lines
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_coverage_report(file) -> dict:
|
|
36
|
+
"""Parse output of "coverage report -m"
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
file: object
|
|
41
|
+
input stream
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
missed_by_file: dict
|
|
46
|
+
list of lines for each file
|
|
47
|
+
"""
|
|
48
|
+
missed_by_file = {}
|
|
49
|
+
file.readline()
|
|
50
|
+
sepline = file.readline()
|
|
51
|
+
assert sepline.startswith("----")
|
|
52
|
+
for line in file:
|
|
53
|
+
if line.startswith("----") or line.strip() == "":
|
|
54
|
+
break
|
|
55
|
+
parts = line.strip().split(maxsplit=4)
|
|
56
|
+
if len(parts) > 4:
|
|
57
|
+
filename = parts[0]
|
|
58
|
+
missing = parts[4].strip()
|
|
59
|
+
missed_by_file[filename] = set(parse_missed_lines(missing))
|
|
60
|
+
return missed_by_file
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_functions_from_file(filepath: str) -> List[Tuple[str, int, int]]:
|
|
64
|
+
"""Get list of python functions.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
filepath: str
|
|
69
|
+
path to python script
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
functions: list
|
|
74
|
+
list of functions and their lines.s
|
|
75
|
+
"""
|
|
76
|
+
with open(filepath, "r") as f:
|
|
77
|
+
source = f.read()
|
|
78
|
+
|
|
79
|
+
tree = ast.parse(source)
|
|
80
|
+
functions = []
|
|
81
|
+
|
|
82
|
+
class FuncVisitor(ast.NodeVisitor):
|
|
83
|
+
def visit_FunctionDef(self, node):
|
|
84
|
+
"""Visit a function node.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
node: object
|
|
89
|
+
ast node.
|
|
90
|
+
"""
|
|
91
|
+
funcloc = node.lineno
|
|
92
|
+
code_lines = []
|
|
93
|
+
for child in node.body:
|
|
94
|
+
# Skip docstring (which is always the first expr if present)
|
|
95
|
+
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
|
|
96
|
+
continue
|
|
97
|
+
start = child.lineno
|
|
98
|
+
end = getattr(child, 'end_lineno', start)
|
|
99
|
+
code_lines.extend(range(start, end + 1))
|
|
100
|
+
functions.append((node.name, funcloc, code_lines))
|
|
101
|
+
self.generic_visit(node)
|
|
102
|
+
|
|
103
|
+
def visit_AsyncFunctionDef(self, node):
|
|
104
|
+
"""Visit a async function.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
node: object
|
|
109
|
+
ast node.
|
|
110
|
+
"""
|
|
111
|
+
self.visit_FunctionDef(node)
|
|
112
|
+
|
|
113
|
+
FuncVisitor().visit(tree)
|
|
114
|
+
return functions
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_never_called_functions(file = sys.stdin, source_root: str = "."):
|
|
118
|
+
"""Find functions not covered by tests.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
file: object
|
|
123
|
+
input stream
|
|
124
|
+
source_root: str
|
|
125
|
+
path where files are.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
never_called: list
|
|
130
|
+
list of filename, function name and location tuples.
|
|
131
|
+
"""
|
|
132
|
+
missed_by_file = parse_coverage_report(file)
|
|
133
|
+
never_called = []
|
|
134
|
+
|
|
135
|
+
for rel_path, missed_lines in missed_by_file.items():
|
|
136
|
+
filepath = os.path.join(source_root, rel_path)
|
|
137
|
+
if not os.path.exists(filepath):
|
|
138
|
+
print(f"File not found: {filepath}")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
functions = get_functions_from_file(filepath)
|
|
142
|
+
for func_name, funcloc, func_lines in functions:
|
|
143
|
+
if all(line in missed_lines for line in func_lines):
|
|
144
|
+
never_called.append((rel_path, func_name, funcloc))
|
|
145
|
+
|
|
146
|
+
return never_called
|
|
147
|
+
|
|
148
|
+
def pytest_addoption(parser):
|
|
149
|
+
"""Add our option to pytest.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
parser: object
|
|
154
|
+
pytest parser
|
|
155
|
+
"""
|
|
156
|
+
parser.addoption(
|
|
157
|
+
"--fail-on-untested", action="store_true", default=False,
|
|
158
|
+
help="Fail tests if completely untested functions are found."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
162
|
+
"""Run when pytest is done.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
session: object
|
|
167
|
+
pytest session
|
|
168
|
+
exitstatus: int
|
|
169
|
+
Exit status.
|
|
170
|
+
"""
|
|
171
|
+
cov_file = Path(".coverage")
|
|
172
|
+
if not cov_file.exists():
|
|
173
|
+
session.config.warn("COV001", "No .coverage file found, skipping untested function check.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
cov = coverage.Coverage()
|
|
177
|
+
cov.load()
|
|
178
|
+
|
|
179
|
+
report_output = StringIO()
|
|
180
|
+
cov.report(file=report_output, show_missing=True)
|
|
181
|
+
report_output.seek(0)
|
|
182
|
+
|
|
183
|
+
untested = find_never_called_functions(report_output)
|
|
184
|
+
|
|
185
|
+
if untested:
|
|
186
|
+
for filename, name, lineno in untested:
|
|
187
|
+
sys.stderr.write(f"{filename}:{lineno}: untested function: {name}\n")
|
|
188
|
+
|
|
189
|
+
if session.config.getoption("--fail-on-untested"):
|
|
190
|
+
pytest.exit("Untested functions found.", 32)
|
|
191
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.rst
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pytest_everyfunc/__init__.py
|
|
4
|
+
pytest_everyfunc/plugin.py
|
|
5
|
+
pytest_everyfunc.egg-info/PKG-INFO
|
|
6
|
+
pytest_everyfunc.egg-info/SOURCES.txt
|
|
7
|
+
pytest_everyfunc.egg-info/dependency_links.txt
|
|
8
|
+
pytest_everyfunc.egg-info/entry_points.txt
|
|
9
|
+
pytest_everyfunc.egg-info/requires.txt
|
|
10
|
+
pytest_everyfunc.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_everyfunc
|