Coverage for python/lsst/daf/butler/tests/cliCmdTestBase.py: 43%

65 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-05 02:04 -0800

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 program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21from __future__ import annotations 

22 

23import abc 

24import copy 

25import os 

26from collections.abc import Callable 

27from typing import TYPE_CHECKING, Any 

28from unittest.mock import DEFAULT, call, patch 

29 

30from ..cli import butler 

31from ..cli.utils import LogCliRunner, clickResultMsg 

32 

33if TYPE_CHECKING: 33 ↛ 34line 33 didn't jump to line 34, because the condition on line 33 was never true

34 import unittest 

35 

36 import click 

37 

38 

39class CliCmdTestBase(abc.ABC): 

40 """A test case base that is used to verify click command functions import 

41 and call their respective script functions correctly. 

42 """ 

43 

44 if TYPE_CHECKING: 44 ↛ 45line 44 didn't jump to line 45, because the condition on line 44 was never true

45 assertNotEqual: Callable 

46 assertRegex: Callable 

47 assertFalse: Callable 

48 assertEqual: Callable 

49 

50 @staticmethod 

51 @abc.abstractmethod 

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

53 pass 

54 

55 @staticmethod 

56 @abc.abstractmethod 

57 def command() -> click.Command: 

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

59 pass 

60 

61 @property 

62 def cli(self) -> click.core.Command: 

63 """Get the command line interface function under test, can be 

64 overridden to test CLIs other than butler.""" 

65 return butler.cli 

66 

67 @property 

68 def mock(self) -> unittest.mock.Mock: 

69 """Get the mock object to use in place of `mockFuncName`. If not 

70 provided will use the default provided by `unittest.mock.patch`, this 

71 is usually a `unittest.mock.MagicMock`.""" 

72 return DEFAULT 

73 

74 @property 

75 @abc.abstractmethod 

76 def mockFuncName(self) -> str: 

77 """The qualified name of the function to mock, will be passed to 

78 unittest.mock.patch, see python docs for details.""" 

79 pass 

80 

81 def setUp(self) -> None: 

82 self.runner = LogCliRunner() 

83 

84 @classmethod 

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

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

87 expected.update(kwargs) 

88 return expected 

89 

90 def run_command(self, inputs: list[str]) -> click.testing.Result: 

91 """Use the LogCliRunner with the mock environment variable set to 

92 execute a butler subcommand and parameters specified in inputs. 

93 

94 Parameters 

95 ---------- 

96 inputs : [`str`] 

97 A list of strings that begins with the subcommand name and is 

98 followed by arguments, option keys and option values. 

99 

100 Returns 

101 ------- 

102 result : `click.testing.Result` 

103 The Result object contains the results from calling 

104 self.runner.invoke. 

105 """ 

106 return self.runner.invoke(self.cli, inputs) 

107 

108 def run_test( 

109 self, inputs: list[str], expectedKwargs: dict[str, str], withTempFile: str | None = None 

110 ) -> click.testing.Result: 

111 """Run the subcommand specified in inputs and verify a successful 

112 outcome where exit code = 0 and the mock object has been called with 

113 the expected arguments. 

114 

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

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

117 

118 Parameters 

119 ---------- 

120 inputs : [`str`] 

121 A list of strings that begins with the subcommand name and is 

122 followed by arguments, option keys and option values. 

123 expectedKwargs : `dict` [`str`, `str`] 

124 The arguments that the subcommand function is expected to have been 

125 called with. Keys are the argument name and values are the argument 

126 value. 

127 withTempFile : `str`, optional 

128 If not None, will run in a temporary directory and create a file 

129 with the given name, can be used with commands with parameters that 

130 require a file to exist. 

131 

132 Returns 

133 ------- 

134 result : `click.testing.Result` 

135 The result object produced by invocation of the command under test. 

136 """ 

137 with self.runner.isolated_filesystem(): 

138 if withTempFile is not None: 

139 directory, filename = os.path.split(withTempFile) 

140 if directory: 

141 os.makedirs(os.path.dirname(withTempFile), exist_ok=True) 

142 with open(withTempFile, "w") as _: 

143 # just need to make the file, don't need to keep it open. 

144 pass 

145 with patch(self.mockFuncName, self.mock) as mock: 

146 result = self.run_command(inputs) 

147 self.assertEqual(result.exit_code, 0, clickResultMsg(result)) 

148 calls = (call(**expectedKwargs),) 

149 mock.assert_has_calls(list(calls)) 

150 return result 

151 

152 def run_missing(self, inputs: list[str], expectedMsg: str) -> None: 

153 """Run the subcommand specified in inputs and verify a failed outcome 

154 where exit code != 0 and an expected message has been written to 

155 stdout. 

156 

157 Parameters 

158 ---------- 

159 inputs : [`str`] 

160 A list of strings that begins with the subcommand name and is 

161 followed by arguments, option keys and option values. 

162 expectedMsg : `str` 

163 An error message that should be present in stdout after running the 

164 subcommand. Can be a regular expression string. 

165 """ 

166 result = self.run_command(inputs) 

167 self.assertNotEqual(result.exit_code, 0, clickResultMsg(result)) 

168 self.assertRegex(result.stdout, expectedMsg) 

169 

170 def test_help(self) -> None: 

171 self.assertFalse( 

172 self.command().get_short_help_str().endswith("..."), 

173 msg="The command help message is being truncated to " 

174 f'"{self.command().get_short_help_str()}". It should be shortened, or define ' 

175 '@command(short_help="something short and helpful")', 

176 )