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

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 

28 

29__all__ = ["CliCmdTestBase"] 

30 

31import abc 

32import copy 

33import os 

34from collections.abc import Callable 

35from typing import TYPE_CHECKING, Any 

36from unittest.mock import DEFAULT, call, patch 

37 

38from ..cli import butler 

39from ..cli.utils import LogCliRunner, clickResultMsg 

40 

41if TYPE_CHECKING: 

42 import unittest 

43 

44 import click 

45 

46 

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 """ 

51 

52 if TYPE_CHECKING: 

53 assertNotEqual: Callable 

54 assertRegex: Callable 

55 assertFalse: Callable 

56 assertEqual: Callable 

57 

58 @staticmethod 

59 @abc.abstractmethod 

60 def defaultExpected() -> dict[str, Any]: 

61 pass 

62 

63 @staticmethod 

64 @abc.abstractmethod 

65 def command() -> click.Command: 

66 """Get the click.Command being tested.""" 

67 pass 

68 

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 

75 

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 

83 

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 

91 

92 def setUp(self) -> None: 

93 self.runner = LogCliRunner() 

94 

95 @classmethod 

96 def makeExpected(cls, **kwargs: Any) -> dict[str, Any]: 

97 expected = copy.copy(cls.defaultExpected()) 

98 expected.update(kwargs) 

99 return expected 

100 

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. 

104 

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. 

110 

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) 

118 

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. 

125 

126 Returns the result object for inspection, e.g. sometimes it's useful to 

127 be able to inspect or print ``result.output``. 

128 

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. 

142 

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 

162 

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. 

167 

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) 

180 

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 )