Coverage for python/lsst/daf/butler/cli/utils.py: 42%

293 statements  

« prev     ^ index     » next       coverage.py v6.4, created at 2022-05-24 02:27 -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 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 copy 

23import itertools 

24import logging 

25import os 

26import sys 

27import textwrap 

28import traceback 

29import uuid 

30from contextlib import contextmanager 

31from functools import partial, wraps 

32from unittest.mock import patch 

33 

34import click 

35import click.exceptions 

36import click.testing 

37import yaml 

38from lsst.utils.iteration import ensure_iterable 

39 

40from ..core.config import Config 

41from .cliLog import CliLog 

42 

43log = logging.getLogger(__name__) 

44 

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

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

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

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

49# callback=split_kv. 

50typeStrAcceptsMultiple = "TEXT ..." 

51typeStrAcceptsSingle = "TEXT" 

52 

53# The standard help string for the --where option when it takes a WHERE clause. 

54where_help = ( 

55 "A string expression similar to a SQL WHERE clause. May involve any column of a " 

56 "dimension table or a dimension name as a shortcut for the primary key column of a " 

57 "dimension table." 

58) 

59 

60 

61def astropyTablesToStr(tables): 

62 """Render astropy tables to string as they are displayed in the CLI. 

63 

64 Output formatting matches ``printAstropyTables``. 

65 """ 

66 ret = "" 

67 for table in tables: 

68 ret += "\n" 

69 table.pformat_all() 

70 ret += "\n" 

71 return ret 

72 

73 

74def printAstropyTables(tables): 

75 """Print astropy tables to be displayed in the CLI. 

76 

77 Output formatting matches ``astropyTablesToStr``. 

78 """ 

79 for table in tables: 

80 print("") 

81 table.pprint_all() 

82 print("") 

83 

84 

85def textTypeStr(multiple): 

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

87 

88 Parameters 

89 ---------- 

90 multiple : `bool` 

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

92 allowed. 

93 

94 Returns 

95 ------- 

96 textTypeStr : `str` 

97 The type string to use. 

98 """ 

99 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

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 = f"""\noutput: {result.output}\nexception: {result.exception}""" 

131 if result.exception: 

132 msg += f"""\ntraceback: {"".join(traceback.format_tb(result.exception.__traceback__))}""" 

133 return msg 

134 

135 

136@contextmanager 

137def command_test_env(runner, commandModule, commandName): 

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

139 provides a CLI plugin command with the given name. 

140 

141 Parameters 

142 ---------- 

143 runner : click.testing.CliRunner 

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

145 commandModule : `str` 

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

147 commandName : `str` 

148 The name of the command being published to import. 

149 """ 

150 with runner.isolated_filesystem(): 

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

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

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

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

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

156 # is properly stripped out. 

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

158 yield 

159 

160 

161def addArgumentHelp(doc, helpText): 

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

163 

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

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

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

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

168 from the order they are applied in. 

169 

170 Parameters 

171 ---------- 

172 doc : `str` 

173 The function's docstring. 

174 helpText : `str` 

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

176 docstring. 

177 

178 Returns 

179 ------- 

180 doc : `str` 

181 Updated function documentation. 

182 """ 

183 if doc is None: 183 ↛ 184line 183 didn't jump to line 184, because the condition on line 183 was never true

184 doc = helpText 

185 else: 

186 # See click documentation for details: 

187 # https://click.palletsprojects.com/en/7.x/documentation/#truncating-help-texts 

188 # In short, text for the click command help can be truncated by putting 

189 # "\f" in the docstring, everything after it should be removed 

190 if "\f" in doc: 190 ↛ 191line 190 didn't jump to line 191, because the condition on line 190 was never true

191 doc = doc.split("\f")[0] 

192 

193 doclines = doc.splitlines() 

194 # The function's docstring may span multiple lines, so combine the 

195 # docstring from all the first lines until a blank line is encountered. 

196 # (Lines after the first blank line will be argument help.) 

197 while len(doclines) > 1 and doclines[1]: 

198 doclines[0] = " ".join((doclines[0], doclines.pop(1).strip())) 

199 # Add standard indent to help text for proper alignment with command 

200 # function documentation: 

201 helpText = " " + helpText 

202 doclines.insert(1, helpText) 

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

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

205 return doc 

206 

207 

208def split_commas(context, param, values): 

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

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

211 

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

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

214 

215 Parameters 

216 ---------- 

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

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

219 callbacks. 

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

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

222 callbacks. 

223 values : [`str`] 

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

225 which will be treated as delimiters for separate values. 

226 

227 Returns 

228 ------- 

229 list of string 

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

231 list. 

232 """ 

233 if values is None: 

234 return values 

235 valueList = [] 

236 for value in ensure_iterable(values): 

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

238 return tuple(valueList) 

239 

240 

241def split_kv( 

242 context, 

243 param, 

244 values, 

245 choice=None, 

246 multiple=True, 

247 normalize=False, 

248 separator="=", 

249 unseparated_okay=False, 

250 return_type=dict, 

251 default_key="", 

252 reverse_kv=False, 

253 add_to_default=False, 

254): 

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

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

257 all the passed-in values. 

258 

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

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

261 

262 Parameters 

263 ---------- 

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

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

266 callbacks. 

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

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

269 callbacks. 

270 values : [`str`] 

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

272 which will be treated as delimiters for separate values. 

273 choice : `click.Choice`, optional 

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

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

276 default None 

277 multiple : `bool`, optional 

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

279 default True. 

280 normalize : `bool`, optional 

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

282 user provided to match the choice's case. By default False. 

283 separator : str, optional 

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

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

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

287 unseparated_okay : `bool`, optional 

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

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

290 is: `values[''] = (unseparated_values, )`. By default False. 

291 return_type : `type`, must be `dict` or `tuple` 

292 The type of the value that should be returned. 

293 If `dict` then the returned object will be a dict, for each item in 

294 values, the value to the left of the separator will be the key and the 

295 value to the right of the separator will be the value. 

296 If `tuple` then the returned object will be a tuple. Each item in the 

297 tuple will be 2-item tuple, the first item will be the value to the 

298 left of the separator and the second item will be the value to the 

299 right. By default `dict`. 

300 default_key : `Any` 

301 The key to use if a value is passed that is not a key-value pair. 

302 (Passing values that are not key-value pairs requires 

303 ``unseparated_okay`` to be `True`.) 

304 reverse_kv : bool 

305 If true then for each item in values, the value to the left of the 

306 separator is treated as the value and the value to the right of the 

307 separator is treated as the key. By default False. 

308 add_to_default : `bool`, optional 

309 If True, then passed-in values will not overwrite the default value 

310 unless the ``return_type`` is `dict` and passed-in value(s) have the 

311 same key(s) as the default value. 

312 

313 Returns 

314 ------- 

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

316 The passed-in values in dict form. 

317 

318 Raises 

319 ------ 

320 `click.ClickException` 

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

322 are encountered. 

323 """ 

324 

325 def norm(val): 

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

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

328 choices. 

329 

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

331 instance to verify val is a valid choice. 

332 """ 

333 if normalize and choice is not None: 

334 v = val.casefold() 

335 for opt in choice.choices: 

336 if opt.casefold() == v: 

337 return opt 

338 return val 

339 

340 class RetDict: 

341 def __init__(self): 

342 self.ret = {} 

343 

344 def add(self, key, val): 

345 if reverse_kv: 

346 key, val = val, key 

347 self.ret[key] = val 

348 

349 def get(self): 

350 return self.ret 

351 

352 class RetTuple: 

353 def __init__(self): 

354 self.ret = [] 

355 

356 def add(self, key, val): 

357 if reverse_kv: 

358 key, val = val, key 

359 self.ret.append((key, val)) 

360 

361 def get(self): 

362 return tuple(self.ret) 

363 

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

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

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

367 

368 if add_to_default: 

369 default = param.get_default(context) 

370 if default: 

371 vals = itertools.chain(default, vals) 

372 

373 if return_type is dict: 

374 ret = RetDict() 

375 elif return_type is tuple: 

376 ret = RetTuple() 

377 else: 

378 raise click.ClickException( 

379 message=f"Internal error: invalid return type '{return_type}' for split_kv." 

380 ) 

381 if multiple: 

382 vals = split_commas(context, param, vals) 

383 for val in ensure_iterable(vals): 

384 if unseparated_okay and separator not in val: 

385 if choice is not None: 

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

387 ret.add(default_key, norm(val)) 

388 else: 

389 try: 

390 k, v = val.split(separator) 

391 if choice is not None: 

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

393 except ValueError: 

394 raise click.ClickException( 

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

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

397 ) 

398 ret.add(k, norm(v)) 

399 return ret.get() 

400 

401 

402def to_upper(context, param, value): 

403 """Convert a value to upper case. 

404 

405 Parameters 

406 ---------- 

407 context : click.Context 

408 

409 values : string 

410 The value to be converted. 

411 

412 Returns 

413 ------- 

414 string 

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

416 """ 

417 return value.upper() 

418 

419 

420def unwrap(val): 

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

422 a consistent indentation level. 

423 

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

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

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

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

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

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

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

431 

432 Parameters 

433 ---------- 

434 val : `str` 

435 The string to change. 

436 

437 Returns 

438 ------- 

439 strippedString : `str` 

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

441 whitespace removed. 

442 """ 

443 

444 def splitSection(val): 

445 if not val.startswith("\n"): 445 ↛ 449line 445 didn't jump to line 449, because the condition on line 445 was never false

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

447 firstLine += " " 

448 else: 

449 firstLine = "" 

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

451 

452 return "\n\n".join([splitSection(s) for s in val.split("\n\n")]) 

453 

454 

455class option_section: # noqa: N801 

456 """Decorator to add a section label between options in the help text of a 

457 command. 

458 

459 Parameters 

460 ---------- 

461 sectionText : `str` 

462 The text to print in the section identifier. 

463 """ 

464 

465 def __init__(self, sectionText): 

466 self.sectionText = "\n" + sectionText 

467 

468 def __call__(self, f): 

469 # Generate a parameter declaration that will be unique for this 

470 # section. 

471 return click.option( 

472 f"--option-section-{str(uuid.uuid4())}", sectionText=self.sectionText, cls=OptionSection 

473 )(f) 

474 

475 

476class MWPath(click.Path): 

477 """Overrides click.Path to implement file-does-not-exist checking. 

478 

479 Changes the definition of ``exists` so that `True` indicates the location 

480 (file or directory) must exist, `False` indicates the location must *not* 

481 exist, and `None` indicates that the file may exist or not. The standard 

482 definition for the `click.Path` ``exists`` parameter is that for `True` a 

483 location must exist, but `False` means it is not required to exist (not 

484 that it is required to not exist). 

485 

486 Parameters 

487 ---------- 

488 exists : `True`, `False`, or `None` 

489 If `True`, the location (file or directory) indicated by the caller 

490 must exist. If `False` the location must not exist. If `None`, the 

491 location may exist or not. 

492 

493 For other parameters see `click.Path`. 

494 """ 

495 

496 def __init__( 

497 self, 

498 exists=None, 

499 file_okay=True, 

500 dir_okay=True, 

501 writable=False, 

502 readable=True, 

503 resolve_path=False, 

504 allow_dash=False, 

505 path_type=None, 

506 ): 

507 self.mustNotExist = exists is False 

508 if exists is None: 508 ↛ 510line 508 didn't jump to line 510, because the condition on line 508 was never false

509 exists = False 

510 super().__init__( 

511 exists=exists, 

512 file_okay=file_okay, 

513 dir_okay=dir_okay, 

514 writable=writable, 

515 readable=readable, 

516 resolve_path=resolve_path, 

517 allow_dash=allow_dash, 

518 path_type=path_type, 

519 ) 

520 

521 def convert(self, value, param, ctx): 

522 """Called by click.ParamType to "convert values through types". 

523 `click.Path` uses this step to verify Path conditions.""" 

524 if self.mustNotExist and os.path.exists(value): 

525 self.fail(f'Path "{value}" should not exist.') 

526 return super().convert(value, param, ctx) 

527 

528 

529class MWOption(click.Option): 

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

531 

532 def make_metavar(self): 

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

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

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

536 implementation. 

537 

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

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

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

541 space between. 

542 

543 Does not get called for some option types (e.g. flag) so metavar 

544 transformation that must apply to all types should be applied in 

545 get_help_record. 

546 """ 

547 metavar = super().make_metavar() 

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

549 metavar += " ..." 

550 elif self.nargs != 1: 

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

552 return metavar 

553 

554 

555class MWArgument(click.Argument): 

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

557 

558 def make_metavar(self): 

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

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

561 metavar name if the option accepts multiple inputs. 

562 

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

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

565 

566 Returns 

567 ------- 

568 metavar : `str` 

569 The metavar value. 

570 """ 

571 metavar = super().make_metavar() 

572 if self.nargs != 1: 

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

574 return metavar 

575 

576 

577class OptionSection(MWOption): 

578 """Implements an Option that prints a section label in the help text and 

579 does not pass any value to the command function. 

580 

581 This class does a bit of hackery to add a section label to a click command 

582 help output: first, `expose_value` is set to `False` so that no value is 

583 passed to the command function. Second, this class overrides 

584 `click.Option.get_help_record` to return the section label string without 

585 any prefix so that it stands out as a section label. 

586 

587 This class overrides the hidden attribute because our documentation build 

588 tool, sphinx-click, implements its own `get_help_record` function which 

589 builds the record from other option values (e.g. `name`, `opts`), which 

590 breaks the hack we use to make `get_help_record` only return the 

591 `sectionText`. Fortunately, Click gets the value of `hidden` inside the 

592 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before 

593 entering its `_get_help_record` function. So, making the hidden property 

594 return True hides this option from sphinx-click, while allowing the section 

595 text to be returned by our `get_help_record` method when using Click. 

596 

597 The intention for this implementation is to do minimally invasive overrides 

598 of the click classes so as to be robust and easy to fix if the click 

599 internals change. 

600 

601 Parameters 

602 ---------- 

603 sectionName : `str` 

604 The parameter declaration for this option. It is not shown to the user, 

605 it must be unique within the command. If using the `section` decorator 

606 to add a section to a command's options, the section name is 

607 auto-generated. 

608 sectionText : `str` 

609 The text to print in the section identifier. 

610 """ 

611 

612 @property 

613 def hidden(self): 

614 return True 

615 

616 @hidden.setter 

617 def hidden(self, val): 

618 pass 

619 

620 def __init__(self, sectionName, sectionText): 

621 super().__init__(sectionName, expose_value=False) 

622 self.sectionText = sectionText 

623 

624 def get_help_record(self, ctx): 

625 return (self.sectionText, "") 

626 

627 

628class MWOptionDecorator: 

629 """Wraps the click.option decorator to enable shared options to be declared 

630 and allows inspection of the shared option. 

631 """ 

632 

633 def __init__(self, *param_decls, **kwargs): 

634 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption), **kwargs) 

635 opt = click.Option(param_decls, **kwargs) 

636 self._name = opt.name 

637 self._opts = opt.opts 

638 

639 def name(self): 

640 """Get the name that will be passed to the command function for this 

641 option.""" 

642 return self._name 

643 

644 def opts(self): 

645 """Get the flags that will be used for this option on the command 

646 line.""" 

647 return self._opts 

648 

649 @property 

650 def help(self): 

651 """Get the help text for this option. Returns an empty string if no 

652 help was defined.""" 

653 return self.partialOpt.keywords.get("help", "") 

654 

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

656 return self.partialOpt(*args, **kwargs) 

657 

658 

659class MWArgumentDecorator: 

660 """Wraps the click.argument decorator to enable shared arguments to be 

661 declared.""" 

662 

663 def __init__(self, *param_decls, **kwargs): 

664 self._helpText = kwargs.pop("help", None) 

665 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs) 

666 

667 def __call__(self, *args, help=None, **kwargs): 

668 def decorator(f): 

669 if help is not None: 

670 self._helpText = help 

671 if self._helpText: 

672 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText) 

673 return self.partialArg(*args, **kwargs)(f) 

674 

675 return decorator 

676 

677 

678class MWCommand(click.Command): 

679 """Command subclass that stores a copy of the args list for use by the 

680 command.""" 

681 

682 extra_epilog = None 

683 

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

685 # wrap callback method with catch_and_exit decorator 

686 callback = kwargs.get("callback") 

687 if callback is not None: 687 ↛ 690line 687 didn't jump to line 690, because the condition on line 687 was never false

688 kwargs = kwargs.copy() 

689 kwargs["callback"] = catch_and_exit(callback) 

690 super().__init__(*args, **kwargs) 

691 

692 def parse_args(self, ctx, args): 

693 MWCtxObj.getFrom(ctx).args = copy.copy(args) 

694 super().parse_args(ctx, args) 

695 

696 @property 

697 def epilog(self): 

698 """Override the epilog attribute to add extra_epilog (if defined by a 

699 subclass) to the end of any epilog provided by a subcommand. 

700 """ 

701 ret = self._epilog if self._epilog else "" 

702 if self.extra_epilog: 

703 if ret: 

704 ret += "\n\n" 

705 ret += self.extra_epilog 

706 return ret 

707 

708 @epilog.setter 

709 def epilog(self, val): 

710 self._epilog = val 

711 

712 

713class ButlerCommand(MWCommand): 

714 """Command subclass with butler-command specific overrides.""" 

715 

716 extra_epilog = "See 'butler --help' for more options." 

717 

718 

719class OptionGroup: 

720 """Base class for an option group decorator. Requires the option group 

721 subclass to have a property called `decorator`.""" 

722 

723 def __call__(self, f): 

724 for decorator in reversed(self.decorators): 

725 f = decorator(f) 

726 return f 

727 

728 

729class MWCtxObj: 

730 """Helper object for managing the `click.Context.obj` parameter, allows 

731 obj data to be managed in a consistent way. 

732 

733 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to 

734 initialize the obj if needed and return a new or existing MWCtxObj. 

735 

736 Attributes 

737 ---------- 

738 args : `list` [`str`] 

739 The list of arguments (argument values, option flags, and option 

740 values), split using whitespace, that were passed in on the command 

741 line for the subcommand represented by the parent context object. 

742 """ 

743 

744 def __init__(self): 

745 

746 self.args = None 

747 

748 @staticmethod 

749 def getFrom(ctx): 

750 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the 

751 new or already existing MWCtxObj.""" 

752 if ctx.obj is not None: 

753 return ctx.obj 

754 ctx.obj = MWCtxObj() 

755 return ctx.obj 

756 

757 

758def yaml_presets(ctx, param, value): 

759 """Click callback that reads additional values from the supplied 

760 YAML file. 

761 

762 Parameters 

763 ---------- 

764 ctx : `click.context` 

765 The context for the click operation. Used to extract the subcommand 

766 name and translate option & argument names. 

767 param : `str` 

768 The parameter name. 

769 value : `object` 

770 The value of the parameter. 

771 """ 

772 

773 def _name_for_option(ctx: click.Context, option: str) -> str: 

774 """Use a CLI option name to find the name of the argument to the 

775 command function. 

776 

777 Parameters 

778 ---------- 

779 ctx : `click.Context` 

780 The context for the click operation. 

781 option : `str` 

782 The option/argument name from the yaml file. 

783 

784 Returns 

785 ------- 

786 name : str 

787 The name of the argument to use when calling the click.command 

788 function, as it should appear in the `ctx.default_map`. 

789 

790 Raises 

791 ------ 

792 RuntimeError 

793 Raised if the option name from the yaml file does not exist in the 

794 command parameters. This catches misspellings and incorrect useage 

795 in the yaml file. 

796 """ 

797 for param in ctx.command.params: 

798 # Remove leading dashes: they are not used for option names in the 

799 # yaml file. 

800 if option in [opt.lstrip("-") for opt in param.opts]: 

801 return param.name 

802 raise RuntimeError(f"'{option}' is not a valid option for {ctx.info_name}") 

803 

804 ctx.default_map = ctx.default_map or {} 

805 cmd_name = ctx.info_name 

806 if value: 

807 try: 

808 overrides = _read_yaml_presets(value, cmd_name) 

809 options = list(overrides.keys()) 

810 for option in options: 

811 name = _name_for_option(ctx, option) 

812 if name == option: 

813 continue 

814 overrides[name] = overrides.pop(option) 

815 except Exception as e: 

816 raise click.BadOptionUsage( 

817 option_name=param.name, 

818 message=f"Error reading overrides file: {e}", 

819 ctx=ctx, 

820 ) 

821 # Override the defaults for this subcommand 

822 ctx.default_map.update(overrides) 

823 return 

824 

825 

826def _read_yaml_presets(file_uri, cmd_name): 

827 """Read file command line overrides from YAML config file. 

828 

829 Parameters 

830 ---------- 

831 file_uri : `str` 

832 URI of override YAML file containing the command line overrides. 

833 They should be grouped by command name. 

834 cmd_name : `str` 

835 The subcommand name that is being modified. 

836 

837 Returns 

838 ------- 

839 overrides : `dict` of [`str`, Any] 

840 The relevant command line options read from the override file. 

841 """ 

842 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri) 

843 config = Config(file_uri) 

844 return config[cmd_name] 

845 

846 

847def sortAstropyTable(table, dimensions, sort_first=None): 

848 """Sort an astropy table, with prioritization given to columns in this 

849 order: 

850 1. the provided named columns 

851 2. spatial and temporal columns 

852 3. the rest of the columns 

853 

854 The table is sorted in-place, and is also returned for convenience. 

855 

856 Parameters 

857 ---------- 

858 table : `astropy.table.Table` 

859 The table to sort 

860 dimensions : `list` [``Dimension``] 

861 The dimensions of the dataIds in the table (the dimensions should be 

862 the same for all the dataIds). Used to determine if the column is 

863 spatial, temporal, or neither. 

864 sort_first : `list` [`str`] 

865 The names of columns that should be sorted first, before spatial and 

866 temporal columns. 

867 

868 Returns 

869 ------- 

870 `astropy.table.Table` 

871 For convenience, the table that has been sorted. 

872 """ 

873 # For sorting we want to ignore the id 

874 # We also want to move temporal or spatial dimensions earlier 

875 sort_first = sort_first or [] 

876 sort_early = [] 

877 sort_late = [] 

878 for dim in dimensions: 

879 if dim.spatial or dim.temporal: 

880 sort_early.extend(dim.required.names) 

881 else: 

882 sort_late.append(str(dim)) 

883 sort_keys = sort_first + sort_early + sort_late 

884 # The required names above means that we have the possibility of 

885 # repeats of sort keys. Now have to remove them 

886 # (order is retained by dict creation). 

887 sort_keys = list(dict.fromkeys(sort_keys).keys()) 

888 

889 table.sort(sort_keys) 

890 return table 

891 

892 

893def catch_and_exit(func): 

894 """Decorator which catches all exceptions, prints an exception traceback 

895 and signals click to exit. 

896 """ 

897 

898 @wraps(func) 

899 def inner(*args, **kwargs): 

900 try: 

901 func(*args, **kwargs) 

902 except (click.exceptions.ClickException, click.exceptions.Exit, click.exceptions.Abort): 

903 # this is handled by click itself 

904 raise 

905 except Exception: 

906 exc_type, exc_value, exc_tb = sys.exc_info() 

907 if exc_tb.tb_next: 

908 # do not show this decorator in traceback 

909 exc_tb = exc_tb.tb_next 

910 log.exception( 

911 "Caught an exception, details are in traceback:", exc_info=(exc_type, exc_value, exc_tb) 

912 ) 

913 # tell click to stop, this never returns. 

914 click.get_current_context().exit(1) 

915 

916 return inner