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

Shortcuts 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

292 statements  

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 doclines.insert(1, helpText) 

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

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

202 return doc 

203 

204 

205def split_commas(context, param, values): 

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

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

208 

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

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

211 

212 Parameters 

213 ---------- 

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

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

216 callbacks. 

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

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

219 callbacks. 

220 values : [`str`] 

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

222 which will be treated as delimiters for separate values. 

223 

224 Returns 

225 ------- 

226 list of string 

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

228 list. 

229 """ 

230 if values is None: 

231 return values 

232 valueList = [] 

233 for value in ensure_iterable(values): 

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

235 return tuple(valueList) 

236 

237 

238def split_kv( 

239 context, 

240 param, 

241 values, 

242 choice=None, 

243 multiple=True, 

244 normalize=False, 

245 separator="=", 

246 unseparated_okay=False, 

247 return_type=dict, 

248 default_key="", 

249 reverse_kv=False, 

250 add_to_default=False, 

251): 

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

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

254 all the passed-in values. 

255 

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

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

258 

259 Parameters 

260 ---------- 

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

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

263 callbacks. 

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

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

266 callbacks. 

267 values : [`str`] 

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

269 which will be treated as delimiters for separate values. 

270 choice : `click.Choice`, optional 

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

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

273 default None 

274 multiple : `bool`, optional 

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

276 default True. 

277 normalize : `bool`, optional 

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

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

280 separator : str, optional 

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

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

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

284 unseparated_okay : `bool`, optional 

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

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

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

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

289 The type of the value that should be returned. 

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

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

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

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

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

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

296 right. By default `dict`. 

297 default_key : `Any` 

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

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

300 ``unseparated_okay`` to be `True`.) 

301 reverse_kv : bool 

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

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

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

305 add_to_default : `bool`, optional 

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

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

308 same key(s) as the default value. 

309 

310 Returns 

311 ------- 

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

313 The passed-in values in dict form. 

314 

315 Raises 

316 ------ 

317 `click.ClickException` 

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

319 are encountered. 

320 """ 

321 

322 def norm(val): 

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

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

325 choices. 

326 

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

328 instance to verify val is a valid choice. 

329 """ 

330 if normalize and choice is not None: 

331 v = val.casefold() 

332 for opt in choice.choices: 

333 if opt.casefold() == v: 

334 return opt 

335 return val 

336 

337 class RetDict: 

338 def __init__(self): 

339 self.ret = {} 

340 

341 def add(self, key, val): 

342 if reverse_kv: 

343 key, val = val, key 

344 self.ret[key] = val 

345 

346 def get(self): 

347 return self.ret 

348 

349 class RetTuple: 

350 def __init__(self): 

351 self.ret = [] 

352 

353 def add(self, key, val): 

354 if reverse_kv: 

355 key, val = val, key 

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

357 

358 def get(self): 

359 return tuple(self.ret) 

360 

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

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

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

364 

365 if add_to_default: 

366 default = param.get_default(context) 

367 if default: 

368 vals = itertools.chain(default, vals) 

369 

370 if return_type is dict: 

371 ret = RetDict() 

372 elif return_type is tuple: 

373 ret = RetTuple() 

374 else: 

375 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.") 

376 if multiple: 

377 vals = split_commas(context, param, vals) 

378 for val in ensure_iterable(vals): 

379 if unseparated_okay and separator not in val: 

380 if choice is not None: 

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

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

383 else: 

384 try: 

385 k, v = val.split(separator) 

386 if choice is not None: 

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

388 except ValueError: 

389 raise click.ClickException( 

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

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

392 ) 

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

394 return ret.get() 

395 

396 

397def to_upper(context, param, value): 

398 """Convert a value to upper case. 

399 

400 Parameters 

401 ---------- 

402 context : click.Context 

403 

404 values : string 

405 The value to be converted. 

406 

407 Returns 

408 ------- 

409 string 

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

411 """ 

412 return value.upper() 

413 

414 

415def unwrap(val): 

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

417 a consistent indentation level. 

418 

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

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

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

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

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

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

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

426 

427 Parameters 

428 ---------- 

429 val : `str` 

430 The string to change. 

431 

432 Returns 

433 ------- 

434 strippedString : `str` 

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

436 whitespace removed. 

437 """ 

438 

439 def splitSection(val): 

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

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

442 firstLine += " " 

443 else: 

444 firstLine = "" 

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

446 

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

448 

449 

450class option_section: # noqa: N801 

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

452 command. 

453 

454 Parameters 

455 ---------- 

456 sectionText : `str` 

457 The text to print in the section identifier. 

458 """ 

459 

460 def __init__(self, sectionText): 

461 self.sectionText = "\n" + sectionText 

462 

463 def __call__(self, f): 

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

465 # section. 

466 return click.option( 

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

468 )(f) 

469 

470 

471class MWPath(click.Path): 

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

473 

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

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

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

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

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

479 that it is required to not exist). 

480 

481 Parameters 

482 ---------- 

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

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

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

486 location may exist or not. 

487 

488 For other parameters see `click.Path`. 

489 """ 

490 

491 def __init__( 

492 self, 

493 exists=None, 

494 file_okay=True, 

495 dir_okay=True, 

496 writable=False, 

497 readable=True, 

498 resolve_path=False, 

499 allow_dash=False, 

500 path_type=None, 

501 ): 

502 self.mustNotExist = exists is False 

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

504 exists = False 

505 super().__init__(exists, file_okay, dir_okay, writable, readable, resolve_path, allow_dash, path_type) 

506 

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

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

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

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

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

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

513 

514 

515class MWOption(click.Option): 

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

517 

518 def make_metavar(self): 

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

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

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

522 implementation. 

523 

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

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

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

527 space between. 

528 

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

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

531 get_help_record. 

532 """ 

533 metavar = super().make_metavar() 

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

535 metavar += " ..." 

536 elif self.nargs != 1: 

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

538 return metavar 

539 

540 

541class MWArgument(click.Argument): 

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

543 

544 def make_metavar(self): 

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

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

547 metavar name if the option accepts multiple inputs. 

548 

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

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

551 

552 Returns 

553 ------- 

554 metavar : `str` 

555 The metavar value. 

556 """ 

557 metavar = super().make_metavar() 

558 if self.nargs != 1: 

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

560 return metavar 

561 

562 

563class OptionSection(MWOption): 

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

565 does not pass any value to the command function. 

566 

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

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

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

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

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

572 

573 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

582 

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

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

585 internals change. 

586 

587 Parameters 

588 ---------- 

589 sectionName : `str` 

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

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

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

593 auto-generated. 

594 sectionText : `str` 

595 The text to print in the section identifier. 

596 """ 

597 

598 @property 

599 def hidden(self): 

600 return True 

601 

602 @hidden.setter 

603 def hidden(self, val): 

604 pass 

605 

606 def __init__(self, sectionName, sectionText): 

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

608 self.sectionText = sectionText 

609 

610 def get_help_record(self, ctx): 

611 return (self.sectionText, "") 

612 

613 

614class MWOptionDecorator: 

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

616 and allows inspection of the shared option. 

617 """ 

618 

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

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

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

622 self._name = opt.name 

623 self._opts = opt.opts 

624 

625 def name(self): 

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

627 option.""" 

628 return self._name 

629 

630 def opts(self): 

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

632 line.""" 

633 return self._opts 

634 

635 @property 

636 def help(self): 

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

638 help was defined.""" 

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

640 

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

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

643 

644 

645class MWArgumentDecorator: 

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

647 declared.""" 

648 

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

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

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

652 

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

654 def decorator(f): 

655 if help is not None: 

656 self._helpText = help 

657 if self._helpText: 

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

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

660 

661 return decorator 

662 

663 

664class MWCommand(click.Command): 

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

666 command.""" 

667 

668 extra_epilog = None 

669 

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

671 # wrap callback method with catch_and_exit decorator 

672 callback = kwargs.get("callback") 

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

674 kwargs = kwargs.copy() 

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

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

677 

678 def parse_args(self, ctx, args): 

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

680 super().parse_args(ctx, args) 

681 

682 @property 

683 def epilog(self): 

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

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

686 """ 

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

688 if self.extra_epilog: 

689 if ret: 

690 ret += "\n\n" 

691 ret += self.extra_epilog 

692 return ret 

693 

694 @epilog.setter 

695 def epilog(self, val): 

696 self._epilog = val 

697 

698 

699class ButlerCommand(MWCommand): 

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

701 

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

703 

704 

705class OptionGroup: 

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

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

708 

709 def __call__(self, f): 

710 for decorator in reversed(self.decorators): 

711 f = decorator(f) 

712 return f 

713 

714 

715class MWCtxObj: 

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

717 obj data to be managed in a consistent way. 

718 

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

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

721 

722 Attributes 

723 ---------- 

724 args : `list` [`str`] 

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

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

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

728 """ 

729 

730 def __init__(self): 

731 

732 self.args = None 

733 

734 @staticmethod 

735 def getFrom(ctx): 

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

737 new or already existing MWCtxObj.""" 

738 if ctx.obj is not None: 

739 return ctx.obj 

740 ctx.obj = MWCtxObj() 

741 return ctx.obj 

742 

743 

744def yaml_presets(ctx, param, value): 

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

746 YAML file. 

747 

748 Parameters 

749 ---------- 

750 ctx : `click.context` 

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

752 name and translate option & argument names. 

753 param : `str` 

754 The parameter name. 

755 value : `object` 

756 The value of the parameter. 

757 """ 

758 

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

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

761 command function. 

762 

763 Parameters 

764 ---------- 

765 ctx : `click.Context` 

766 The context for the click operation. 

767 option : `str` 

768 The option/argument name from the yaml file. 

769 

770 Returns 

771 ------- 

772 name : str 

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

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

775 

776 Raises 

777 ------ 

778 RuntimeError 

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

780 command parameters. This catches misspellings and incorrect useage 

781 in the yaml file. 

782 """ 

783 for param in ctx.command.params: 

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

785 # yaml file. 

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

787 return param.name 

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

789 

790 ctx.default_map = ctx.default_map or {} 

791 cmd_name = ctx.info_name 

792 if value: 

793 try: 

794 overrides = _read_yaml_presets(value, cmd_name) 

795 options = list(overrides.keys()) 

796 for option in options: 

797 name = _name_for_option(ctx, option) 

798 if name == option: 

799 continue 

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

801 except Exception as e: 

802 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx) 

803 # Override the defaults for this subcommand 

804 ctx.default_map.update(overrides) 

805 return 

806 

807 

808def _read_yaml_presets(file_uri, cmd_name): 

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

810 

811 Parameters 

812 ---------- 

813 file_uri : `str` 

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

815 They should be grouped by command name. 

816 cmd_name : `str` 

817 The subcommand name that is being modified. 

818 

819 Returns 

820 ------- 

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

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

823 """ 

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

825 config = Config(file_uri) 

826 return config[cmd_name] 

827 

828 

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

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

831 order: 

832 1. the provided named columns 

833 2. spatial and temporal columns 

834 3. the rest of the columns 

835 

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

837 

838 Parameters 

839 ---------- 

840 table : `astropy.table.Table` 

841 The table to sort 

842 dimensions : `list` [``Dimension``] 

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

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

845 spatial, temporal, or neither. 

846 sort_first : `list` [`str`] 

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

848 temporal columns. 

849 

850 Returns 

851 ------- 

852 `astropy.table.Table` 

853 For convenience, the table that has been sorted. 

854 """ 

855 # For sorting we want to ignore the id 

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

857 sort_first = sort_first or [] 

858 sort_early = [] 

859 sort_late = [] 

860 for dim in dimensions: 

861 if dim.spatial or dim.temporal: 

862 sort_early.extend(dim.required.names) 

863 else: 

864 sort_late.append(str(dim)) 

865 sort_keys = sort_first + sort_early + sort_late 

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

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

868 # (order is retained by dict creation). 

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

870 

871 table.sort(sort_keys) 

872 return table 

873 

874 

875def catch_and_exit(func): 

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

877 and signals click to exit. 

878 """ 

879 

880 @wraps(func) 

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

882 try: 

883 func(*args, **kwargs) 

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

885 # this is handled by click itself 

886 raise 

887 except Exception: 

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

889 if exc_tb.tb_next: 

890 # do not show this decorator in traceback 

891 exc_tb = exc_tb.tb_next 

892 log.exception( 

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

894 ) 

895 # tell click to stop, this never returns. 

896 click.get_current_context().exit(1) 

897 

898 return inner