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 click 

23import click.exceptions 

24import click.testing 

25from contextlib import contextmanager 

26import copy 

27from functools import partial, wraps 

28import itertools 

29import logging 

30import os 

31import sys 

32import textwrap 

33import traceback 

34from unittest.mock import patch 

35import uuid 

36import yaml 

37 

38from lsst.utils.iteration import ensure_iterable 

39from .cliLog import CliLog 

40from ..core.config import Config 

41 

42log = logging.getLogger(__name__) 

43 

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

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

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

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

48# callback=split_kv. 

49typeStrAcceptsMultiple = "TEXT ..." 

50typeStrAcceptsSingle = "TEXT" 

51 

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

53# for those inputs. 

54split_kv_separator = "=" 

55 

56 

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

58where_help = "A string expression similar to a SQL WHERE clause. May involve any column of a " \ 

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

60 "dimension table." 

61 

62 

63def astropyTablesToStr(tables): 

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

65 

66 Output formatting matches ``printAstropyTables``. 

67 """ 

68 ret = "" 

69 for table in tables: 

70 ret += "\n" 

71 table.pformat_all() 

72 ret += "\n" 

73 return ret 

74 

75 

76def printAstropyTables(tables): 

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

78 

79 Output formatting matches ``astropyTablesToStr``. 

80 """ 

81 for table in tables: 

82 print("") 

83 table.pprint_all() 

84 print("") 

85 

86 

87def textTypeStr(multiple): 

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

89 

90 Parameters 

91 ---------- 

92 multiple : `bool` 

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

94 allowed. 

95 

96 Returns 

97 ------- 

98 textTypeStr : `str` 

99 The type string to use. 

100 """ 

101 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

102 

103 

104class LogCliRunner(click.testing.CliRunner): 

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

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

107 was done with the CliLog interface. 

108 

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

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

111 `CliLog.defaultLsstLogLevel`.""" 

112 

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

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

115 CliLog.resetLog() 

116 return result 

117 

118 

119def clickResultMsg(result): 

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

121 

122 Parameters 

123 ---------- 

124 result : click.Result 

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

126 

127 Returns 

128 ------- 

129 msg : `str` 

130 The message string. 

131 """ 

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

133 if result.exception: 

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

135 return msg 

136 

137 

138@contextmanager 

139def command_test_env(runner, commandModule, commandName): 

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

141 provides a CLI plugin command with the given name. 

142 

143 Parameters 

144 ---------- 

145 runner : click.testing.CliRunner 

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

147 commandModule : `str` 

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

149 commandName : `str` 

150 The name of the command being published to import. 

151 """ 

152 with runner.isolated_filesystem(): 

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

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

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

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

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

158 # is properly stripped out. 

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

160 yield 

161 

162 

163def addArgumentHelp(doc, helpText): 

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

165 

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

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

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

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

170 from the order they are applied in. 

171 

172 Parameters 

173 ---------- 

174 doc : `str` 

175 The function's docstring. 

176 helpText : `str` 

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

178 docstring. 

179 

180 Returns 

181 ------- 

182 doc : `str` 

183 Updated function documentation. 

184 """ 

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

186 doc = helpText 

187 else: 

188 # See click documentation for details: 

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

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

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

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

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

194 

195 doclines = doc.splitlines() 

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

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

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

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

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

201 doclines.insert(1, helpText) 

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

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

204 return doc 

205 

206 

207def split_commas(context, param, values): 

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

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

210 

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

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

213 

214 Parameters 

215 ---------- 

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

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

218 callbacks. 

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

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

221 callbacks. 

222 values : [`str`] 

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

224 which will be treated as delimiters for separate values. 

225 

226 Returns 

227 ------- 

228 list of string 

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

230 list. 

231 """ 

232 if values is None: 

233 return values 

234 valueList = [] 

235 for value in ensure_iterable(values): 

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

237 return tuple(valueList) 

238 

239 

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

241 unseparated_okay=False, return_type=dict, default_key="", reverse_kv=False, 

242 add_to_default=False): 

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

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

245 all the passed-in values. 

246 

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

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

249 

250 Parameters 

251 ---------- 

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

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

254 callbacks. 

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

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

257 callbacks. 

258 values : [`str`] 

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

260 which will be treated as delimiters for separate values. 

261 choice : `click.Choice`, optional 

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

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

264 default None 

265 multiple : `bool`, optional 

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

267 default True. 

268 normalize : `bool`, optional 

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

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

271 separator : str, optional 

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

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

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

275 unseparated_okay : `bool`, optional 

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

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

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

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

280 The type of the value that should be returned. 

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

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

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

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

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

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

287 right. By default `dict`. 

288 default_key : `Any` 

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

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

291 ``unseparated_okay`` to be `True`.) 

292 reverse_kv : bool 

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

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

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

296 add_to_default : `bool`, optional 

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

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

299 same key(s) as the default value. 

300 

301 Returns 

302 ------- 

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

304 The passed-in values in dict form. 

305 

306 Raises 

307 ------ 

308 `click.ClickException` 

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

310 are encountered. 

311 """ 

312 

313 def norm(val): 

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

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

316 choices. 

317 

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

319 instance to verify val is a valid choice. 

320 """ 

321 if normalize and choice is not None: 

322 v = val.casefold() 

323 for opt in choice.choices: 

324 if opt.casefold() == v: 

325 return opt 

326 return val 

327 

328 class RetDict: 

329 

330 def __init__(self): 

331 self.ret = {} 

332 

333 def add(self, key, val): 

334 if reverse_kv: 

335 key, val = val, key 

336 self.ret[key] = val 

337 

338 def get(self): 

339 return self.ret 

340 

341 class RetTuple: 

342 

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.append((key, val)) 

350 

351 def get(self): 

352 return tuple(self.ret) 

353 

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

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

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

357 

358 if add_to_default: 

359 default = param.get_default(context) 

360 if default: 

361 vals = itertools.chain(default, vals) 

362 

363 if return_type is dict: 

364 ret = RetDict() 

365 elif return_type is tuple: 

366 ret = RetTuple() 

367 else: 

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

369 if multiple: 

370 vals = split_commas(context, param, vals) 

371 for val in ensure_iterable(vals): 

372 if unseparated_okay and separator not in val: 

373 if choice is not None: 

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

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

376 else: 

377 try: 

378 k, v = val.split(separator) 

379 if choice is not None: 

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

381 except ValueError: 

382 raise click.ClickException( 

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

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

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

386 return ret.get() 

387 

388 

389def to_upper(context, param, value): 

390 """Convert a value to upper case. 

391 

392 Parameters 

393 ---------- 

394 context : click.Context 

395 

396 values : string 

397 The value to be converted. 

398 

399 Returns 

400 ------- 

401 string 

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

403 """ 

404 return value.upper() 

405 

406 

407def unwrap(val): 

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

409 a consistent indentation level. 

410 

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

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

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

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

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

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

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

418 

419 Parameters 

420 ---------- 

421 val : `str` 

422 The string to change. 

423 

424 Returns 

425 ------- 

426 strippedString : `str` 

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

428 whitespace removed. 

429 """ 

430 def splitSection(val): 

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

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

433 firstLine += " " 

434 else: 

435 firstLine = "" 

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

437 

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

439 

440 

441class option_section: # noqa: N801 

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

443 command. 

444 

445 Parameters 

446 ---------- 

447 sectionText : `str` 

448 The text to print in the section identifier. 

449 """ 

450 

451 def __init__(self, sectionText): 

452 self.sectionText = "\n" + sectionText 

453 

454 def __call__(self, f): 

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

456 # section. 

457 return click.option(f"--option-section-{str(uuid.uuid4())}", 

458 sectionText=self.sectionText, 

459 cls=OptionSection)(f) 

460 

461 

462class MWPath(click.Path): 

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

464 

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

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

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

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

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

470 that it is required to not exist). 

471 

472 Parameters 

473 ---------- 

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

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

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

477 location may exist or not. 

478 

479 For other parameters see `click.Path`. 

480 """ 

481 

482 def __init__(self, exists=None, file_okay=True, dir_okay=True, 

483 writable=False, readable=True, resolve_path=False, 

484 allow_dash=False, path_type=None): 

485 self.mustNotExist = exists is False 

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

487 exists = False 

488 super().__init__(exists, file_okay, dir_okay, writable, readable, 

489 resolve_path, allow_dash, path_type) 

490 

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

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

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

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

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

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

497 

498 

499class MWOption(click.Option): 

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

501 

502 def make_metavar(self): 

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

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

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

506 implementation. 

507 

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

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

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

511 space between. 

512 

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

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

515 get_help_record. 

516 """ 

517 metavar = super().make_metavar() 

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

519 metavar += " ..." 

520 elif self.nargs != 1: 

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

522 return metavar 

523 

524 

525class MWArgument(click.Argument): 

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

527 

528 def make_metavar(self): 

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

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

531 metavar name if the option accepts multiple inputs. 

532 

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

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

535 

536 Returns 

537 ------- 

538 metavar : `str` 

539 The metavar value. 

540 """ 

541 metavar = super().make_metavar() 

542 if self.nargs != 1: 

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

544 return metavar 

545 

546 

547class OptionSection(MWOption): 

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

549 does not pass any value to the command function. 

550 

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

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

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

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

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

556 

557 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

566 

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

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

569 internals change. 

570 

571 Parameters 

572 ---------- 

573 sectionName : `str` 

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

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

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

577 auto-generated. 

578 sectionText : `str` 

579 The text to print in the section identifier. 

580 """ 

581 

582 @property 

583 def hidden(self): 

584 return True 

585 

586 @hidden.setter 

587 def hidden(self, val): 

588 pass 

589 

590 def __init__(self, sectionName, sectionText): 

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

592 self.sectionText = sectionText 

593 

594 def get_help_record(self, ctx): 

595 return (self.sectionText, "") 

596 

597 

598class MWOptionDecorator: 

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

600 and allows inspection of the shared option. 

601 """ 

602 

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

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

605 **kwargs) 

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

607 self._name = opt.name 

608 self._opts = opt.opts 

609 

610 def name(self): 

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

612 option.""" 

613 return self._name 

614 

615 def opts(self): 

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

617 line.""" 

618 return self._opts 

619 

620 @property 

621 def help(self): 

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

623 help was defined.""" 

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

625 

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

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

628 

629 

630class MWArgumentDecorator: 

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

632 declared. """ 

633 

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

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

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

637 

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

639 def decorator(f): 

640 if help is not None: 

641 self._helpText = help 

642 if self._helpText: 

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

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

645 return decorator 

646 

647 

648class MWCommand(click.Command): 

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

650 command.""" 

651 

652 extra_epilog = None 

653 

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

655 # wrap callback method with catch_and_exit decorator 

656 callback = kwargs.get("callback") 

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

658 kwargs = kwargs.copy() 

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

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

661 

662 def parse_args(self, ctx, args): 

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

664 super().parse_args(ctx, args) 

665 

666 @property 

667 def epilog(self): 

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

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

670 """ 

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

672 if self.extra_epilog: 

673 if ret: 

674 ret += "\n\n" 

675 ret += self.extra_epilog 

676 return ret 

677 

678 @epilog.setter 

679 def epilog(self, val): 

680 self._epilog = val 

681 

682 

683class ButlerCommand(MWCommand): 

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

685 

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

687 

688 

689class OptionGroup: 

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

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

692 

693 def __call__(self, f): 

694 for decorator in reversed(self.decorators): 

695 f = decorator(f) 

696 return f 

697 

698 

699class MWCtxObj(): 

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

701 obj data to be managed in a consistent way. 

702 

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

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

705 

706 Attributes 

707 ---------- 

708 args : `list` [`str`] 

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

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

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

712 """ 

713 

714 def __init__(self): 

715 

716 self.args = None 

717 

718 @staticmethod 

719 def getFrom(ctx): 

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

721 new or already existing MWCtxObj.""" 

722 if ctx.obj is not None: 

723 return ctx.obj 

724 ctx.obj = MWCtxObj() 

725 return ctx.obj 

726 

727 

728def yaml_presets(ctx, param, value): 

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

730 YAML file. 

731 

732 Parameters 

733 ---------- 

734 ctx : `click.context` 

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

736 name and translate option & argument names. 

737 param : `str` 

738 The parameter name. 

739 value : `object` 

740 The value of the parameter. 

741 """ 

742 

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

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

745 command function. 

746 

747 Parameters 

748 ---------- 

749 ctx : `click.Context` 

750 The context for the click operation. 

751 option : `str` 

752 The option/argument name from the yaml file. 

753 

754 Returns 

755 ------- 

756 name : str 

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

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

759 

760 Raises 

761 ------ 

762 RuntimeError 

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

764 command parameters. This catches misspellings and incorrect useage 

765 in the yaml file. 

766 """ 

767 for param in ctx.command.params: 

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

769 # yaml file. 

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

771 return param.name 

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

773 

774 ctx.default_map = ctx.default_map or {} 

775 cmd_name = ctx.info_name 

776 if value: 

777 try: 

778 overrides = _read_yaml_presets(value, cmd_name) 

779 options = list(overrides.keys()) 

780 for option in options: 

781 name = _name_for_option(ctx, option) 

782 if name == option: 

783 continue 

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

785 except Exception as e: 

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

787 # Override the defaults for this subcommand 

788 ctx.default_map.update(overrides) 

789 return 

790 

791 

792def _read_yaml_presets(file_uri, cmd_name): 

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

794 

795 Parameters 

796 ---------- 

797 file_uri : `str` 

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

799 They should be grouped by command name. 

800 cmd_name : `str` 

801 The subcommand name that is being modified. 

802 

803 Returns 

804 ------- 

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

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

807 """ 

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

809 config = Config(file_uri) 

810 return config[cmd_name] 

811 

812 

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

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

815 order: 

816 1. the provided named columns 

817 2. spatial and temporal columns 

818 3. the rest of the columns 

819 

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

821 

822 Parameters 

823 ---------- 

824 table : `astropy.table.Table` 

825 The table to sort 

826 dimensions : `list` [``Dimension``] 

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

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

829 spatial, temporal, or neither. 

830 sort_first : `list` [`str`] 

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

832 temporal columns. 

833 

834 Returns 

835 ------- 

836 `astropy.table.Table` 

837 For convenience, the table that has been sorted. 

838 """ 

839 # For sorting we want to ignore the id 

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

841 sort_first = sort_first or [] 

842 sort_early = [] 

843 sort_late = [] 

844 for dim in dimensions: 

845 if dim.spatial or dim.temporal: 

846 sort_early.extend(dim.required.names) 

847 else: 

848 sort_late.append(str(dim)) 

849 sort_keys = sort_first + sort_early + sort_late 

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

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

852 # (order is retained by dict creation). 

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

854 

855 table.sort(sort_keys) 

856 return table 

857 

858 

859def catch_and_exit(func): 

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

861 and signals click to exit. 

862 """ 

863 @wraps(func) 

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

865 try: 

866 func(*args, **kwargs) 

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

868 # this is handled by click itself 

869 raise 

870 except Exception: 

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

872 if exc_tb.tb_next: 

873 # do not show this decorator in traceback 

874 exc_tb = exc_tb.tb_next 

875 log.exception("Caught an exception, details are in traceback:", 

876 exc_info=(exc_type, exc_value, exc_tb)) 

877 # tell click to stop, this never returns. 

878 click.get_current_context().exit(1) 

879 

880 return inner