Coverage for tests/test_cliUtils.py: 21%

199 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-14 19:21 +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/>. 

21 

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

23""" 

24 

25import unittest 

26from unittest.mock import MagicMock 

27 

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) 

42 

43 

44class ArgumentHelpGeneratorTestCase(unittest.TestCase): 

45 """Test the help system.""" 

46 

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 

56 

57 self.runTest(cli) 

58 

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 

68 

69 self.runTest(cli) 

70 

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 

78 

79 The cli help message. 

80 

81 repo help text 

82 

83 directory help text 

84 

85Options: 

86 --help Show this message and exit. 

87""" 

88 runner = LogCliRunner() 

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

90 self.assertIn(expected, result.output) 

91 

92 

93class UnwrapStringTestCase(unittest.TestCase): 

94 """Test string unwrapping.""" 

95 

96 def test_leadingNewline(self): 

97 testStr = """ 

98 foo bar 

99 baz """ 

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

101 

102 def test_leadingContent(self): 

103 testStr = """foo bar 

104 baz """ 

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

106 

107 def test_trailingNewline(self): 

108 testStr = """ 

109 foo bar 

110 baz 

111 """ 

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

113 

114 def test_oneLine(self): 

115 testStr = """foo bar baz""" 

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

117 

118 def test_oneLineWithLeading(self): 

119 testStr = """ 

120 foo bar baz""" 

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

122 

123 def test_oneLineWithTrailing(self): 

124 testStr = """foo bar baz 

125 """ 

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

127 

128 def test_lineBreaks(self): 

129 testStr = """foo bar 

130 baz 

131 

132 boz 

133 

134 qux""" 

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

136 

137 

138class MWOptionTest(unittest.TestCase): 

139 """Test MWOption.""" 

140 

141 def setUp(self): 

142 self.runner = LogCliRunner() 

143 

144 def test_addEllipsisToMultiple(self): 

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

146 `multiple=True` 

147 

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

149 have `multiple=True`. 

150 """ 

151 

152 @click.command() 

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

154 def cmd(things): 

155 pass 

156 

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) 

162 

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. 

166 

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 

172 

173 @click.command() 

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

175 def cmd(things): 

176 pass 

177 

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) 

183 

184 

185class MWArgumentDecoratorTest(unittest.TestCase): 

186 """Tests for the MWArgumentDecorator class.""" 

187 

188 things_argument = MWArgumentDecorator("things") 

189 otherHelpText = "Help text for OTHER." 

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

191 

192 def setUp(self): 

193 self.runner = LogCliRunner() 

194 

195 def test_help(self): 

196 """Verify expected help text output. 

197 

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

199 arguments are declared. 

200 

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. 

208 

209 helpText = "Things help text." 

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

211 for required in (True, False): 

212 

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 

219 

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 

225 

226 Cmd help text. 

227 

228 {helpText} 

229 

230 {self.otherHelpText} 

231""" 

232 self.assertIn(expectedOutput, result.output) 

233 

234 def testUse(self): 

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

236 mock = MagicMock() 

237 

238 @click.command() 

239 @self.things_argument() 

240 def cli(things): 

241 mock(things) 

242 

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") 

247 

248 

249class MWOptionDecoratorTest(unittest.TestCase): 

250 """Tests for the MWOptionDecorator class.""" 

251 

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

253 

254 def testGetName(self): 

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

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

257 

258 def testGetOpts(self): 

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

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

261 

262 def testUse(self): 

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

264 mock = MagicMock() 

265 

266 @click.command() 

267 @self._test_option() 

268 def cli(test): 

269 mock(test) 

270 

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",)) 

275 

276 def testOverride(self): 

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

278 of the default values. 

279 """ 

280 mock = MagicMock() 

281 

282 @click.command() 

283 @self._test_option(multiple=False) 

284 def cli(test): 

285 mock(test) 

286 

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") 

291 

292 

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 """ 

297 

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 

305 

306 def setUp(self): 

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

308 

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) 

324 

325 def test_section_function(self): 

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

327 the command function. 

328 

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)) 

336 

337 

338class MWPathTest(unittest.TestCase): 

339 """Test MWPath.""" 

340 

341 def getCmd(self, exists): 

342 @click.command() 

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

344 def cmd(name): 

345 pass 

346 

347 return cmd 

348 

349 def setUp(self): 

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

351 

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"] 

362 

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.""") 

366 

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

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

369 

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

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

372 

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)) 

378 

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

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

381 

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) 

385 

386 

387class MWCommandTest(unittest.TestCase): 

388 """Test MWCommand.""" 

389 

390 def setUp(self): 

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

392 self.ctx = None 

393 

394 def testCaptureOptions(self): 

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

396 

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 

409 

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) 

445 

446 

447if __name__ == "__main__": 

448 unittest.main()