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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-everyfunc
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin to detect completely untested functions using coverage
5
+ Author-email: Johannes Buchner <johannes.buchner.acad@gmx.com>
6
+ Requires-Dist: pytest
7
+ Requires-Dist: coverage
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-everyfunc
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin to detect completely untested functions using coverage
5
+ Author-email: Johannes Buchner <johannes.buchner.acad@gmx.com>
6
+ Requires-Dist: pytest
7
+ Requires-Dist: coverage
@@ -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,2 @@
1
+ [pytest11]
2
+ pytest_everyfunc = pytest_everyfunc.plugin
@@ -0,0 +1,2 @@
1
+ pytest
2
+ coverage
@@ -0,0 +1 @@
1
+ pytest_everyfunc
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+