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# 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(f"Internal error: invalid return type '{return_type}' for split_kv.") 

379 if multiple: 

380 vals = split_commas(context, param, vals) 

381 for val in ensure_iterable(vals): 

382 if unseparated_okay and separator not in val: 

383 if choice is not None: 

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

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

386 else: 

387 try: 

388 k, v = val.split(separator) 

389 if choice is not None: 

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

391 except ValueError: 

392 raise click.ClickException( 

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

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

395 ) 

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

397 return ret.get() 

398 

399 

400def to_upper(context, param, value): 

401 """Convert a value to upper case. 

402 

403 Parameters 

404 ---------- 

405 context : click.Context 

406 

407 values : string 

408 The value to be converted. 

409 

410 Returns 

411 ------- 

412 string 

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

414 """ 

415 return value.upper() 

416 

417 

418def unwrap(val): 

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

420 a consistent indentation level. 

421 

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

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

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

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

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

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

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

429 

430 Parameters 

431 ---------- 

432 val : `str` 

433 The string to change. 

434 

435 Returns 

436 ------- 

437 strippedString : `str` 

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

439 whitespace removed. 

440 """ 

441 

442 def splitSection(val): 

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

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

445 firstLine += " " 

446 else: 

447 firstLine = "" 

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

449 

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

451 

452 

453class option_section: # noqa: N801 

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

455 command. 

456 

457 Parameters 

458 ---------- 

459 sectionText : `str` 

460 The text to print in the section identifier. 

461 """ 

462 

463 def __init__(self, sectionText): 

464 self.sectionText = "\n" + sectionText 

465 

466 def __call__(self, f): 

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

468 # section. 

469 return click.option( 

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

471 )(f) 

472 

473 

474class MWPath(click.Path): 

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

476 

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

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

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

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

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

482 that it is required to not exist). 

483 

484 Parameters 

485 ---------- 

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

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

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

489 location may exist or not. 

490 

491 For other parameters see `click.Path`. 

492 """ 

493 

494 def __init__( 

495 self, 

496 exists=None, 

497 file_okay=True, 

498 dir_okay=True, 

499 writable=False, 

500 readable=True, 

501 resolve_path=False, 

502 allow_dash=False, 

503 path_type=None, 

504 ): 

505 self.mustNotExist = exists is False 

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

507 exists = False 

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

509 

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

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

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

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

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

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

516 

517 

518class MWOption(click.Option): 

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

520 

521 def make_metavar(self): 

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

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

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

525 implementation. 

526 

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

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

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

530 space between. 

531 

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

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

534 get_help_record. 

535 """ 

536 metavar = super().make_metavar() 

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

538 metavar += " ..." 

539 elif self.nargs != 1: 

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

541 return metavar 

542 

543 

544class MWArgument(click.Argument): 

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

546 

547 def make_metavar(self): 

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

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

550 metavar name if the option accepts multiple inputs. 

551 

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

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

554 

555 Returns 

556 ------- 

557 metavar : `str` 

558 The metavar value. 

559 """ 

560 metavar = super().make_metavar() 

561 if self.nargs != 1: 

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

563 return metavar 

564 

565 

566class OptionSection(MWOption): 

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

568 does not pass any value to the command function. 

569 

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

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

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

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

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

575 

576 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

585 

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

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

588 internals change. 

589 

590 Parameters 

591 ---------- 

592 sectionName : `str` 

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

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

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

596 auto-generated. 

597 sectionText : `str` 

598 The text to print in the section identifier. 

599 """ 

600 

601 @property 

602 def hidden(self): 

603 return True 

604 

605 @hidden.setter 

606 def hidden(self, val): 

607 pass 

608 

609 def __init__(self, sectionName, sectionText): 

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

611 self.sectionText = sectionText 

612 

613 def get_help_record(self, ctx): 

614 return (self.sectionText, "") 

615 

616 

617class MWOptionDecorator: 

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

619 and allows inspection of the shared option. 

620 """ 

621 

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

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

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

625 self._name = opt.name 

626 self._opts = opt.opts 

627 

628 def name(self): 

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

630 option.""" 

631 return self._name 

632 

633 def opts(self): 

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

635 line.""" 

636 return self._opts 

637 

638 @property 

639 def help(self): 

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

641 help was defined.""" 

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

643 

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

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

646 

647 

648class MWArgumentDecorator: 

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

650 declared.""" 

651 

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

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

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

655 

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

657 def decorator(f): 

658 if help is not None: 

659 self._helpText = help 

660 if self._helpText: 

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

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

663 

664 return decorator 

665 

666 

667class MWCommand(click.Command): 

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

669 command.""" 

670 

671 extra_epilog = None 

672 

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

674 # wrap callback method with catch_and_exit decorator 

675 callback = kwargs.get("callback") 

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

677 kwargs = kwargs.copy() 

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

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

680 

681 def parse_args(self, ctx, args): 

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

683 super().parse_args(ctx, args) 

684 

685 @property 

686 def epilog(self): 

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

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

689 """ 

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

691 if self.extra_epilog: 

692 if ret: 

693 ret += "\n\n" 

694 ret += self.extra_epilog 

695 return ret 

696 

697 @epilog.setter 

698 def epilog(self, val): 

699 self._epilog = val 

700 

701 

702class ButlerCommand(MWCommand): 

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

704 

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

706 

707 

708class OptionGroup: 

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

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

711 

712 def __call__(self, f): 

713 for decorator in reversed(self.decorators): 

714 f = decorator(f) 

715 return f 

716 

717 

718class MWCtxObj: 

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

720 obj data to be managed in a consistent way. 

721 

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

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

724 

725 Attributes 

726 ---------- 

727 args : `list` [`str`] 

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

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

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

731 """ 

732 

733 def __init__(self): 

734 

735 self.args = None 

736 

737 @staticmethod 

738 def getFrom(ctx): 

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

740 new or already existing MWCtxObj.""" 

741 if ctx.obj is not None: 

742 return ctx.obj 

743 ctx.obj = MWCtxObj() 

744 return ctx.obj 

745 

746 

747def yaml_presets(ctx, param, value): 

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

749 YAML file. 

750 

751 Parameters 

752 ---------- 

753 ctx : `click.context` 

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

755 name and translate option & argument names. 

756 param : `str` 

757 The parameter name. 

758 value : `object` 

759 The value of the parameter. 

760 """ 

761 

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

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

764 command function. 

765 

766 Parameters 

767 ---------- 

768 ctx : `click.Context` 

769 The context for the click operation. 

770 option : `str` 

771 The option/argument name from the yaml file. 

772 

773 Returns 

774 ------- 

775 name : str 

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

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

778 

779 Raises 

780 ------ 

781 RuntimeError 

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

783 command parameters. This catches misspellings and incorrect useage 

784 in the yaml file. 

785 """ 

786 for param in ctx.command.params: 

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

788 # yaml file. 

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

790 return param.name 

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

792 

793 ctx.default_map = ctx.default_map or {} 

794 cmd_name = ctx.info_name 

795 if value: 

796 try: 

797 overrides = _read_yaml_presets(value, cmd_name) 

798 options = list(overrides.keys()) 

799 for option in options: 

800 name = _name_for_option(ctx, option) 

801 if name == option: 

802 continue 

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

804 except Exception as e: 

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

806 # Override the defaults for this subcommand 

807 ctx.default_map.update(overrides) 

808 return 

809 

810 

811def _read_yaml_presets(file_uri, cmd_name): 

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

813 

814 Parameters 

815 ---------- 

816 file_uri : `str` 

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

818 They should be grouped by command name. 

819 cmd_name : `str` 

820 The subcommand name that is being modified. 

821 

822 Returns 

823 ------- 

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

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

826 """ 

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

828 config = Config(file_uri) 

829 return config[cmd_name] 

830 

831 

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

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

834 order: 

835 1. the provided named columns 

836 2. spatial and temporal columns 

837 3. the rest of the columns 

838 

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

840 

841 Parameters 

842 ---------- 

843 table : `astropy.table.Table` 

844 The table to sort 

845 dimensions : `list` [``Dimension``] 

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

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

848 spatial, temporal, or neither. 

849 sort_first : `list` [`str`] 

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

851 temporal columns. 

852 

853 Returns 

854 ------- 

855 `astropy.table.Table` 

856 For convenience, the table that has been sorted. 

857 """ 

858 # For sorting we want to ignore the id 

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

860 sort_first = sort_first or [] 

861 sort_early = [] 

862 sort_late = [] 

863 for dim in dimensions: 

864 if dim.spatial or dim.temporal: 

865 sort_early.extend(dim.required.names) 

866 else: 

867 sort_late.append(str(dim)) 

868 sort_keys = sort_first + sort_early + sort_late 

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

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

871 # (order is retained by dict creation). 

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

873 

874 table.sort(sort_keys) 

875 return table 

876 

877 

878def catch_and_exit(func): 

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

880 and signals click to exit. 

881 """ 

882 

883 @wraps(func) 

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

885 try: 

886 func(*args, **kwargs) 

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

888 # this is handled by click itself 

889 raise 

890 except Exception: 

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

892 if exc_tb.tb_next: 

893 # do not show this decorator in traceback 

894 exc_tb = exc_tb.tb_next 

895 log.exception( 

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

897 ) 

898 # tell click to stop, this never returns. 

899 click.get_current_context().exit(1) 

900 

901 return inner