Coverage for python / lsst / daf / butler / tests / cliCmdTestBase.py: 50%
58 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 08:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 08:17 +0000
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
27from __future__ import annotations
29__all__ = ["CliCmdTestBase"]
31import abc
32import copy
33import os
34from collections.abc import Callable
35from typing import TYPE_CHECKING, Any
36from unittest.mock import DEFAULT, call, patch
38from ..cli import butler
39from ..cli.utils import LogCliRunner, clickResultMsg
41if TYPE_CHECKING:
42 import unittest
44 import click
47class CliCmdTestBase(abc.ABC):
48 """A test case base that is used to verify click command functions import
49 and call their respective script functions correctly.
50 """
52 if TYPE_CHECKING:
53 assertNotEqual: Callable
54 assertRegex: Callable
55 assertFalse: Callable
56 assertEqual: Callable
58 @staticmethod
59 @abc.abstractmethod
60 def defaultExpected() -> dict[str, Any]:
61 pass
63 @staticmethod
64 @abc.abstractmethod
65 def command() -> click.Command:
66 """Get the click.Command being tested."""
67 pass
69 @property
70 def cli(self) -> click.core.Command:
71 """Get the command line interface function under test, can be
72 overridden to test CLIs other than butler.
73 """
74 return butler.cli
76 @property
77 def mock(self) -> unittest.mock.Mock:
78 """Get the mock object to use in place of `mockFuncName`. If not
79 provided will use the default provided by `unittest.mock.patch`, this
80 is usually a `unittest.mock.MagicMock`.
81 """
82 return DEFAULT
84 @property
85 @abc.abstractmethod
86 def mockFuncName(self) -> str:
87 """The qualified name of the function to mock, will be passed to
88 unittest.mock.patch, see python docs for details.
89 """
90 pass
92 def setUp(self) -> None:
93 self.runner = LogCliRunner()
95 @classmethod
96 def makeExpected(cls, **kwargs: Any) -> dict[str, Any]:
97 expected = copy.copy(cls.defaultExpected())
98 expected.update(kwargs)
99 return expected
101 def run_command(self, inputs: list[str]) -> click.testing.Result:
102 """Use the LogCliRunner with the mock environment variable set to
103 execute a butler subcommand and parameters specified in inputs.
105 Parameters
106 ----------
107 inputs : [`str`]
108 A list of strings that begins with the subcommand name and is
109 followed by arguments, option keys and option values.
111 Returns
112 -------
113 result : `click.testing.Result`
114 The Result object contains the results from calling
115 self.runner.invoke.
116 """
117 return self.runner.invoke(self.cli, inputs)
119 def run_test(
120 self, inputs: list[str], expectedKwargs: dict[str, str], withTempFile: str | None = None
121 ) -> click.testing.Result:
122 """Run the subcommand specified in inputs and verify a successful
123 outcome where exit code = 0 and the mock object has been called with
124 the expected arguments.
126 Returns the result object for inspection, e.g. sometimes it's useful to
127 be able to inspect or print ``result.output``.
129 Parameters
130 ----------
131 inputs : [`str`]
132 A list of strings that begins with the subcommand name and is
133 followed by arguments, option keys and option values.
134 expectedKwargs : `dict` [`str`, `str`]
135 The arguments that the subcommand function is expected to have been
136 called with. Keys are the argument name and values are the argument
137 value.
138 withTempFile : `str`, optional
139 If not None, will run in a temporary directory and create a file
140 with the given name, can be used with commands with parameters that
141 require a file to exist.
143 Returns
144 -------
145 result : `click.testing.Result`
146 The result object produced by invocation of the command under test.
147 """
148 with self.runner.isolated_filesystem():
149 if withTempFile is not None:
150 directory, filename = os.path.split(withTempFile)
151 if directory:
152 os.makedirs(os.path.dirname(withTempFile), exist_ok=True)
153 with open(withTempFile, "w") as _:
154 # just need to make the file, don't need to keep it open.
155 pass
156 with patch(self.mockFuncName, self.mock) as mock:
157 result = self.run_command(inputs)
158 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
159 calls = (call(**expectedKwargs),)
160 mock.assert_has_calls(list(calls))
161 return result
163 def run_missing(self, inputs: list[str], expectedMsg: str) -> None:
164 """Run the subcommand specified in inputs and verify a failed outcome
165 where exit code != 0 and an expected message has been written to
166 stdout.
168 Parameters
169 ----------
170 inputs : [`str`]
171 A list of strings that begins with the subcommand name and is
172 followed by arguments, option keys and option values.
173 expectedMsg : `str`
174 An error message that should be present in stdout after running the
175 subcommand. Can be a regular expression string.
176 """
177 result = self.run_command(inputs)
178 self.assertNotEqual(result.exit_code, 0, clickResultMsg(result))
179 self.assertRegex(result.output, expectedMsg)
181 def test_help(self) -> None:
182 self.assertFalse(
183 self.command().get_short_help_str().endswith("..."),
184 msg="The command help message is being truncated to "
185 f'"{self.command().get_short_help_str()}". It should be shortened, or define '
186 '@command(short_help="something short and helpful")',
187 )