Coverage for python/lsst/daf/butler/tests/cliCmdTestBase.py: 45%
57 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-07 00:58 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-07 00:58 -0700
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
23import abc
24import copy
25import os
26from collections.abc import Callable
27from typing import TYPE_CHECKING, Any
28from unittest.mock import DEFAULT, call, patch
30from ..cli import butler
31from ..cli.utils import LogCliRunner, clickResultMsg
33if TYPE_CHECKING:
34 import unittest
36 import click
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 """
44 if TYPE_CHECKING:
45 assertNotEqual: Callable
46 assertRegex: Callable
47 assertFalse: Callable
48 assertEqual: Callable
50 @staticmethod
51 @abc.abstractmethod
52 def defaultExpected() -> dict[str, Any]:
53 pass
55 @staticmethod
56 @abc.abstractmethod
57 def command() -> click.Command:
58 """Get the click.Command being tested."""
59 pass
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
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
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
81 def setUp(self) -> None:
82 self.runner = LogCliRunner()
84 @classmethod
85 def makeExpected(cls, **kwargs: Any) -> dict[str, Any]:
86 expected = copy.copy(cls.defaultExpected())
87 expected.update(kwargs)
88 return expected
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.
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.
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)
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.
115 Returns the result object for inspection, e.g. sometimes it's useful to
116 be able to inspect or print `result.output`.
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.
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
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.
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)
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 )