Coverage for tests/test_cliUtils.py: 21%

199 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-04 02:55 -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 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/>. 

27 

28"""Unit tests for the daf_butler shared CLI options. 

29""" 

30 

31import unittest 

32from unittest.mock import MagicMock 

33 

34import click 

35from lsst.daf.butler.cli.opt import directory_argument, repo_argument 

36from lsst.daf.butler.cli.utils import ( 

37 LogCliRunner, 

38 MWArgumentDecorator, 

39 MWCommand, 

40 MWCtxObj, 

41 MWOption, 

42 MWOptionDecorator, 

43 MWPath, 

44 clickResultMsg, 

45 option_section, 

46 unwrap, 

47) 

48 

49 

50class ArgumentHelpGeneratorTestCase(unittest.TestCase): 

51 """Test the help system.""" 

52 

53 def testHelp(self): 

54 @click.command() 

55 # Use custom help in the arguments so that any changes to default help 

56 # text do not break this test unnecessarily. 

57 @repo_argument(help="repo help text") 

58 @directory_argument(help="directory help text") 

59 def cli(): 

60 """The cli help message.""" # noqa: D401 

61 pass 

62 

63 self.runTest(cli) 

64 

65 def testHelpWrapped(self): 

66 @click.command() 

67 # Use custom help in the arguments so that any changes to default help 

68 # text do not break this test unnecessarily. 

69 @repo_argument(help="repo help text") 

70 @directory_argument(help="directory help text") 

71 def cli(): 

72 """The cli help message.""" # noqa: D401 

73 pass 

74 

75 self.runTest(cli) 

76 

77 def runTest(self, cli): 

78 """Test `utils.addArgumentHelp` and its use in repo_argument and 

79 directory_argument; verifies that the argument help gets added to the 

80 command function help, and that it's added in the correct order. See 

81 addArgumentHelp for more details. 

82 """ 

83 expected = """Usage: cli [OPTIONS] REPO DIRECTORY 

84 

85 The cli help message. 

86 

87 repo help text 

88 

89 directory help text 

90 

91Options: 

92 --help Show this message and exit. 

93""" 

94 runner = LogCliRunner() 

95 result = runner.invoke(cli, ["--help"]) 

96 self.assertIn(expected, result.output) 

97 

98 

99class UnwrapStringTestCase(unittest.TestCase): 

100 """Test string unwrapping.""" 

101 

102 def test_leadingNewline(self): 

103 testStr = """ 

104 foo bar 

105 baz """ 

106 self.assertEqual(unwrap(testStr), "foo bar baz") 

107 

108 def test_leadingContent(self): 

109 testStr = """foo bar 

110 baz """ 

111 self.assertEqual(unwrap(testStr), "foo bar baz") 

112 

113 def test_trailingNewline(self): 

114 testStr = """ 

115 foo bar 

116 baz 

117 """ 

118 self.assertEqual(unwrap(testStr), "foo bar baz") 

119 

120 def test_oneLine(self): 

121 testStr = """foo bar baz""" 

122 self.assertEqual(unwrap(testStr), "foo bar baz") 

123 

124 def test_oneLineWithLeading(self): 

125 testStr = """ 

126 foo bar baz""" 

127 self.assertEqual(unwrap(testStr), "foo bar baz") 

128 

129 def test_oneLineWithTrailing(self): 

130 testStr = """foo bar baz 

131 """ 

132 self.assertEqual(unwrap(testStr), "foo bar baz") 

133 

134 def test_lineBreaks(self): 

135 testStr = """foo bar 

136 baz 

137 

138 boz 

139 

140 qux""" 

141 self.assertEqual(unwrap(testStr), "foo bar baz\n\nboz\n\nqux") 

142 

143 

144class MWOptionTest(unittest.TestCase): 

145 """Test MWOption.""" 

146 

147 def setUp(self): 

148 self.runner = LogCliRunner() 

149 

150 def test_addEllipsisToMultiple(self): 

151 """Verify that MWOption adds ellipsis to the option metavar when 

152 `multiple=True` 

153 

154 The default behavior of click is to not add ellipsis to options that 

155 have `multiple=True`. 

156 """ 

157 

158 @click.command() 

159 @click.option("--things", cls=MWOption, multiple=True) 

160 def cmd(things): 

161 pass 

162 

163 result = self.runner.invoke(cmd, ["--help"]) 

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

165 expectedOutput = """Options: 

166 --things TEXT ...""" 

167 self.assertIn(expectedOutput, result.output) 

168 

169 def test_addEllipsisToNargs(self): 

170 """Verify that MWOption adds " ..." after the option metavar when 

171 `nargs` is set to more than 1 and less than 1. 

172 

173 The default behavior of click is to add ellipsis when nargs does not 

174 equal 1, but it does not put a space before the ellipsis and we prefer 

175 a space between the metavar and the ellipsis. 

176 """ 

177 for numberOfArgs in (0, 1, 2): # nargs must be >= 0 for an option 

178 

179 @click.command() 

180 @click.option("--things", cls=MWOption, nargs=numberOfArgs) 

181 def cmd(things): 

182 pass 

183 

184 result = self.runner.invoke(cmd, ["--help"]) 

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

186 expectedOutput = f"""Options: 

187 --things TEXT{' ...' if numberOfArgs != 1 else ''}""" 

188 self.assertIn(expectedOutput, result.output) 

189 

190 

191class MWArgumentDecoratorTest(unittest.TestCase): 

192 """Tests for the MWArgumentDecorator class.""" 

193 

194 things_argument = MWArgumentDecorator("things") 

195 otherHelpText = "Help text for OTHER." 

196 other_argument = MWArgumentDecorator("other", help=otherHelpText) 

197 

198 def setUp(self): 

199 self.runner = LogCliRunner() 

200 

201 def test_help(self): 

202 """Verify expected help text output. 

203 

204 Verify argument help gets inserted after the usage, in the order 

205 arguments are declared. 

206 

207 Verify that MWArgument adds " ..." after the option metavar when 

208 `nargs` != 1. The default behavior of click is to add ellipsis when 

209 nargs does not equal 1, but it does not put a space before the ellipsis 

210 and we prefer a space between the metavar and the ellipsis. 

211 """ 

212 # nargs can be -1 for any number of args, or >= 1 for a specified 

213 # number of arguments. 

214 

215 helpText = "Things help text." 

216 for numberOfArgs in (-1, 1, 2): 

217 for required in (True, False): 

218 

219 @click.command() 

220 @self.things_argument(required=required, nargs=numberOfArgs, help=helpText) 

221 @self.other_argument() 

222 def cmd(things, other): 

223 """Cmd help text.""" 

224 pass 

225 

226 result = self.runner.invoke(cmd, ["--help"]) 

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

228 things = "THINGS" if required else "[THINGS]" 

229 additional = "... " if numberOfArgs != 1 else "" 

230 expectedOutput = f"""Usage: cmd [OPTIONS] {things} {additional}OTHER 

231 

232 Cmd help text. 

233 

234 {helpText} 

235 

236 {self.otherHelpText} 

237""" 

238 self.assertIn(expectedOutput, result.output) 

239 

240 def testUse(self): 

241 """Test using the MWArgumentDecorator with a command.""" 

242 mock = MagicMock() 

243 

244 @click.command() 

245 @self.things_argument() 

246 def cli(things): 

247 mock(things) 

248 

249 self.runner = click.testing.CliRunner() 

250 result = self.runner.invoke(cli, "foo") 

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

252 mock.assert_called_with("foo") 

253 

254 

255class MWOptionDecoratorTest(unittest.TestCase): 

256 """Tests for the MWOptionDecorator class.""" 

257 

258 _test_option = MWOptionDecorator("-t", "--test", multiple=True) 

259 

260 def testGetName(self): 

261 """Test getting the option name from the MWOptionDecorator.""" 

262 self.assertEqual(self._test_option.name(), "test") 

263 

264 def testGetOpts(self): 

265 """Test getting the option flags from the MWOptionDecorator.""" 

266 self.assertEqual(self._test_option.opts(), ["-t", "--test"]) 

267 

268 def testUse(self): 

269 """Test using the MWOptionDecorator with a command.""" 

270 mock = MagicMock() 

271 

272 @click.command() 

273 @self._test_option() 

274 def cli(test): 

275 mock(test) 

276 

277 self.runner = click.testing.CliRunner() 

278 result = self.runner.invoke(cli, ("-t", "foo")) 

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

280 mock.assert_called_with(("foo",)) 

281 

282 def testOverride(self): 

283 """Test using the MWOptionDecorator with a command and overriding one 

284 of the default values. 

285 """ 

286 mock = MagicMock() 

287 

288 @click.command() 

289 @self._test_option(multiple=False) 

290 def cli(test): 

291 mock(test) 

292 

293 self.runner = click.testing.CliRunner() 

294 result = self.runner.invoke(cli, ("-t", "foo")) 

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

296 mock.assert_called_with("foo") 

297 

298 

299class SectionOptionTest(unittest.TestCase): 

300 """Tests for the option_section decorator that inserts section break 

301 headings between options in the --help output of a command. 

302 """ 

303 

304 @staticmethod 

305 @click.command() 

306 @click.option("--foo") 

307 @option_section("Section break between metasyntactic variables.") 

308 @click.option("--bar") 

309 def cli(foo, bar): 

310 pass 

311 

312 def setUp(self): 

313 self.runner = click.testing.CliRunner() 

314 

315 def test_section_help(self): 

316 """Verify that the section break is printed in the help output in the 

317 expected location and with expected formatting. 

318 """ 

319 result = self.runner.invoke(self.cli, ["--help"]) 

320 # \x20 is a space, added explicitly below to prevent the 

321 # normally-helpful editor setting "remove trailing whitespace" from 

322 # stripping it out in this case. (The blank line with 2 spaces is an 

323 # artifact of how click and our code generate help text.) 

324 expected = """Options: 

325 --foo TEXT 

326\x20\x20 

327Section break between metasyntactic variables. 

328 --bar TEXT""" 

329 self.assertIn(expected, result.output) 

330 

331 def test_section_function(self): 

332 """Verify that the section does not cause any arguments to be passed to 

333 the command function. 

334 

335 The command function `cli` implementation inputs `foo` and `bar`, but 

336 does accept an argument for the section. When the command is invoked 

337 and the function called it should result in exit_code=0 (not 1 with a 

338 missing argument error). 

339 """ 

340 result = self.runner.invoke(self.cli, []) 

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

342 

343 

344class MWPathTest(unittest.TestCase): 

345 """Test MWPath.""" 

346 

347 def getCmd(self, exists): 

348 @click.command() 

349 @click.option("--name", type=MWPath(exists=exists)) 

350 def cmd(name): 

351 pass 

352 

353 return cmd 

354 

355 def setUp(self): 

356 self.runner = click.testing.CliRunner() 

357 

358 def test_exist(self): 

359 """Test the exist argument, verify that True means the file must exist, 

360 False means the file must not exist, and None means that the file may 

361 or may not exist. 

362 """ 

363 with self.runner.isolated_filesystem(): 

364 mustExistCmd = self.getCmd(exists=True) 

365 mayExistCmd = self.getCmd(exists=None) 

366 mustNotExistCmd = self.getCmd(exists=False) 

367 args = ["--name", "foo.txt"] 

368 

369 result = self.runner.invoke(mustExistCmd, args) 

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

371 self.assertRegex(result.output, """['"]foo.txt['"] does not exist.""") 

372 

373 result = self.runner.invoke(mayExistCmd, args) 

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

375 

376 result = self.runner.invoke(mustNotExistCmd, args) 

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

378 

379 # isolated_filesystem runs in a temporary directory, when it is 

380 # removed everything inside will be removed. 

381 with open("foo.txt", "w") as _: 

382 result = self.runner.invoke(mustExistCmd, args) 

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

384 

385 result = self.runner.invoke(mayExistCmd, args) 

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

387 

388 result = self.runner.invoke(mustNotExistCmd, args) 

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

390 self.assertIn('"foo.txt" should not exist.', result.output) 

391 

392 

393class MWCommandTest(unittest.TestCase): 

394 """Test MWCommand.""" 

395 

396 def setUp(self): 

397 self.runner = click.testing.CliRunner() 

398 self.ctx = None 

399 

400 def testCaptureOptions(self): 

401 """Test that command options are captured in order.""" 

402 

403 @click.command(cls=MWCommand) 

404 @click.argument("ARG_A", required=False) 

405 @click.argument("ARG_B", required=False) 

406 @click.option("-o", "--an-option") 

407 @click.option("-s", "--second-option") 

408 @click.option("-f", is_flag=True) 

409 @click.option("-d/-n", "--do/--do-not", "do_or_do_not") 

410 @click.option("--multi", multiple=True) 

411 @click.pass_context 

412 def cmd(ctx, arg_a, arg_b, an_option, second_option, f, do_or_do_not, multi): 

413 self.assertIsNotNone(ctx) 

414 self.ctx = ctx 

415 

416 # When `expected` is `None`, the expected args are exactly the same as 

417 # as the result of `args.split()`. If `expected` is not `None` then 

418 # the expected args are the same as `expected.split()`. 

419 for args, expected in ( 

420 ("--an-option foo --second-option bar", None), 

421 ("--second-option bar --an-option foo", None), 

422 ("--an-option foo", None), 

423 ("--second-option bar", None), 

424 ("--an-option foo -f --second-option bar", None), 

425 ("-o foo -s bar", "--an-option foo --second-option bar"), 

426 ("--an-option foo -f -s bar", "--an-option foo -f --second-option bar"), 

427 ("--an-option=foo", "--an-option foo"), 

428 # NB when using a short flag everything that follows, including an 

429 # equals sign, is part of the value! 

430 ("-o=foo", "--an-option =foo"), 

431 ("--do", None), 

432 ("--do-not", None), 

433 ("-d", "--do"), 

434 ("-n", "--do-not"), 

435 # Arguments always come last, but the order of Arguments is still 

436 # preserved: 

437 ("myarg --an-option foo", "--an-option foo myarg"), 

438 ("argA --an-option foo", "--an-option foo argA"), 

439 ("argA argB --an-option foo", "--an-option foo argA argB"), 

440 ("argA --an-option foo argB", "--an-option foo argA argB"), 

441 ("--an-option foo argA argB", "--an-option foo argA argB"), 

442 ("--multi one --multi two", None), 

443 ): 

444 split_args = args.split() 

445 expected_args = split_args if expected is None else expected.split() 

446 result = self.runner.invoke(cmd, split_args) 

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

448 self.assertIsNotNone(self.ctx) 

449 ctx_obj = MWCtxObj.getFrom(self.ctx) 

450 self.assertEqual(ctx_obj.args, expected_args) 

451 

452 

453if __name__ == "__main__": 

454 unittest.main()