diff --git a/doc/changes/DM-47159.bugfix.md b/doc/changes/DM-47159.bugfix.md new file mode 100644 index 00000000..a6959a12 --- /dev/null +++ b/doc/changes/DM-47159.bugfix.md @@ -0,0 +1,3 @@ +Makes coverage package optional and lazy-loads it only when needed. +Declares packaging extra for coverage, i.e., `pip install lsst-ctrl-mpexec[coverage]`. +Updates minimum supported Python to 3.11 (dependency-driven change). diff --git a/pyproject.toml b/pyproject.toml index 00368e29..29689729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "lsst-ctrl-mpexec" -requires-python = ">=3.10.0" +requires-python = ">=3.11.0" description = "Pipeline execution infrastructure for the Rubin Observatory LSST Science Pipelines." license = {text = "BSD 3-Clause License"} readme = "README.rst" @@ -16,8 +16,9 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Astronomy", ] keywords = ["lsst"] @@ -38,6 +39,7 @@ dynamic = ["version"] "Homepage" = "https://github.com/lsst/ctrl_mpexec" [project.optional-dependencies] +coverage = ["coverage"] test = ["pytest >= 3.2"] [tool.setuptools.packages.find] diff --git a/python/lsst/ctrl/mpexec/cli/cmd/commands.py b/python/lsst/ctrl/mpexec/cli/cmd/commands.py index db16e2f0..6ca498a9 100644 --- a/python/lsst/ctrl/mpexec/cli/cmd/commands.py +++ b/python/lsst/ctrl/mpexec/cli/cmd/commands.py @@ -29,11 +29,11 @@ from collections.abc import Iterator, Sequence from contextlib import contextmanager from functools import partial +from importlib import import_module from tempfile import NamedTemporaryFile from typing import Any import click -import coverage import lsst.pipe.base.cli.opt as pipeBaseOpts from lsst.ctrl.mpexec import Report from lsst.ctrl.mpexec.showInfo import ShowInfo @@ -145,6 +145,11 @@ def coverage_context(kwargs: dict[str, Any]) -> Iterator[None]: if not kwargs.pop("coverage", False): yield return + # Lazily import coverage only when we might need it + try: + coverage = import_module("coverage") + except ModuleNotFoundError: + raise click.ClickException("coverage was requested but the coverage package is not installed.") with NamedTemporaryFile("w") as rcfile: rcfile.write( """ diff --git a/python/lsst/ctrl/mpexec/cli/opt/options.py b/python/lsst/ctrl/mpexec/cli/opt/options.py index 43035521..4b81d0f1 100644 --- a/python/lsst/ctrl/mpexec/cli/opt/options.py +++ b/python/lsst/ctrl/mpexec/cli/opt/options.py @@ -54,7 +54,9 @@ "--debug", help="Enable debugging output using lsstDebug facility (imports debug.py).", is_flag=True ) -coverage_option = MWOptionDecorator("--coverage", help="Enable coverage output.", is_flag=True) +coverage_option = MWOptionDecorator( + "--coverage", help="Enable coverage output (requires coverage package).", is_flag=True +) coverage_report_option = MWOptionDecorator( "--cov-report/--no-cov-report", diff --git a/tests/test_cliScript.py b/tests/test_cliScript.py index f05c4771..e6c92637 100644 --- a/tests/test_cliScript.py +++ b/tests/test_cliScript.py @@ -28,10 +28,12 @@ import os import tempfile import unittest +import unittest.mock import click import lsst.utils.tests from lsst.ctrl.mpexec.cli import opt, script +from lsst.ctrl.mpexec.cli.cmd.commands import coverage_context from lsst.ctrl.mpexec.cli.pipetask import cli as pipetaskCli from lsst.ctrl.mpexec.showInfo import ShowInfo from lsst.daf.butler.cli.utils import LogCliRunner, clickResultMsg @@ -203,6 +205,25 @@ def cli(**kwargs): self.assertNotEqual(result.exit_code, 0) +class CoverageTestCase(unittest.TestCase): + """Test coverage context manager.""" + + @unittest.mock.patch.dict("sys.modules", coverage=unittest.mock.MagicMock()) + def testWithCoverage(self): + """Test that the coverage context manager runs when invoked.""" + with coverage_context({"coverage": True}): + self.assertTrue(True) + + @unittest.mock.patch("lsst.ctrl.mpexec.cli.cmd.commands.import_module", side_effect=ModuleNotFoundError()) + def testWithMissingCoverage(self, mock_import): + """Test that the coverage context manager complains when coverage is + not available. + """ + with self.assertRaises(click.exceptions.ClickException): + with coverage_context({"coverage": True}): + pass + + if __name__ == "__main__": lsst.utils.tests.init() unittest.main()