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

293 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# For parameters that support key-value inputs, this defines the separator 

54# for those inputs. 

55split_kv_separator = "=" 

56 

57 

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

59where_help = ( 

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

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

62 "dimension table." 

63) 

64 

65 

66def astropyTablesToStr(tables): 

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

68 

69 Output formatting matches ``printAstropyTables``. 

70 """ 

71 ret = "" 

72 for table in tables: 

73 ret += "\n" 

74 table.pformat_all() 

75 ret += "\n" 

76 return ret 

77 

78 

79def printAstropyTables(tables): 

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

81 

82 Output formatting matches ``astropyTablesToStr``. 

83 """ 

84 for table in tables: 

85 print("") 

86 table.pprint_all() 

87 print("") 

88 

89 

90def textTypeStr(multiple): 

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

92 

93 Parameters 

94 ---------- 

95 multiple : `bool` 

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

97 allowed. 

98 

99 Returns 

100 ------- 

101 textTypeStr : `str` 

102 The type string to use. 

103 """ 

104 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

105 

106 

107class LogCliRunner(click.testing.CliRunner): 

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

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

110 was done with the CliLog interface. 

111 

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

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

114 `CliLog.defaultLsstLogLevel`.""" 

115 

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

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

118 CliLog.resetLog() 

119 return result 

120 

121 

122def clickResultMsg(result): 

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

124 

125 Parameters 

126 ---------- 

127 result : click.Result 

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

129 

130 Returns 

131 ------- 

132 msg : `str` 

133 The message string. 

134 """ 

135 msg = f"""\noutput: {result.output}\nexception: {result.exception}""" 

136 if result.exception: 

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

138 return msg 

139 

140 

141@contextmanager 

142def command_test_env(runner, commandModule, commandName): 

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

144 provides a CLI plugin command with the given name. 

145 

146 Parameters 

147 ---------- 

148 runner : click.testing.CliRunner 

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

150 commandModule : `str` 

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

152 commandName : `str` 

153 The name of the command being published to import. 

154 """ 

155 with runner.isolated_filesystem(): 

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

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

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

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

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

161 # is properly stripped out. 

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

163 yield 

164 

165 

166def addArgumentHelp(doc, helpText): 

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

168 

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

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

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

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

173 from the order they are applied in. 

174 

175 Parameters 

176 ---------- 

177 doc : `str` 

178 The function's docstring. 

179 helpText : `str` 

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

181 docstring. 

182 

183 Returns 

184 ------- 

185 doc : `str` 

186 Updated function documentation. 

187 """ 

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

189 doc = helpText 

190 else: 

191 # See click documentation for details: 

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

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

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

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

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

197 

198 doclines = doc.splitlines() 

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

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

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

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

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

204 doclines.insert(1, helpText) 

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

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

207 return doc 

208 

209 

210def split_commas(context, param, values): 

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

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

213 

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

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

216 

217 Parameters 

218 ---------- 

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

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

221 callbacks. 

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

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

224 callbacks. 

225 values : [`str`] 

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

227 which will be treated as delimiters for separate values. 

228 

229 Returns 

230 ------- 

231 list of string 

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

233 list. 

234 """ 

235 if values is None: 

236 return values 

237 valueList = [] 

238 for value in ensure_iterable(values): 

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

240 return tuple(valueList) 

241 

242 

243def split_kv( 

244 context, 

245 param, 

246 values, 

247 choice=None, 

248 multiple=True, 

249 normalize=False, 

250 separator="=", 

251 unseparated_okay=False, 

252 return_type=dict, 

253 default_key="", 

254 reverse_kv=False, 

255 add_to_default=False, 

256): 

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

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

259 all the passed-in values. 

260 

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

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

263 

264 Parameters 

265 ---------- 

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

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

268 callbacks. 

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

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

271 callbacks. 

272 values : [`str`] 

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

274 which will be treated as delimiters for separate values. 

275 choice : `click.Choice`, optional 

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

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

278 default None 

279 multiple : `bool`, optional 

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

281 default True. 

282 normalize : `bool`, optional 

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

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

285 separator : str, optional 

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

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

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

289 unseparated_okay : `bool`, optional 

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

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

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

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

294 The type of the value that should be returned. 

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

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

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

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

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

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

301 right. By default `dict`. 

302 default_key : `Any` 

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

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

305 ``unseparated_okay`` to be `True`.) 

306 reverse_kv : bool 

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

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

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

310 add_to_default : `bool`, optional 

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

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

313 same key(s) as the default value. 

314 

315 Returns 

316 ------- 

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

318 The passed-in values in dict form. 

319 

320 Raises 

321 ------ 

322 `click.ClickException` 

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

324 are encountered. 

325 """ 

326 

327 def norm(val): 

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

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

330 choices. 

331 

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

333 instance to verify val is a valid choice. 

334 """ 

335 if normalize and choice is not None: 

336 v = val.casefold() 

337 for opt in choice.choices: 

338 if opt.casefold() == v: 

339 return opt 

340 return val 

341 

342 class RetDict: 

343 def __init__(self): 

344 self.ret = {} 

345 

346 def add(self, key, val): 

347 if reverse_kv: 

348 key, val = val, key 

349 self.ret[key] = val 

350 

351 def get(self): 

352 return self.ret 

353 

354 class RetTuple: 

355 def __init__(self): 

356 self.ret = [] 

357 

358 def add(self, key, val): 

359 if reverse_kv: 

360 key, val = val, key 

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

362 

363 def get(self): 

364 return tuple(self.ret) 

365 

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

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

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

369 

370 if add_to_default: 

371 default = param.get_default(context) 

372 if default: 

373 vals = itertools.chain(default, vals) 

374 

375 if return_type is dict: 

376 ret = RetDict() 

377 elif return_type is tuple: 

378 ret = RetTuple() 

379 else: 

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

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 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__(exists, file_okay, dir_okay, writable, readable, resolve_path, allow_dash, path_type) 

511 

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

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

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

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

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

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

518 

519 

520class MWOption(click.Option): 

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

522 

523 def make_metavar(self): 

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

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

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

527 implementation. 

528 

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

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

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

532 space between. 

533 

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

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

536 get_help_record. 

537 """ 

538 metavar = super().make_metavar() 

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

540 metavar += " ..." 

541 elif self.nargs != 1: 

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

543 return metavar 

544 

545 

546class MWArgument(click.Argument): 

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

548 

549 def make_metavar(self): 

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

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

552 metavar name if the option accepts multiple inputs. 

553 

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

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

556 

557 Returns 

558 ------- 

559 metavar : `str` 

560 The metavar value. 

561 """ 

562 metavar = super().make_metavar() 

563 if self.nargs != 1: 

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

565 return metavar 

566 

567 

568class OptionSection(MWOption): 

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

570 does not pass any value to the command function. 

571 

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

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

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

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

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

577 

578 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

587 

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

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

590 internals change. 

591 

592 Parameters 

593 ---------- 

594 sectionName : `str` 

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

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

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

598 auto-generated. 

599 sectionText : `str` 

600 The text to print in the section identifier. 

601 """ 

602 

603 @property 

604 def hidden(self): 

605 return True 

606 

607 @hidden.setter 

608 def hidden(self, val): 

609 pass 

610 

611 def __init__(self, sectionName, sectionText): 

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

613 self.sectionText = sectionText 

614 

615 def get_help_record(self, ctx): 

616 return (self.sectionText, "") 

617 

618 

619class MWOptionDecorator: 

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

621 and allows inspection of the shared option. 

622 """ 

623 

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

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

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

627 self._name = opt.name 

628 self._opts = opt.opts 

629 

630 def name(self): 

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

632 option.""" 

633 return self._name 

634 

635 def opts(self): 

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

637 line.""" 

638 return self._opts 

639 

640 @property 

641 def help(self): 

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

643 help was defined.""" 

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

645 

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

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

648 

649 

650class MWArgumentDecorator: 

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

652 declared.""" 

653 

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

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

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

657 

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

659 def decorator(f): 

660 if help is not None: 

661 self._helpText = help 

662 if self._helpText: 

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

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

665 

666 return decorator 

667 

668 

669class MWCommand(click.Command): 

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

671 command.""" 

672 

673 extra_epilog = None 

674 

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

676 # wrap callback method with catch_and_exit decorator 

677 callback = kwargs.get("callback") 

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

679 kwargs = kwargs.copy() 

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

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

682 

683 def parse_args(self, ctx, args): 

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

685 super().parse_args(ctx, args) 

686 

687 @property 

688 def epilog(self): 

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

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

691 """ 

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

693 if self.extra_epilog: 

694 if ret: 

695 ret += "\n\n" 

696 ret += self.extra_epilog 

697 return ret 

698 

699 @epilog.setter 

700 def epilog(self, val): 

701 self._epilog = val 

702 

703 

704class ButlerCommand(MWCommand): 

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

706 

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

708 

709 

710class OptionGroup: 

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

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

713 

714 def __call__(self, f): 

715 for decorator in reversed(self.decorators): 

716 f = decorator(f) 

717 return f 

718 

719 

720class MWCtxObj: 

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

722 obj data to be managed in a consistent way. 

723 

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

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

726 

727 Attributes 

728 ---------- 

729 args : `list` [`str`] 

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

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

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

733 """ 

734 

735 def __init__(self): 

736 

737 self.args = None 

738 

739 @staticmethod 

740 def getFrom(ctx): 

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

742 new or already existing MWCtxObj.""" 

743 if ctx.obj is not None: 

744 return ctx.obj 

745 ctx.obj = MWCtxObj() 

746 return ctx.obj 

747 

748 

749def yaml_presets(ctx, param, value): 

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

751 YAML file. 

752 

753 Parameters 

754 ---------- 

755 ctx : `click.context` 

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

757 name and translate option & argument names. 

758 param : `str` 

759 The parameter name. 

760 value : `object` 

761 The value of the parameter. 

762 """ 

763 

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

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

766 command function. 

767 

768 Parameters 

769 ---------- 

770 ctx : `click.Context` 

771 The context for the click operation. 

772 option : `str` 

773 The option/argument name from the yaml file. 

774 

775 Returns 

776 ------- 

777 name : str 

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

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

780 

781 Raises 

782 ------ 

783 RuntimeError 

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

785 command parameters. This catches misspellings and incorrect useage 

786 in the yaml file. 

787 """ 

788 for param in ctx.command.params: 

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

790 # yaml file. 

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

792 return param.name 

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

794 

795 ctx.default_map = ctx.default_map or {} 

796 cmd_name = ctx.info_name 

797 if value: 

798 try: 

799 overrides = _read_yaml_presets(value, cmd_name) 

800 options = list(overrides.keys()) 

801 for option in options: 

802 name = _name_for_option(ctx, option) 

803 if name == option: 

804 continue 

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

806 except Exception as e: 

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

808 # Override the defaults for this subcommand 

809 ctx.default_map.update(overrides) 

810 return 

811 

812 

813def _read_yaml_presets(file_uri, cmd_name): 

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

815 

816 Parameters 

817 ---------- 

818 file_uri : `str` 

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

820 They should be grouped by command name. 

821 cmd_name : `str` 

822 The subcommand name that is being modified. 

823 

824 Returns 

825 ------- 

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

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

828 """ 

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

830 config = Config(file_uri) 

831 return config[cmd_name] 

832 

833 

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

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

836 order: 

837 1. the provided named columns 

838 2. spatial and temporal columns 

839 3. the rest of the columns 

840 

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

842 

843 Parameters 

844 ---------- 

845 table : `astropy.table.Table` 

846 The table to sort 

847 dimensions : `list` [``Dimension``] 

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

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

850 spatial, temporal, or neither. 

851 sort_first : `list` [`str`] 

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

853 temporal columns. 

854 

855 Returns 

856 ------- 

857 `astropy.table.Table` 

858 For convenience, the table that has been sorted. 

859 """ 

860 # For sorting we want to ignore the id 

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

862 sort_first = sort_first or [] 

863 sort_early = [] 

864 sort_late = [] 

865 for dim in dimensions: 

866 if dim.spatial or dim.temporal: 

867 sort_early.extend(dim.required.names) 

868 else: 

869 sort_late.append(str(dim)) 

870 sort_keys = sort_first + sort_early + sort_late 

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

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

873 # (order is retained by dict creation). 

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

875 

876 table.sort(sort_keys) 

877 return table 

878 

879 

880def catch_and_exit(func): 

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

882 and signals click to exit. 

883 """ 

884 

885 @wraps(func) 

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

887 try: 

888 func(*args, **kwargs) 

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

890 # this is handled by click itself 

891 raise 

892 except Exception: 

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

894 if exc_tb.tb_next: 

895 # do not show this decorator in traceback 

896 exc_tb = exc_tb.tb_next 

897 log.exception( 

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

899 ) 

900 # tell click to stop, this never returns. 

901 click.get_current_context().exit(1) 

902 

903 return inner