Coverage for tests/test_cliUtils.py: 25%
199 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-14 09:11 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-14 09:11 +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 def testHelp(self):
46 @click.command()
47 # Use custom help in the arguments so that any changes to default help
48 # text do not break this test unnecessarily.
49 @repo_argument(help="repo help text")
50 @directory_argument(help="directory help text")
51 def cli():
52 """The cli help message."""
53 pass
55 self.runTest(cli)
57 def testHelpWrapped(self):
58 @click.command()
59 # Use custom help in the arguments so that any changes to default help
60 # text do not break this test unnecessarily.
61 @repo_argument(help="repo help text")
62 @directory_argument(help="directory help text")
63 def cli():
64 """The cli
65 help
66 message."""
67 pass
69 self.runTest(cli)
71 def runTest(self, cli):
72 """Tests `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 expected = """Usage: cli [OPTIONS] REPO DIRECTORY
78 The cli help message.
80 repo help text
82 directory help text
84Options:
85 --help Show this message and exit.
86"""
87 runner = LogCliRunner()
88 result = runner.invoke(cli, ["--help"])
89 self.assertIn(expected, result.output)
92class UnwrapStringTestCase(unittest.TestCase):
93 def test_leadingNewline(self):
94 testStr = """
95 foo bar
96 baz """
97 self.assertEqual(unwrap(testStr), "foo bar baz")
99 def test_leadingContent(self):
100 testStr = """foo bar
101 baz """
102 self.assertEqual(unwrap(testStr), "foo bar baz")
104 def test_trailingNewline(self):
105 testStr = """
106 foo bar
107 baz
108 """
109 self.assertEqual(unwrap(testStr), "foo bar baz")
111 def test_oneLine(self):
112 testStr = """foo bar baz"""
113 self.assertEqual(unwrap(testStr), "foo bar baz")
115 def test_oneLineWithLeading(self):
116 testStr = """
117 foo bar baz"""
118 self.assertEqual(unwrap(testStr), "foo bar baz")
120 def test_oneLineWithTrailing(self):
121 testStr = """foo bar baz
122 """
123 self.assertEqual(unwrap(testStr), "foo bar baz")
125 def test_lineBreaks(self):
126 testStr = """foo bar
127 baz
129 boz
131 qux"""
132 self.assertEqual(unwrap(testStr), "foo bar baz\n\nboz\n\nqux")
135class MWOptionTest(unittest.TestCase):
136 def setUp(self):
137 self.runner = LogCliRunner()
139 def test_addElipsisToMultiple(self):
140 """Verify that MWOption adds elipsis to the option metavar when
141 `multiple=True`
143 The default behavior of click is to not add elipsis to options that
144 have `multiple=True`."""
146 @click.command()
147 @click.option("--things", cls=MWOption, multiple=True)
148 def cmd(things):
149 pass
151 result = self.runner.invoke(cmd, ["--help"])
152 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
153 expectedOutput = """Options:
154 --things TEXT ..."""
155 self.assertIn(expectedOutput, result.output)
157 def test_addElipsisToNargs(self):
158 """Verify that MWOption adds " ..." after the option metavar when
159 `nargs` is set to more than 1 and less than 1.
161 The default behavior of click is to add elipsis when nargs does not
162 equal 1, but it does not put a space before the elipsis and we prefer
163 a space between the metavar and the elipsis."""
164 for numberOfArgs in (0, 1, 2): # nargs must be >= 0 for an option
166 @click.command()
167 @click.option("--things", cls=MWOption, nargs=numberOfArgs)
168 def cmd(things):
169 pass
171 result = self.runner.invoke(cmd, ["--help"])
172 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
173 expectedOutput = f"""Options:
174 --things TEXT{' ...' if numberOfArgs != 1 else ''}"""
175 self.assertIn(expectedOutput, result.output)
178class MWArgumentDecoratorTest(unittest.TestCase):
179 """Tests for the MWArgumentDecorator class."""
181 things_argument = MWArgumentDecorator("things")
182 otherHelpText = "Help text for OTHER."
183 other_argument = MWArgumentDecorator("other", help=otherHelpText)
185 def setUp(self):
186 self.runner = LogCliRunner()
188 def test_help(self):
189 """Verify expected help text output.
191 Verify argument help gets inserted after the usage, in the order
192 arguments are declared.
194 Verify that MWArgument adds " ..." after the option metavar when
195 `nargs` != 1. The default behavior of click is to add elipsis when
196 nargs does not equal 1, but it does not put a space before the elipsis
197 and we prefer a space between the metavar and the elipsis."""
198 # nargs can be -1 for any number of args, or >= 1 for a specified
199 # number of arguments.
201 helpText = "Things help text."
202 for numberOfArgs in (-1, 1, 2):
203 for required in (True, False):
205 @click.command()
206 @self.things_argument(required=required, nargs=numberOfArgs, help=helpText)
207 @self.other_argument()
208 def cmd(things, other):
209 """Cmd help text."""
210 pass
212 result = self.runner.invoke(cmd, ["--help"])
213 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
214 things = "THINGS" if required else "[THINGS]"
215 additional = "... " if numberOfArgs != 1 else ""
216 expectedOutput = f"""Usage: cmd [OPTIONS] {things} {additional}OTHER
218 Cmd help text.
220 {helpText}
222 {self.otherHelpText}
223"""
224 self.assertIn(expectedOutput, result.output)
226 def testUse(self):
227 """Test using the MWArgumentDecorator with a command."""
228 mock = MagicMock()
230 @click.command()
231 @self.things_argument()
232 def cli(things):
233 mock(things)
235 self.runner = click.testing.CliRunner()
236 result = self.runner.invoke(cli, "foo")
237 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
238 mock.assert_called_with("foo")
241class MWOptionDecoratorTest(unittest.TestCase):
242 """Tests for the MWOptionDecorator class."""
244 test_option = MWOptionDecorator("-t", "--test", multiple=True)
246 def testGetName(self):
247 """Test getting the option name from the MWOptionDecorator."""
248 self.assertEqual(self.test_option.name(), "test")
250 def testGetOpts(self):
251 """Test getting the option flags from the MWOptionDecorator."""
252 self.assertEqual(self.test_option.opts(), ["-t", "--test"])
254 def testUse(self):
255 """Test using the MWOptionDecorator with a command."""
256 mock = MagicMock()
258 @click.command()
259 @self.test_option()
260 def cli(test):
261 mock(test)
263 self.runner = click.testing.CliRunner()
264 result = self.runner.invoke(cli, ("-t", "foo"))
265 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
266 mock.assert_called_with(("foo",))
268 def testOverride(self):
269 """Test using the MWOptionDecorator with a command and overriding one
270 of the default values."""
271 mock = MagicMock()
273 @click.command()
274 @self.test_option(multiple=False)
275 def cli(test):
276 mock(test)
278 self.runner = click.testing.CliRunner()
279 result = self.runner.invoke(cli, ("-t", "foo"))
280 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
281 mock.assert_called_with("foo")
284class SectionOptionTest(unittest.TestCase):
285 """Tests for the option_section decorator that inserts section break
286 headings between options in the --help output of a command."""
288 @staticmethod
289 @click.command()
290 @click.option("--foo")
291 @option_section("Section break between metasyntactic variables.")
292 @click.option("--bar")
293 def cli(foo, bar):
294 pass
296 def setUp(self):
297 self.runner = click.testing.CliRunner()
299 def test_section_help(self):
300 """Verify that the section break is printed in the help output in the
301 expected location and with expected formatting."""
302 result = self.runner.invoke(self.cli, ["--help"])
303 # \x20 is a space, added explicitly below to prevent the
304 # normally-helpful editor setting "remove trailing whitespace" from
305 # stripping it out in this case. (The blank line with 2 spaces is an
306 # artifact of how click and our code generate help text.)
307 expected = """Options:
308 --foo TEXT
309\x20\x20
310Section break between metasyntactic variables.
311 --bar TEXT"""
312 self.assertIn(expected, result.output)
314 def test_section_function(self):
315 """Verify that the section does not cause any arguments to be passed to
316 the command function.
318 The command function `cli` implementation inputs `foo` and `bar`, but
319 does accept an argument for the section. When the command is invoked
320 and the function called it should result in exit_code=0 (not 1 with a
321 missing argument error).
322 """
323 result = self.runner.invoke(self.cli, [])
324 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
327class MWPathTest(unittest.TestCase):
328 def getCmd(self, exists):
329 @click.command()
330 @click.option("--name", type=MWPath(exists=exists))
331 def cmd(name):
332 pass
334 return cmd
336 def setUp(self):
337 self.runner = click.testing.CliRunner()
339 def test_exist(self):
340 """Test the exist argument, verify that True means the file must exist,
341 False means the file must not exist, and None means that the file may
342 or may not exist."""
343 with self.runner.isolated_filesystem():
344 mustExistCmd = self.getCmd(exists=True)
345 mayExistCmd = self.getCmd(exists=None)
346 mustNotExistCmd = self.getCmd(exists=False)
347 args = ["--name", "foo.txt"]
349 result = self.runner.invoke(mustExistCmd, args)
350 self.assertNotEqual(result.exit_code, 0, clickResultMsg(result))
351 self.assertRegex(result.output, """['"]foo.txt['"] does not exist.""")
353 result = self.runner.invoke(mayExistCmd, args)
354 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
356 result = self.runner.invoke(mustNotExistCmd, args)
357 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
359 # isolated_filesystem runs in a temporary directory, when it is
360 # removed everything inside will be removed.
361 with open("foo.txt", "w") as _:
362 result = self.runner.invoke(mustExistCmd, args)
363 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
365 result = self.runner.invoke(mayExistCmd, args)
366 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
368 result = self.runner.invoke(mustNotExistCmd, args)
369 self.assertNotEqual(result.exit_code, 0, clickResultMsg(result))
370 self.assertIn('"foo.txt" should not exist.', result.output)
373class MWCommandTest(unittest.TestCase):
374 def setUp(self):
375 self.runner = click.testing.CliRunner()
376 self.ctx = None
378 def testCaptureOptions(self):
379 """Test that command options are captured in order."""
381 @click.command(cls=MWCommand)
382 @click.argument("ARG_A", required=False)
383 @click.argument("ARG_B", required=False)
384 @click.option("-o", "--an-option")
385 @click.option("-s", "--second-option")
386 @click.option("-f", is_flag=True)
387 @click.option("-d/-n", "--do/--do-not", "do_or_do_not")
388 @click.option("--multi", multiple=True)
389 @click.pass_context
390 def cmd(ctx, arg_a, arg_b, an_option, second_option, f, do_or_do_not, multi):
391 self.assertIsNotNone(ctx)
392 self.ctx = ctx
394 # When `expected` is `None`, the expected args are exactly the same as
395 # as the result of `args.split()`. If `expected` is not `None` then
396 # the expected args are the same as `expected.split()`.
397 for args, expected in (
398 ("--an-option foo --second-option bar", None),
399 ("--second-option bar --an-option foo", None),
400 ("--an-option foo", None),
401 ("--second-option bar", None),
402 ("--an-option foo -f --second-option bar", None),
403 ("-o foo -s bar", "--an-option foo --second-option bar"),
404 ("--an-option foo -f -s bar", "--an-option foo -f --second-option bar"),
405 ("--an-option=foo", "--an-option foo"),
406 # NB when using a short flag everything that follows, including an
407 # equals sign, is part of the value!
408 ("-o=foo", "--an-option =foo"),
409 ("--do", None),
410 ("--do-not", None),
411 ("-d", "--do"),
412 ("-n", "--do-not"),
413 # Arguments always come last, but the order of Arguments is still
414 # preserved:
415 ("myarg --an-option foo", "--an-option foo myarg"),
416 ("argA --an-option foo", "--an-option foo argA"),
417 ("argA argB --an-option foo", "--an-option foo argA argB"),
418 ("argA --an-option foo argB", "--an-option foo argA argB"),
419 ("--an-option foo argA argB", "--an-option foo argA argB"),
420 ("--multi one --multi two", None),
421 ):
422 split_args = args.split()
423 expected_args = split_args if expected is None else expected.split()
424 result = self.runner.invoke(cmd, split_args)
425 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
426 self.assertIsNotNone(self.ctx)
427 ctx_obj = MWCtxObj.getFrom(self.ctx)
428 self.assertEqual(ctx_obj.args, expected_args)
431if __name__ == "__main__":
432 unittest.main()