Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 

22import click 

23import click.testing 

24from contextlib import contextmanager 

25import enum 

26import io 

27import os 

28import textwrap 

29import traceback 

30from unittest.mock import MagicMock, patch 

31import yaml 

32 

33from .cliLog import CliLog 

34from ..core.utils import iterable 

35 

36 

37# CLI_MOCK_ENV is set by some tests as an environment variable, it 

38# indicates to the cli_handle_exception function that instead of executing the 

39# command implementation function it should use the Mocker class for unit test 

40# verification. 

41mockEnvVarKey = "CLI_MOCK_ENV" 

42mockEnvVar = {mockEnvVarKey: "1"} 

43 

44# This is used as the metavar argument to Options that accept multiple string 

45# inputs, which may be comma-separarated. For example: 

46# --my-opt foo,bar --my-opt baz. 

47# Other arguments to the Option should include multiple=true and 

48# callback=split_kv. 

49typeStrAcceptsMultiple = "TEXT ..." 

50typeStrAcceptsSingle = "TEXT" 

51 

52 

53def textTypeStr(multiple): 

54 """Get the text type string for CLI help documentation. 

55 

56 Parameters 

57 ---------- 

58 multiple : `bool` 

59 True if multiple text values are allowed, False if only one value is 

60 allowed. 

61 

62 Returns 

63 ------- 

64 textTypeStr : `str` 

65 The type string to use. 

66 """ 

67 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

68 

69 

70# For parameters that support key-value inputs, this defines the separator 

71# for those inputs. 

72split_kv_separator = "=" 

73 

74 

75# The ParameterType enum is used to indicate a click Argument or Option (both 

76# of which are subclasses of click.Parameter). 

77class ParameterType(enum.Enum): 

78 ARGUMENT = 0 

79 OPTION = 1 

80 

81 

82class Mocker: 

83 

84 mock = MagicMock() 

85 

86 def __init__(self, *args, **kwargs): 

87 """Mocker is a helper class for unit tests. It can be imported and 

88 called and later imported again and call can be verified. 

89 

90 For convenience, constructor arguments are forwarded to the call 

91 function. 

92 """ 

93 self.__call__(*args, **kwargs) 

94 

95 def __call__(self, *args, **kwargs): 

96 """Creates a MagicMock and stores it in a static variable that can 

97 later be verified. 

98 """ 

99 Mocker.mock(*args, **kwargs) 

100 

101 

102class LogCliRunner(click.testing.CliRunner): 

103 """A test runner to use when the logging system will be initialized by code 

104 under test, calls CliLog.resetLog(), which undoes any logging setup that 

105 was done with the CliLog interface. 

106 

107 lsst.log modules can not be set back to an uninitialized state (python 

108 logging modules can be set back to NOTSET), instead they are set to 

109 `CliLog.defaultLsstLogLevel`.""" 

110 

111 def invoke(self, *args, **kwargs): 

112 result = super().invoke(*args, **kwargs) 

113 CliLog.resetLog() 

114 return result 

115 

116 

117def clickResultMsg(result): 

118 """Get a standard assert message from a click result 

119 

120 Parameters 

121 ---------- 

122 result : click.Result 

123 The result object returned from click.testing.CliRunner.invoke 

124 

125 Returns 

126 ------- 

127 msg : `str` 

128 The message string. 

129 """ 

130 msg = io.StringIO() 

131 if result.exception: 

132 traceback.print_tb(result.exception.__traceback__, file=msg) 

133 msg.seek(0) 

134 return f"\noutput: {result.output}\nexception: {result.exception}\ntraceback: {msg.read()}" 

135 

136 

137@contextmanager 

138def command_test_env(runner, commandModule, commandName): 

139 """A context manager that creates (and then cleans up) an environment that 

140 provides a CLI plugin command with the given name. 

141 

142 Parameters 

143 ---------- 

144 runner : click.testing.CliRunner 

145 The test runner to use to create the isolated filesystem. 

146 commandModule : `str` 

147 The importable module that the command can be imported from. 

148 commandName : `str` 

149 The name of the command being published to import. 

150 """ 

151 with runner.isolated_filesystem(): 

152 with open("resources.yaml", "w") as f: 

153 f.write(yaml.dump({"cmd": {"import": commandModule, "commands": [commandName]}})) 

154 # Add a colon to the end of the path on the next line, this tests the 

155 # case where the lookup in LoaderCLI._getPluginList generates an empty 

156 # string in one of the list entries and verifies that the empty string 

157 # is properly stripped out. 

158 with patch.dict("os.environ", {"DAF_BUTLER_PLUGINS": f"{os.path.realpath(f.name)}:"}): 

159 yield 

160 

161 

162def addArgumentHelp(doc, helpText): 

163 """Add a Click argument's help message to a function's documentation. 

164 

165 This is needed because click presents arguments in the order the argument 

166 decorators are applied to a function, top down. But, the evaluation of the 

167 decorators happens bottom up, so if arguments just append their help to the 

168 function's docstring, the argument descriptions appear in reverse order 

169 from the order they are applied in. 

170 

171 Parameters 

172 ---------- 

173 doc : `str` 

174 The function's docstring. 

175 helpText : `str` 

176 The argument's help string to be inserted into the function's 

177 docstring. 

178 

179 Returns 

180 ------- 

181 doc : `str` 

182 Updated function documentation. 

183 """ 

184 if doc is None: 

185 doc = helpText 

186 else: 

187 doclines = doc.splitlines() 

188 doclines.insert(1, helpText) 

189 doclines.insert(1, "\n") 

190 doc = "\n".join(doclines) 

191 return doc 

192 

193 

194def split_commas(context, param, values): 

195 """Process a tuple of values, where each value may contain comma-separated 

196 values, and return a single list of all the passed-in values. 

197 

198 This function can be passed to the 'callback' argument of a click.option to 

199 allow it to process comma-separated values (e.g. "--my-opt a,b,c"). 

200 

201 Parameters 

202 ---------- 

203 context : `click.Context` or `None` 

204 The current execution context. Unused, but Click always passes it to 

205 callbacks. 

206 param : `click.core.Option` or `None` 

207 The parameter being handled. Unused, but Click always passes it to 

208 callbacks. 

209 values : [`str`] 

210 All the values passed for this option. Strings may contain commas, 

211 which will be treated as delimiters for separate values. 

212 

213 Returns 

214 ------- 

215 list of string 

216 The passed in values separated by commas and combined into a single 

217 list. 

218 """ 

219 if values is None: 

220 return values 

221 valueList = [] 

222 for value in iterable(values): 

223 valueList.extend(value.split(",")) 

224 return tuple(valueList) 

225 

226 

227def split_kv(context, param, values, choice=None, multiple=True, normalize=False, separator="=", 

228 unseparated_okay=False): 

229 """Process a tuple of values that are key-value pairs separated by a given 

230 separator. Multiple pairs may be comma separated. Return a dictionary of 

231 all the passed-in values. 

232 

233 This function can be passed to the 'callback' argument of a click.option to 

234 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2"). 

235 

236 Parameters 

237 ---------- 

238 context : `click.Context` or `None` 

239 The current execution context. Unused, but Click always passes it to 

240 callbacks. 

241 param : `click.core.Option` or `None` 

242 The parameter being handled. Unused, but Click always passes it to 

243 callbacks. 

244 values : [`str`] 

245 All the values passed for this option. Strings may contain commas, 

246 which will be treated as delimiters for separate values. 

247 choice : `click.Choice`, optional 

248 If provided, verify each value is a valid choice using the provided 

249 `click.Choice` instance. If None, no verification will be done. 

250 multiple : `bool`, optional 

251 If true, the value may contain multiple comma-separated values. 

252 normalize : `bool`, optional 

253 If True and `choice.case_sensitive == False`, normalize the string the 

254 user provided to match the choice's case. 

255 separator : str, optional 

256 The character that separates key-value pairs. May not be a comma or an 

257 empty space (for space separators use Click's default implementation 

258 for tuples; `type=(str, str)`). By default "=". 

259 unseparated_okay : `bool`, optional 

260 If True, allow values that do not have a separator. They will be 

261 returned in the values dict as a tuple of values in the key '', that 

262 is: `values[''] = (unseparated_values, )`. 

263 

264 Returns 

265 ------- 

266 values : `dict` [`str`, `str`] 

267 The passed-in values in dict form. 

268 

269 Raises 

270 ------ 

271 `click.ClickException` 

272 Raised if the separator is not found in an entry, or if duplicate keys 

273 are encountered. 

274 """ 

275 

276 def norm(val): 

277 """If `normalize` is True and `choice` is not `None`, find the value 

278 in the available choices and return the value as spelled in the 

279 choices. 

280 

281 Assumes that val exists in choices; `split_kv` uses the `choice` 

282 instance to verify val is a valid choice. 

283 """ 

284 if normalize and choice is not None: 

285 v = val.casefold() 

286 for opt in choice.choices: 

287 if opt.casefold() == v: 

288 return opt 

289 return val 

290 

291 if separator in (",", " "): 

292 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.") 

293 vals = values # preserve the original argument for error reporting below. 

294 if multiple: 

295 vals = split_commas(context, param, vals) 

296 ret = {} 

297 for val in iterable(vals): 

298 if unseparated_okay and separator not in val: 

299 if choice is not None: 

300 choice(val) # will raise if val is an invalid choice 

301 ret[""] = norm(val) 

302 else: 

303 try: 

304 k, v = val.split(separator) 

305 if choice is not None: 

306 choice(v) # will raise if val is an invalid choice 

307 except ValueError: 

308 raise click.ClickException( 

309 f"Could not parse key-value pair '{val}' using separator '{separator}', " 

310 f"with multiple values {'allowed' if multiple else 'not allowed'}.") 

311 if k in ret: 

312 raise click.ClickException(f"Duplicate entries for '{k}' in '{values}'") 

313 ret[k] = norm(v) 

314 return ret 

315 

316 

317def to_upper(context, param, value): 

318 """Convert a value to upper case. 

319 

320 Parameters 

321 ---------- 

322 context : click.Context 

323 

324 values : string 

325 The value to be converted. 

326 

327 Returns 

328 ------- 

329 string 

330 A copy of the passed-in value, converted to upper case. 

331 """ 

332 return value.upper() 

333 

334 

335def unwrap(val): 

336 """Remove newlines and leading whitespace from a multi-line string with 

337 a consistent indentation level. 

338 

339 The first line of the string may be only a newline or may contain text 

340 followed by a newline, either is ok. After the first line, each line must 

341 begin with a consistant amount of whitespace. So, content of a 

342 triple-quoted string may begin immediately after the quotes, or the string 

343 may start with a newline. Each line after that must be the same amount of 

344 indentation/whitespace followed by text and a newline. The last line may 

345 end with a new line but is not required to do so. 

346 

347 Parameters 

348 ---------- 

349 val : `str` 

350 The string to change. 

351 

352 Returns 

353 ------- 

354 strippedString : `str` 

355 The string with newlines, indentation, and leading and trailing 

356 whitespace removed. 

357 """ 

358 if not val.startswith("\n"): 

359 firstLine, _, val = val.partition("\n") 

360 firstLine += " " 

361 else: 

362 firstLine = "" 

363 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip() 

364 

365 

366def cli_handle_exception(func, *args, **kwargs): 

367 """Wrap a function call in an exception handler that raises a 

368 ClickException if there is an Exception. 

369 

370 Also provides support for unit testing by testing for an environment 

371 variable, and if it is present prints the function name, args, and kwargs 

372 to stdout so they can be read and verified by the unit test code. 

373 

374 Parameters 

375 ---------- 

376 func : function 

377 A function to be called and exceptions handled. Will pass args & kwargs 

378 to the function. 

379 

380 Returns 

381 ------- 

382 The result of calling func. 

383 

384 Raises 

385 ------ 

386 click.ClickException 

387 An exception to be handled by the Click CLI tool. 

388 """ 

389 if mockEnvVarKey in os.environ: 

390 Mocker(*args, **kwargs) 

391 return 

392 try: 

393 return func(*args, **kwargs) 

394 except Exception: 

395 msg = io.StringIO() 

396 msg.write("An error occurred during command execution:\n") 

397 traceback.print_exc(file=msg) 

398 raise click.ClickException(msg.getvalue()) 

399 

400 

401class MWOption(click.Option): 

402 """Overrides click.Option with desired behaviors.""" 

403 

404 def make_metavar(self): 

405 """Overrides `click.Option.make_metavar`. Makes the metavar for the 

406 help menu. Adds a space and an elipsis after the metavar name if 

407 the option accepts multiple inputs, otherwise defers to the base 

408 implementation. 

409 

410 By default click does not add an elipsis when multiple is True and 

411 nargs is 1. And when nargs does not equal 1 click adds an elipsis 

412 without a space between the metavar and the elipsis, but we prefer a 

413 space between. 

414 """ 

415 metavar = super().make_metavar() 

416 if self.multiple and self.nargs == 1: 

417 metavar += " ..." 

418 elif self.nargs != 1: 

419 metavar = f"{metavar[:-3]} ..." 

420 return metavar 

421 

422 

423class MWArgument(click.Argument): 

424 """Overrides click.Argument with desired behaviors.""" 

425 

426 def make_metavar(self): 

427 """Overrides `click.Option.make_metavar`. Makes the metavar for the 

428 help menu. Always adds a space and an elipsis (' ...') after the 

429 metavar name if the option accepts multiple inputs. 

430 

431 By default click adds an elipsis without a space between the metavar 

432 and the elipsis, but we prefer a space between. 

433 

434 Returns 

435 ------- 

436 metavar : `str` 

437 The metavar value. 

438 """ 

439 metavar = super().make_metavar() 

440 if self.nargs != 1: 

441 metavar = f"{metavar[:-3]} ..." 

442 return metavar