Hide keyboard shortcuts

Hot-keys 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

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.testing 

24from contextlib import contextmanager 

25import copy 

26from functools import partial 

27import itertools 

28import io 

29import logging 

30import os 

31import textwrap 

32import traceback 

33from unittest.mock import MagicMock, patch 

34import uuid 

35import yaml 

36 

37from .cliLog import CliLog 

38from ..core.utils import iterable 

39from ..core.config import Config 

40 

41log = logging.getLogger(__name__) 

42 

43# CLI_MOCK_ENV is set by some tests as an environment variable, it 

44# indicates to the cli_handle_exception function that instead of executing the 

45# command implementation function it should use the Mocker class for unit test 

46# verification. 

47mockEnvVarKey = "CLI_MOCK_ENV" 

48mockEnvVar = {mockEnvVarKey: "1"} 

49 

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

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

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

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

54# callback=split_kv. 

55typeStrAcceptsMultiple = "TEXT ..." 

56typeStrAcceptsSingle = "TEXT" 

57 

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

59# for those inputs. 

60split_kv_separator = "=" 

61 

62 

63def textTypeStr(multiple): 

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

65 

66 Parameters 

67 ---------- 

68 multiple : `bool` 

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

70 allowed. 

71 

72 Returns 

73 ------- 

74 textTypeStr : `str` 

75 The type string to use. 

76 """ 

77 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

78 

79 

80class Mocker: 

81 

82 mock = MagicMock() 

83 

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

85 """Mocker is a helper class for unit tests. It can be imported and 

86 called and later imported again and call can be verified. 

87 

88 For convenience, constructor arguments are forwarded to the call 

89 function. 

90 """ 

91 self.__call__(*args, **kwargs) 

92 

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

94 """Creates a MagicMock and stores it in a static variable that can 

95 later be verified. 

96 """ 

97 Mocker.mock(*args, **kwargs) 

98 

99 @classmethod 

100 def reset(cls): 

101 cls.mock.reset_mock() 

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

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

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

199 return doc 

200 

201 

202def split_commas(context, param, values): 

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

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

205 

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

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

208 

209 Parameters 

210 ---------- 

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

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

213 callbacks. 

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

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

216 callbacks. 

217 values : [`str`] 

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

219 which will be treated as delimiters for separate values. 

220 

221 Returns 

222 ------- 

223 list of string 

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

225 list. 

226 """ 

227 if values is None: 

228 return values 

229 valueList = [] 

230 for value in iterable(values): 

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

232 return tuple(valueList) 

233 

234 

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

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

237 add_to_default=False): 

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

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

240 all the passed-in values. 

241 

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

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

244 

245 Parameters 

246 ---------- 

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

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

249 callbacks. 

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

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

252 callbacks. 

253 values : [`str`] 

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

255 which will be treated as delimiters for separate values. 

256 choice : `click.Choice`, optional 

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

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

259 default None 

260 multiple : `bool`, optional 

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

262 default True. 

263 normalize : `bool`, optional 

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

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

266 separator : str, optional 

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

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

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

270 unseparated_okay : `bool`, optional 

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

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

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

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

275 The type of the value that should be returned. 

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

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

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

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

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

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

282 right. By default `dict`. 

283 default_key : `Any` 

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

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

286 ``unseparated_okay`` to be `True`.) 

287 reverse_kv : bool 

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

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

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

291 add_to_default : `bool`, optional 

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

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

294 same key(s) as the default value. 

295 

296 Returns 

297 ------- 

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

299 The passed-in values in dict form. 

300 

301 Raises 

302 ------ 

303 `click.ClickException` 

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

305 are encountered. 

306 """ 

307 

308 def norm(val): 

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

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

311 choices. 

312 

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

314 instance to verify val is a valid choice. 

315 """ 

316 if normalize and choice is not None: 

317 v = val.casefold() 

318 for opt in choice.choices: 

319 if opt.casefold() == v: 

320 return opt 

321 return val 

322 

323 class RetDict: 

324 

325 def __init__(self): 

326 self.ret = {} 

327 

328 def add(self, key, val): 

329 if reverse_kv: 

330 key, val = val, key 

331 self.ret[key] = val 

332 

333 def get(self): 

334 return self.ret 

335 

336 class RetTuple: 

337 

338 def __init__(self): 

339 self.ret = [] 

340 

341 def add(self, key, val): 

342 if reverse_kv: 

343 key, val = val, key 

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

345 

346 def get(self): 

347 return tuple(self.ret) 

348 

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

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

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

352 

353 if add_to_default: 

354 default = param.get_default(context) 

355 if default: 

356 vals = itertools.chain(default, vals) 

357 

358 if return_type is dict: 

359 ret = RetDict() 

360 elif return_type is tuple: 

361 ret = RetTuple() 

362 else: 

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

364 if multiple: 

365 vals = split_commas(context, param, vals) 

366 for val in iterable(vals): 

367 if unseparated_okay and separator not in val: 

368 if choice is not None: 

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

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

371 else: 

372 try: 

373 k, v = val.split(separator) 

374 if choice is not None: 

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

376 except ValueError: 

377 raise click.ClickException( 

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

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

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

381 return ret.get() 

382 

383 

384def to_upper(context, param, value): 

385 """Convert a value to upper case. 

386 

387 Parameters 

388 ---------- 

389 context : click.Context 

390 

391 values : string 

392 The value to be converted. 

393 

394 Returns 

395 ------- 

396 string 

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

398 """ 

399 return value.upper() 

400 

401 

402def unwrap(val): 

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

404 a consistent indentation level. 

405 

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

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

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

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

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

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

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

413 

414 Parameters 

415 ---------- 

416 val : `str` 

417 The string to change. 

418 

419 Returns 

420 ------- 

421 strippedString : `str` 

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

423 whitespace removed. 

424 """ 

425 def splitSection(val): 

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

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

428 firstLine += " " 

429 else: 

430 firstLine = "" 

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

432 

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

434 

435 

436def cli_handle_exception(func, *args, **kwargs): 

437 """Wrap a function call in an exception handler that raises a 

438 ClickException if there is an Exception. 

439 

440 Also provides support for unit testing by testing for an environment 

441 variable, and if it is present prints the function name, args, and kwargs 

442 to stdout so they can be read and verified by the unit test code. 

443 

444 Parameters 

445 ---------- 

446 func : function 

447 A function to be called and exceptions handled. Will pass args & kwargs 

448 to the function. 

449 

450 Returns 

451 ------- 

452 The result of calling func. 

453 

454 Raises 

455 ------ 

456 click.ClickException 

457 An exception to be handled by the Click CLI tool. 

458 """ 

459 if mockEnvVarKey in os.environ: 

460 Mocker(*args, **kwargs) 

461 return 

462 

463 try: 

464 return func(*args, **kwargs) 

465 except Exception as e: 

466 msg = io.StringIO() 

467 traceback.print_exc(file=msg) 

468 log.debug(msg.getvalue()) 

469 raise click.ClickException(e) from e 

470 

471 

472class option_section: # noqa: N801 

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

474 command. 

475 

476 Parameters 

477 ---------- 

478 sectionText : `str` 

479 The text to print in the section identifier. 

480 """ 

481 

482 def __init__(self, sectionText): 

483 self.sectionText = "\n" + sectionText 

484 

485 def __call__(self, f): 

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

487 # section. 

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

489 sectionText=self.sectionText, 

490 cls=OptionSection)(f) 

491 

492 

493class MWPath(click.Path): 

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

495 

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

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

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

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

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

501 that it is required to not exist). 

502 

503 Parameters 

504 ---------- 

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

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

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

508 location may exist or not. 

509 

510 For other parameters see `click.Path`. 

511 """ 

512 

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

514 writable=False, readable=True, resolve_path=False, 

515 allow_dash=False, path_type=None): 

516 self.mustNotExist = exists is False 

517 if exists is None: 

518 exists = False 

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

520 resolve_path, allow_dash, path_type) 

521 

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

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

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

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

526 self.fail(f'{self.path_type} "{value}" should not exist.') 

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

528 

529 

530class MWOption(click.Option): 

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

532 

533 def make_metavar(self): 

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

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

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

537 implementation. 

538 

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

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

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

542 space between. 

543 

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

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

546 get_help_record. 

547 """ 

548 metavar = super().make_metavar() 

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

550 metavar += " ..." 

551 elif self.nargs != 1: 

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

553 return metavar 

554 

555 

556class MWArgument(click.Argument): 

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

558 

559 def make_metavar(self): 

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

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

562 metavar name if the option accepts multiple inputs. 

563 

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

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

566 

567 Returns 

568 ------- 

569 metavar : `str` 

570 The metavar value. 

571 """ 

572 metavar = super().make_metavar() 

573 if self.nargs != 1: 

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

575 return metavar 

576 

577 

578class OptionSection(MWOption): 

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

580 does not pass any value to the command function. 

581 

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

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

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

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

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

587 

588 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

597 

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

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

600 internals change. 

601 

602 Parameters 

603 ---------- 

604 sectionName : `str` 

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

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

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

608 auto-generated. 

609 sectionText : `str` 

610 The text to print in the section identifier. 

611 """ 

612 

613 @property 

614 def hidden(self): 

615 return True 

616 

617 @hidden.setter 

618 def hidden(self, val): 

619 pass 

620 

621 def __init__(self, sectionName, sectionText): 

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

623 self.sectionText = sectionText 

624 

625 def get_help_record(self, ctx): 

626 return (self.sectionText, "") 

627 

628 

629class MWOptionDecorator: 

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

631 and allows inspection of the shared option. 

632 """ 

633 

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

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

636 **kwargs) 

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

638 self._name = opt.name 

639 self._opts = opt.opts 

640 

641 def name(self): 

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

643 option.""" 

644 return self._name 

645 

646 def opts(self): 

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

648 line.""" 

649 return self._opts 

650 

651 @property 

652 def help(self): 

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

654 help was defined.""" 

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

656 

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

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

659 

660 

661class MWArgumentDecorator: 

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

663 declared. """ 

664 

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

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

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

668 

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

670 def decorator(f): 

671 if help is not None: 

672 self._helpText = help 

673 if self._helpText: 673 ↛ 675line 673 didn't jump to line 675, because the condition on line 673 was never false

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

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

676 return decorator 

677 

678 

679class MWCommand(click.Command): 

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

681 command.""" 

682 

683 extra_epilog = None 

684 

685 def parse_args(self, ctx, args): 

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

687 super().parse_args(ctx, args) 

688 

689 @property 

690 def epilog(self): 

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

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

693 """ 

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

695 if self.extra_epilog: 

696 if ret: 

697 ret += "\n\n" 

698 ret += self.extra_epilog 

699 return ret 

700 

701 @epilog.setter 

702 def epilog(self, val): 

703 self._epilog = val 

704 

705 

706class ButlerCommand(MWCommand): 

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

708 

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

710 

711 

712class MWCtxObj(): 

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

714 obj data to be managed in a consistent way. 

715 

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

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

718 

719 Attributes 

720 ---------- 

721 args : `list` [`str`] 

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

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

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

725 """ 

726 

727 def __init__(self): 

728 

729 self.args = None 

730 

731 @staticmethod 

732 def getFrom(ctx): 

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

734 new or already existing MWCtxObj.""" 

735 if ctx.obj is not None: 

736 return ctx.obj 

737 ctx.obj = MWCtxObj() 

738 return ctx.obj 

739 

740 

741def yaml_presets(ctx, param, value): 

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

743 YAML file. 

744 

745 Parameters 

746 ---------- 

747 ctx : `click.context` 

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

749 name. 

750 param : `str` 

751 The parameter name. 

752 value : `object` 

753 The value of the parameter. 

754 """ 

755 ctx.default_map = ctx.default_map or {} 

756 cmd_name = ctx.info_name 

757 if value: 

758 try: 

759 overrides = _read_yaml_presets(value, cmd_name) 

760 except Exception as e: 

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

762 # Override the defaults for this subcommand 

763 ctx.default_map.update(overrides) 

764 return 

765 

766 

767def _read_yaml_presets(file_uri, cmd_name): 

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

769 

770 Parameters 

771 ---------- 

772 file_uri : `str` 

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

774 They should be grouped by command name. 

775 cmd_name : `str` 

776 The subcommand name that is being modified. 

777 

778 Returns 

779 ------- 

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

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

782 """ 

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

784 config = Config(file_uri) 

785 return config[cmd_name] 

786 

787 

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

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

790 order: 

791 1. the provided named columns 

792 2. spatial and temporal columns 

793 3. the rest of the columns 

794 

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

796 

797 Parameters 

798 ---------- 

799 table : `astropy.table.Table` 

800 The table to sort 

801 dimensions : `list` [``Dimension``] 

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

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

804 spatial, temporal, or neither. 

805 sort_first : `list` [`str`] 

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

807 temporal columns. 

808 

809 Returns 

810 ------- 

811 `astropy.table.Table` 

812 For convenience, the table that has been sorted. 

813 """ 

814 # For sorting we want to ignore the id 

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

816 sort_first = sort_first or [] 

817 sort_early = [] 

818 sort_late = [] 

819 for dim in dimensions: 

820 if dim.spatial or dim.temporal: 

821 sort_early.extend(dim.required.names) 

822 else: 

823 sort_late.append(str(dim)) 

824 sort_keys = sort_first + sort_early + sort_late 

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

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

827 # (order is retained by dict creation). 

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

829 

830 table.sort(sort_keys) 

831 return table