Coverage for tests/test_cliUtils.py: 21%
199 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-25 15:14 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-25 15:14 +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 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/>.
22"""Unit tests for the daf_butler shared CLI options.
23"""
25import unittest
26from unittest.mock import MagicMock
28import click
29from lsst.daf.butler.cli.opt import directory_argument, repo_argument
30from lsst.daf.butler.cli.utils import (
31 LogCliRunner,
32 MWArgumentDecorator,
33 MWCommand,
34 MWCtxObj,
35 MWOption,
36 MWOptionDecorator,
37 MWPath,
38 clickResultMsg,
39 option_section,
40 unwrap,
41)
44class ArgumentHelpGeneratorTestCase(unittest.TestCase):
45 """Test the help system."""
47 def testHelp(self):
48 @click.command()
49 # Use custom help in the arguments so that any changes to default help
50 # text do not break this test unnecessarily.
51 @repo_argument(help="repo help text")
52 @directory_argument(help="directory help text")
53 def cli():
54 """The cli help message.""" # noqa: D401
55 pass
57 self.runTest(cli)
59 def testHelpWrapped(self):
60 @click.command()
61 # Use custom help in the arguments so that any changes to default help
62 # text do not break this test unnecessarily.
63 @repo_argument(help="repo help text")
64 @directory_argument(help="directory help text")
65 def cli():
66 """The cli help message.""" # noqa: D401
67 pass
69 self.runTest(cli)
71 def runTest(self, cli):
72 """Test `utils.addArgumentHelp` and its use in repo_argument and
73 directory_argument; verifies that the argument help gets added to the
74 command function help, and that it's added in the correct order. See
75 addArgumentHelp for more details.
76 """
77 expected = """Usage: cli [OPTIONS] REPO DIRECTORY
79 The cli help message.
81 repo help text
83 directory help text
85Options:
86 --help Show this message and exit.
87"""
88 runner = LogCliRunner()
89 result = runner.invoke(cli, ["--help"])
90 self.assertIn(expected, result.output)
93class UnwrapStringTestCase(unittest.TestCase):
94 """Test string unwrapping."""
96 def test_leadingNewline(self):
97 testStr = """
98 foo bar
99 baz """
100 self.assertEqual(unwrap(testStr), "foo bar baz")
102 def test_leadingContent(self):
103 testStr = """foo bar
104 baz """
105 self.assertEqual(unwrap(testStr), "foo bar baz")
107 def test_trailingNewline(self):
108 testStr = """
109 foo bar
110 baz
111 """
112 self.assertEqual(unwrap(testStr), "foo bar baz")
114 def test_oneLine(self):
115 testStr = """foo bar baz"""
116 self.assertEqual(unwrap(testStr), "foo bar baz")
118 def test_oneLineWithLeading(self):
119 testStr = """
120 foo bar baz"""
121 self.assertEqual(unwrap(testStr), "foo bar baz")
123 def test_oneLineWithTrailing(self):
124 testStr = """foo bar baz
125 """
126 self.assertEqual(unwrap(testStr), "foo bar baz")
128 def test_lineBreaks(self):
129 testStr = """foo bar
130 baz
132 boz
134 qux"""
135 self.assertEqual(unwrap(testStr), "foo bar baz\n\nboz\n\nqux")
138class MWOptionTest(unittest.TestCase):
139 """Test MWOption."""
141 def setUp(self):
142 self.runner = LogCliRunner()
144 def test_addEllipsisToMultiple(self):
145 """Verify that MWOption adds ellipsis to the option metavar when
146 `multiple=True`
148 The default behavior of click is to not add ellipsis to options that
149 have `multiple=True`.
150 """
152 @click.command()
153 @click.option("--things", cls=MWOption, multiple=True)
154 def cmd(things):
155 pass
157 result = self.runner.invoke(cmd, ["--help"])
158 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
159 expectedOutput = """Options:
160 --things TEXT ..."""
161 self.assertIn(expectedOutput, result.output)
163 def test_addEllipsisToNargs(self):
164 """Verify that MWOption adds " ..." after the option metavar when
165 `nargs` is set to more than 1 and less than 1.
167 The default behavior of click is to add ellipsis when nargs does not
168 equal 1, but it does not put a space before the ellipsis and we prefer
169 a space between the metavar and the ellipsis.
170 """
171 for numberOfArgs in (0, 1, 2): # nargs must be >= 0 for an option
173 @click.command()
174 @click.option("--things", cls=MWOption, nargs=numberOfArgs)
175 def cmd(things):
176 pass
178 result = self.runner.invoke(cmd, ["--help"])
179 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
180 expectedOutput = f"""Options:
181 --things TEXT{' ...' if numberOfArgs != 1 else ''}"""
182 self.assertIn(expectedOutput, result.output)
185class MWArgumentDecoratorTest(unittest.TestCase):
186 """Tests for the MWArgumentDecorator class."""
188 things_argument = MWArgumentDecorator("things")
189 otherHelpText = "Help text for OTHER."
190 other_argument = MWArgumentDecorator("other", help=otherHelpText)
192 def setUp(self):
193 self.runner = LogCliRunner()
195 def test_help(self):
196 """Verify expected help text output.
198 Verify argument help gets inserted after the usage, in the order
199 arguments are declared.
201 Verify that MWArgument adds " ..." after the option metavar when
202 `nargs` != 1. The default behavior of click is to add ellipsis when
203 nargs does not equal 1, but it does not put a space before the ellipsis
204 and we prefer a space between the metavar and the ellipsis.
205 """
206 # nargs can be -1 for any number of args, or >= 1 for a specified
207 # number of arguments.
209 helpText = "Things help text."
210 for numberOfArgs in (-1, 1, 2):
211 for required in (True, False):
213 @click.command()
214 @self.things_argument(required=required, nargs=numberOfArgs, help=helpText)
215 @self.other_argument()
216 def cmd(things, other):
217 """Cmd help text."""
218 pass
220 result = self.runner.invoke(cmd, ["--help"])
221 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
222 things = "THINGS" if required else "[THINGS]"
223 additional = "... " if numberOfArgs != 1 else ""
224 expectedOutput = f"""Usage: cmd [OPTIONS] {things} {additional}OTHER
226 Cmd help text.
228 {helpText}
230 {self.otherHelpText}
231"""
232 self.assertIn(expectedOutput, result.output)
234 def testUse(self):
235 """Test using the MWArgumentDecorator with a command."""
236 mock = MagicMock()
238 @click.command()
239 @self.things_argument()
240 def cli(things):
241 mock(things)
243 self.runner = click.testing.CliRunner()
244 result = self.runner.invoke(cli, "foo")
245 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
246 mock.assert_called_with("foo")
249class MWOptionDecoratorTest(unittest.TestCase):
250 """Tests for the MWOptionDecorator class."""
252 _test_option = MWOptionDecorator("-t", "--test", multiple=True)
254 def testGetName(self):
255 """Test getting the option name from the MWOptionDecorator."""
256 self.assertEqual(self._test_option.name(), "test")
258 def testGetOpts(self):
259 """Test getting the option flags from the MWOptionDecorator."""
260 self.assertEqual(self._test_option.opts(), ["-t", "--test"])
262 def testUse(self):
263 """Test using the MWOptionDecorator with a command."""
264 mock = MagicMock()
266 @click.command()
267 @self._test_option()
268 def cli(test):
269 mock(test)
271 self.runner = click.testing.CliRunner()
272 result = self.runner.invoke(cli, ("-t", "foo"))
273 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
274 mock.assert_called_with(("foo",))
276 def testOverride(self):
277 """Test using the MWOptionDecorator with a command and overriding one
278 of the default values.
279 """
280 mock = MagicMock()
282 @click.command()
283 @self._test_option(multiple=False)
284 def cli(test):
285 mock(test)
287 self.runner = click.testing.CliRunner()
288 result = self.runner.invoke(cli, ("-t", "foo"))
289 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
290 mock.assert_called_with("foo")
293class SectionOptionTest(unittest.TestCase):
294 """Tests for the option_section decorator that inserts section break
295 headings between options in the --help output of a command.
296 """
298 @staticmethod
299 @click.command()
300 @click.option("--foo")
301 @option_section("Section break between metasyntactic variables.")
302 @click.option("--bar")
303 def cli(foo, bar):
304 pass
306 def setUp(self):
307 self.runner = click.testing.CliRunner()
309 def test_section_help(self):
310 """Verify that the section break is printed in the help output in the
311 expected location and with expected formatting.
312 """
313 result = self.runner.invoke(self.cli, ["--help"])
314 # \x20 is a space, added explicitly below to prevent the
315 # normally-helpful editor setting "remove trailing whitespace" from
316 # stripping it out in this case. (The blank line with 2 spaces is an
317 # artifact of how click and our code generate help text.)
318 expected = """Options:
319 --foo TEXT
320\x20\x20
321Section break between metasyntactic variables.
322 --bar TEXT"""
323 self.assertIn(expected, result.output)
325 def test_section_function(self):
326 """Verify that the section does not cause any arguments to be passed to
327 the command function.
329 The command function `cli` implementation inputs `foo` and `bar`, but
330 does accept an argument for the section. When the command is invoked
331 and the function called it should result in exit_code=0 (not 1 with a
332 missing argument error).
333 """
334 result = self.runner.invoke(self.cli, [])
335 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
338class MWPathTest(unittest.TestCase):
339 """Test MWPath."""
341 def getCmd(self, exists):
342 @click.command()
343 @click.option("--name", type=MWPath(exists=exists))
344 def cmd(name):
345 pass
347 return cmd
349 def setUp(self):
350 self.runner = click.testing.CliRunner()
352 def test_exist(self):
353 """Test the exist argument, verify that True means the file must exist,
354 False means the file must not exist, and None means that the file may
355 or may not exist.
356 """
357 with self.runner.isolated_filesystem():
358 mustExistCmd = self.getCmd(exists=True)
359 mayExistCmd = self.getCmd(exists=None)
360 mustNotExistCmd = self.getCmd(exists=False)
361 args = ["--name", "foo.txt"]
363 result = self.runner.invoke(mustExistCmd, args)
364 self.assertNotEqual(result.exit_code, 0, clickResultMsg(result))
365 self.assertRegex(result.output, """['"]foo.txt['"] does not exist.""")
367 result = self.runner.invoke(mayExistCmd, args)
368 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
370 result = self.runner.invoke(mustNotExistCmd, args)
371 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
373 # isolated_filesystem runs in a temporary directory, when it is
374 # removed everything inside will be removed.
375 with open("foo.txt", "w") as _:
376 result = self.runner.invoke(mustExistCmd, args)
377 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
379 result = self.runner.invoke(mayExistCmd, args)
380 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
382 result = self.runner.invoke(mustNotExistCmd, args)
383 self.assertNotEqual(result.exit_code, 0, clickResultMsg(result))
384 self.assertIn('"foo.txt" should not exist.', result.output)
387class MWCommandTest(unittest.TestCase):
388 """Test MWCommand."""
390 def setUp(self):
391 self.runner = click.testing.CliRunner()
392 self.ctx = None
394 def testCaptureOptions(self):
395 """Test that command options are captured in order."""
397 @click.command(cls=MWCommand)
398 @click.argument("ARG_A", required=False)
399 @click.argument("ARG_B", required=False)
400 @click.option("-o", "--an-option")
401 @click.option("-s", "--second-option")
402 @click.option("-f", is_flag=True)
403 @click.option("-d/-n", "--do/--do-not", "do_or_do_not")
404 @click.option("--multi", multiple=True)
405 @click.pass_context
406 def cmd(ctx, arg_a, arg_b, an_option, second_option, f, do_or_do_not, multi):
407 self.assertIsNotNone(ctx)
408 self.ctx = ctx
410 # When `expected` is `None`, the expected args are exactly the same as
411 # as the result of `args.split()`. If `expected` is not `None` then
412 # the expected args are the same as `expected.split()`.
413 for args, expected in (
414 ("--an-option foo --second-option bar", None),
415 ("--second-option bar --an-option foo", None),
416 ("--an-option foo", None),
417 ("--second-option bar", None),
418 ("--an-option foo -f --second-option bar", None),
419 ("-o foo -s bar", "--an-option foo --second-option bar"),
420 ("--an-option foo -f -s bar", "--an-option foo -f --second-option bar"),
421 ("--an-option=foo", "--an-option foo"),
422 # NB when using a short flag everything that follows, including an
423 # equals sign, is part of the value!
424 ("-o=foo", "--an-option =foo"),
425 ("--do", None),
426 ("--do-not", None),
427 ("-d", "--do"),
428 ("-n", "--do-not"),
429 # Arguments always come last, but the order of Arguments is still
430 # preserved:
431 ("myarg --an-option foo", "--an-option foo myarg"),
432 ("argA --an-option foo", "--an-option foo argA"),
433 ("argA argB --an-option foo", "--an-option foo argA argB"),
434 ("argA --an-option foo argB", "--an-option foo argA argB"),
435 ("--an-option foo argA argB", "--an-option foo argA argB"),
436 ("--multi one --multi two", None),
437 ):
438 split_args = args.split()
439 expected_args = split_args if expected is None else expected.split()
440 result = self.runner.invoke(cmd, split_args)
441 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
442 self.assertIsNotNone(self.ctx)
443 ctx_obj = MWCtxObj.getFrom(self.ctx)
444 self.assertEqual(ctx_obj.args, expected_args)
447if __name__ == "__main__":
448 unittest.main()