Skip to content

Commit

Permalink
Add coverage module for python
Browse files Browse the repository at this point in the history
The actual analysis is delegated to the well-known coverage.py package.
  • Loading branch information
jodersky committed Jan 31, 2025
1 parent 6b3ae9b commit 4f29780
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 0 deletions.
4 changes: 4 additions & 0 deletions docs/modules/ROOT/pages/pythonlib/linting.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ include::partial$example/pythonlib/linting/1-ruff-format.adoc[]

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

== Code Coverage

include::partial$example/pythonlib/linting/3-coverage.adoc[]

44 changes: 44 additions & 0 deletions example/pythonlib/linting/3-coverage/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Mill's support for code coverage analysis is implemented by
// https://coverage.readthedocs.io/[the coverage.py package].
//
// You can use it by extending `CoverageTests` in your test module.

import mill._, pythonlib._

object `package` extends RootModule with PythonModule {

object test extends PythonTests with TestModule.Pytest with CoverageTests

}

/** See Also: src/main.py */

/** See Also: test/src/test_main.py */

// You can generate a coverage report with the `coverageReport` task.

/** Usage
> mill test.coverageReport
Name ... Stmts Miss Cover
...------------------------------------------------
.../src/main.py 4 1 75%
.../test/src/test_main.py 5 0 100%
...------------------------------------------------
TOTAL ... 9 1 89%
*/

// The task also supports any arguments understood by the `coverage.py` module.
// For example, you can use it to fail if a coverage threshold is not met:

/** Usage
> mill test.coverageReport --fail-under 90
error: ...
error: Coverage failure: total of 89 is less than fail-under=90
*/

// Other forms of reports can be generated:
//
// * `coverageHtml`
// * `coverageJson`
// * `coverageXml`
// * `coverageLcov`
5 changes: 5 additions & 0 deletions example/pythonlib/linting/3-coverage/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def f1():
pass

def f2():
pass
8 changes: 8 additions & 0 deletions example/pythonlib/linting/3-coverage/test/src/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import main

def test_f1():
main.f1()

def test_other():
assert True

133 changes: 133 additions & 0 deletions pythonlib/src/mill/pythonlib/CoverageModule.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package mill.pythonlib

import mill._

/**
* Code coverage via Python's [coverage](https://coverage.readthedocs.io/)
* package.
*
* ** Note that this is a helper trait, and you are unlikely to use this
* directly. If you're looking for including coverage across tests in your
* project, please use [[CoverageTests]] instead! **
*
* If you do want to use this module directly, please be aware that analyzing
* code coverage introduces "non-linear" changes to the execution task flow, and
* you will need to respect the following contract:
*
* 1. This trait defines a location where coverage data must be saved.
*
* 2. You need to define a `coverageTask` which is responsible for creating
* coverage data in the before mentioned location. How this is done is up to
* you. As an example, the [[CoverageTests]] module modifies `pythonOptions`
* to prepend a `-m coverage` command line argument.
*
* 3. This trait defines methods that will a) invoke the coverage task b) assume
* report data exists in the predefined location c) use that data to generate
* coverage reports.
*/
trait CoverageModule extends PythonModule {

override def pythonToolDeps = Task {
super.pythonToolDeps() ++ Seq("coverage>=7.6.10")
}

/**
* The *location* (not the ref), where the coverage report lives. This
* intentionally does not return a PathRef, since it will be populated from
* other places.
*/
def coverageDataFile: T[os.Path] = Task { T.dest / "coverage" }

/**
* The task to run to generate the coverage report.
*
* This task must generate a coverage report into the output directory of
* [[coverageDataFile]]. It is required that this file be readable as soon
* as this task returns.
*/
def coverageTask: Task[_]

private case class CoverageReporter(
interp: os.Path,
env: Map[String, String]
) {
def run(command: String, args: Seq[String]): Unit =
os.call(
(
interp,
"-m",
"coverage",
command,
args
),
env = env,
stdout = os.Inherit
)
}

private def coverageReporter = Task.Anon {
CoverageReporter(
pythonExe().path,
Map(
"COVERAGE_FILE" -> coverageDataFile().toString
)
)
}

/**
* Generate a coverage report.
*
* This command accepts arguments understood by `coverage report`. For
* example, you can cause it to fail if a certain coverage threshold is not
* met: `mill coverageReport --fail-under 90`
*/
def coverageReport(args: String*): Command[Unit] = Task.Command {
coverageTask()
coverageReporter().run("report", args)
}

/**
* Generate a HTML version of the coverage report.
*/
def coverageHtml(args: String*): Command[Unit] = Task.Command {
coverageTask()
coverageReporter().run("html", args)
}

/**
* Generate a JSON version of the coverage report.
*/
def coverageJson(args: String*): Command[Unit] = Task.Command {
coverageTask()
coverageReporter().run("json", args)
}

/**
* Generate an XML version of the coverage report.
*/
def coverageXml(args: String*): Command[Unit] = Task.Command {
coverageTask()
coverageReporter().run("xml", args)
}

/**
* Generate an LCOV version of the coverage report.
*/
def coverageLcov(args: String*): Command[Unit] = Task.Command {
coverageTask()
coverageReporter().run("lcov", args)
}

}

/** Analyze code coverage, starting from tests. */
trait CoverageTests extends CoverageModule with TestModule {

override def pythonOptions = Task {
Seq("-m", "coverage", "run", "--data-file", coverageDataFile().toString) ++
super.pythonOptions()
}

override def coverageTask = testCached

}

0 comments on commit 4f29780

Please sign in to comment.