From 4f297803232dd4e3c95aedb247a37b3861605ef8 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 4 Dec 2024 11:56:21 +0100 Subject: [PATCH] Add coverage module for python The actual analysis is delegated to the well-known coverage.py package. --- .../modules/ROOT/pages/pythonlib/linting.adoc | 4 + .../pythonlib/linting/3-coverage/build.mill | 44 ++++++ .../pythonlib/linting/3-coverage/src/main.py | 5 + .../linting/3-coverage/test/src/test_main.py | 8 ++ .../src/mill/pythonlib/CoverageModule.scala | 133 ++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 example/pythonlib/linting/3-coverage/build.mill create mode 100644 example/pythonlib/linting/3-coverage/src/main.py create mode 100644 example/pythonlib/linting/3-coverage/test/src/test_main.py create mode 100644 pythonlib/src/mill/pythonlib/CoverageModule.scala diff --git a/docs/modules/ROOT/pages/pythonlib/linting.adoc b/docs/modules/ROOT/pages/pythonlib/linting.adoc index 4b02ed5f5e9..761f07166d2 100644 --- a/docs/modules/ROOT/pages/pythonlib/linting.adoc +++ b/docs/modules/ROOT/pages/pythonlib/linting.adoc @@ -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[] + diff --git a/example/pythonlib/linting/3-coverage/build.mill b/example/pythonlib/linting/3-coverage/build.mill new file mode 100644 index 00000000000..13637a8762e --- /dev/null +++ b/example/pythonlib/linting/3-coverage/build.mill @@ -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` diff --git a/example/pythonlib/linting/3-coverage/src/main.py b/example/pythonlib/linting/3-coverage/src/main.py new file mode 100644 index 00000000000..a17ea5d634f --- /dev/null +++ b/example/pythonlib/linting/3-coverage/src/main.py @@ -0,0 +1,5 @@ +def f1(): + pass + +def f2(): + pass diff --git a/example/pythonlib/linting/3-coverage/test/src/test_main.py b/example/pythonlib/linting/3-coverage/test/src/test_main.py new file mode 100644 index 00000000000..7b4ed079b0f --- /dev/null +++ b/example/pythonlib/linting/3-coverage/test/src/test_main.py @@ -0,0 +1,8 @@ +import main + +def test_f1(): + main.f1() + +def test_other(): + assert True + diff --git a/pythonlib/src/mill/pythonlib/CoverageModule.scala b/pythonlib/src/mill/pythonlib/CoverageModule.scala new file mode 100644 index 00000000000..b578ae70569 --- /dev/null +++ b/pythonlib/src/mill/pythonlib/CoverageModule.scala @@ -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 + +}