Skip to content

Commit 9333c8c

Browse files
authored
Add coverage module for python (#4439)
The actual analysis is delegated to the well-known coverage.py package.
1 parent 6b3ae9b commit 9333c8c

File tree

5 files changed

+194
-0
lines changed

5 files changed

+194
-0
lines changed

docs/modules/ROOT/pages/pythonlib/linting.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ include::partial$example/pythonlib/linting/1-ruff-format.adoc[]
1717

1818
include::partial$example/pythonlib/linting/2-ruff-check.adoc[]
1919

20+
== Code Coverage
21+
22+
include::partial$example/pythonlib/linting/3-coverage.adoc[]
23+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Mill's support for code coverage analysis is implemented by
2+
// https://coverage.readthedocs.io/[the coverage.py package].
3+
//
4+
// You can use it by extending `CoverageTests` in your test module.
5+
6+
import mill._, pythonlib._
7+
8+
object `package` extends RootModule with PythonModule {
9+
10+
object test extends PythonTests with TestModule.Pytest with CoverageTests
11+
12+
}
13+
14+
/** See Also: src/main.py */
15+
16+
/** See Also: test/src/test_main.py */
17+
18+
// You can generate a coverage report with the `coverageReport` task.
19+
20+
/** Usage
21+
> mill test.coverageReport
22+
Name ... Stmts Miss Cover
23+
...------------------------------------------------
24+
.../src/main.py 4 1 75%
25+
.../test/src/test_main.py 5 0 100%
26+
...------------------------------------------------
27+
TOTAL ... 9 1 89%
28+
*/
29+
30+
// The task also supports any arguments understood by the `coverage.py` module.
31+
// For example, you can use it to fail if a coverage threshold is not met:
32+
33+
/** Usage
34+
> mill test.coverageReport --fail-under 90
35+
error: ...
36+
error: Coverage failure: total of 89 is less than fail-under=90
37+
*/
38+
39+
// Other forms of reports can be generated:
40+
//
41+
// * `coverageHtml`
42+
// * `coverageJson`
43+
// * `coverageXml`
44+
// * `coverageLcov`
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def f1():
2+
pass
3+
4+
def f2():
5+
pass
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import main
2+
3+
def test_f1():
4+
main.f1()
5+
6+
def test_other():
7+
assert True
8+
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package mill.pythonlib
2+
3+
import mill._
4+
5+
/**
6+
* Code coverage via Python's [coverage](https://coverage.readthedocs.io/)
7+
* package.
8+
*
9+
* ** Note that this is a helper trait, and you are unlikely to use this
10+
* directly. If you're looking for including coverage across tests in your
11+
* project, please use [[CoverageTests]] instead! **
12+
*
13+
* If you do want to use this module directly, please be aware that analyzing
14+
* code coverage introduces "non-linear" changes to the execution task flow, and
15+
* you will need to respect the following contract:
16+
*
17+
* 1. This trait defines a location where coverage data must be saved.
18+
*
19+
* 2. You need to define a `coverageTask` which is responsible for creating
20+
* coverage data in the before mentioned location. How this is done is up to
21+
* you. As an example, the [[CoverageTests]] module modifies `pythonOptions`
22+
* to prepend a `-m coverage` command line argument.
23+
*
24+
* 3. This trait defines methods that will a) invoke the coverage task b) assume
25+
* report data exists in the predefined location c) use that data to generate
26+
* coverage reports.
27+
*/
28+
trait CoverageModule extends PythonModule {
29+
30+
override def pythonToolDeps = Task {
31+
super.pythonToolDeps() ++ Seq("coverage>=7.6.10")
32+
}
33+
34+
/**
35+
* The *location* (not the ref), where the coverage report lives. This
36+
* intentionally does not return a PathRef, since it will be populated from
37+
* other places.
38+
*/
39+
def coverageDataFile: T[os.Path] = Task { T.dest / "coverage" }
40+
41+
/**
42+
* The task to run to generate the coverage report.
43+
*
44+
* This task must generate a coverage report into the output directory of
45+
* [[coverageDataFile]]. It is required that this file be readable as soon
46+
* as this task returns.
47+
*/
48+
def coverageTask: Task[_]
49+
50+
private case class CoverageReporter(
51+
interp: os.Path,
52+
env: Map[String, String]
53+
) {
54+
def run(command: String, args: Seq[String]): Unit =
55+
os.call(
56+
(
57+
interp,
58+
"-m",
59+
"coverage",
60+
command,
61+
args
62+
),
63+
env = env,
64+
stdout = os.Inherit
65+
)
66+
}
67+
68+
private def coverageReporter = Task.Anon {
69+
CoverageReporter(
70+
pythonExe().path,
71+
Map(
72+
"COVERAGE_FILE" -> coverageDataFile().toString
73+
)
74+
)
75+
}
76+
77+
/**
78+
* Generate a coverage report.
79+
*
80+
* This command accepts arguments understood by `coverage report`. For
81+
* example, you can cause it to fail if a certain coverage threshold is not
82+
* met: `mill coverageReport --fail-under 90`
83+
*/
84+
def coverageReport(args: String*): Command[Unit] = Task.Command {
85+
coverageTask()
86+
coverageReporter().run("report", args)
87+
}
88+
89+
/**
90+
* Generate a HTML version of the coverage report.
91+
*/
92+
def coverageHtml(args: String*): Command[Unit] = Task.Command {
93+
coverageTask()
94+
coverageReporter().run("html", args)
95+
}
96+
97+
/**
98+
* Generate a JSON version of the coverage report.
99+
*/
100+
def coverageJson(args: String*): Command[Unit] = Task.Command {
101+
coverageTask()
102+
coverageReporter().run("json", args)
103+
}
104+
105+
/**
106+
* Generate an XML version of the coverage report.
107+
*/
108+
def coverageXml(args: String*): Command[Unit] = Task.Command {
109+
coverageTask()
110+
coverageReporter().run("xml", args)
111+
}
112+
113+
/**
114+
* Generate an LCOV version of the coverage report.
115+
*/
116+
def coverageLcov(args: String*): Command[Unit] = Task.Command {
117+
coverageTask()
118+
coverageReporter().run("lcov", args)
119+
}
120+
121+
}
122+
123+
/** Analyze code coverage, starting from tests. */
124+
trait CoverageTests extends CoverageModule with TestModule {
125+
126+
override def pythonOptions = Task {
127+
Seq("-m", "coverage", "run", "--data-file", coverageDataFile().toString) ++
128+
super.pythonOptions()
129+
}
130+
131+
override def coverageTask = testCached
132+
133+
}

0 commit comments

Comments
 (0)