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 logging 

29import os 

30import textwrap 

31import traceback 

32from unittest.mock import patch 

33import uuid 

34import yaml 

35 

36from .cliLog import CliLog 

37from ..core.utils import iterable 

38from ..core.config import Config 

39 

40log = logging.getLogger(__name__) 

41 

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

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

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

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

46# callback=split_kv. 

47typeStrAcceptsMultiple = "TEXT ..." 

48typeStrAcceptsSingle = "TEXT" 

49 

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

51# for those inputs. 

52split_kv_separator = "=" 

53 

54 

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

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

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

58 "dimension table." 

59 

60 

61def astropyTablesToStr(tables): 

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

63 

64 Output formatting matches ``printAstropyTables``. 

65 """ 

66 ret = "" 

67 for table in tables: 

68 ret += "\n" 

69 table.pformat_all() 

70 ret += "\n" 

71 return ret 

72 

73 

74def printAstropyTables(tables): 

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

76 

77 Output formatting matches ``astropyTablesToStr``. 

78 """ 

79 for table in tables: 

80 print("") 

81 table.pprint_all() 

82 print("") 

83 

84 

85def textTypeStr(multiple): 

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

87 

88 Parameters 

89 ---------- 

90 multiple : `bool` 

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

92 allowed. 

93 

94 Returns 

95 ------- 

96 textTypeStr : `str` 

97 The type string to use. 

98 """ 

99 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

100 

101 

102class LogCliRunner(click.testing.CliRunner): 

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

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

105 was done with the CliLog interface. 

106 

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

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

109 `CliLog.defaultLsstLogLevel`.""" 

110 

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

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

113 CliLog.resetLog() 

114 return result 

115 

116 

117def clickResultMsg(result): 

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

119 

120 Parameters 

121 ---------- 

122 result : click.Result 

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

124 

125 Returns 

126 ------- 

127 msg : `str` 

128 The message string. 

129 """ 

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

131 if result.exception: 

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

133 return msg 

134 

135 

136@contextmanager 

137def command_test_env(runner, commandModule, commandName): 

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

139 provides a CLI plugin command with the given name. 

140 

141 Parameters 

142 ---------- 

143 runner : click.testing.CliRunner 

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

145 commandModule : `str` 

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

147 commandName : `str` 

148 The name of the command being published to import. 

149 """ 

150 with runner.isolated_filesystem(): 

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

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

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

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

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

156 # is properly stripped out. 

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

158 yield 

159 

160 

161def addArgumentHelp(doc, helpText): 

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

163 

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

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

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

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

168 from the order they are applied in. 

169 

170 Parameters 

171 ---------- 

172 doc : `str` 

173 The function's docstring. 

174 helpText : `str` 

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

176 docstring. 

177 

178 Returns 

179 ------- 

180 doc : `str` 

181 Updated function documentation. 

182 """ 

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

184 doc = helpText 

185 else: 

186 # See click documentation for details: 

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

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

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

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

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

192 

193 doclines = doc.splitlines() 

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

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

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

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

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

199 doclines.insert(1, helpText) 

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

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

202 return doc 

203 

204 

205def split_commas(context, param, values): 

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

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

208 

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

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

211 

212 Parameters 

213 ---------- 

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

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

216 callbacks. 

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

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

219 callbacks. 

220 values : [`str`] 

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

222 which will be treated as delimiters for separate values. 

223 

224 Returns 

225 ------- 

226 list of string 

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

228 list. 

229 """ 

230 if values is None: 

231 return values 

232 valueList = [] 

233 for value in iterable(values): 

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

235 return tuple(valueList) 

236 

237 

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

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

240 add_to_default=False): 

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

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

243 all the passed-in values. 

244 

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

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

247 

248 Parameters 

249 ---------- 

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

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

252 callbacks. 

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

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

255 callbacks. 

256 values : [`str`] 

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

258 which will be treated as delimiters for separate values. 

259 choice : `click.Choice`, optional 

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

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

262 default None 

263 multiple : `bool`, optional 

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

265 default True. 

266 normalize : `bool`, optional 

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

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

269 separator : str, optional 

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

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

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

273 unseparated_okay : `bool`, optional 

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

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

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

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

278 The type of the value that should be returned. 

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

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

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

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

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

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

285 right. By default `dict`. 

286 default_key : `Any` 

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

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

289 ``unseparated_okay`` to be `True`.) 

290 reverse_kv : bool 

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

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

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

294 add_to_default : `bool`, optional 

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

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

297 same key(s) as the default value. 

298 

299 Returns 

300 ------- 

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

302 The passed-in values in dict form. 

303 

304 Raises 

305 ------ 

306 `click.ClickException` 

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

308 are encountered. 

309 """ 

310 

311 def norm(val): 

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

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

314 choices. 

315 

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

317 instance to verify val is a valid choice. 

318 """ 

319 if normalize and choice is not None: 

320 v = val.casefold() 

321 for opt in choice.choices: 

322 if opt.casefold() == v: 

323 return opt 

324 return val 

325 

326 class RetDict: 

327 

328 def __init__(self): 

329 self.ret = {} 

330 

331 def add(self, key, val): 

332 if reverse_kv: 

333 key, val = val, key 

334 self.ret[key] = val 

335 

336 def get(self): 

337 return self.ret 

338 

339 class RetTuple: 

340 

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

348 

349 def get(self): 

350 return tuple(self.ret) 

351 

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

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

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

355 

356 if add_to_default: 

357 default = param.get_default(context) 

358 if default: 

359 vals = itertools.chain(default, vals) 

360 

361 if return_type is dict: 

362 ret = RetDict() 

363 elif return_type is tuple: 

364 ret = RetTuple() 

365 else: 

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

367 if multiple: 

368 vals = split_commas(context, param, vals) 

369 for val in iterable(vals): 

370 if unseparated_okay and separator not in val: 

371 if choice is not None: 

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

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

374 else: 

375 try: 

376 k, v = val.split(separator) 

377 if choice is not None: 

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

379 except ValueError: 

380 raise click.ClickException( 

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

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

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

384 return ret.get() 

385 

386 

387def to_upper(context, param, value): 

388 """Convert a value to upper case. 

389 

390 Parameters 

391 ---------- 

392 context : click.Context 

393 

394 values : string 

395 The value to be converted. 

396 

397 Returns 

398 ------- 

399 string 

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

401 """ 

402 return value.upper() 

403 

404 

405def unwrap(val): 

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

407 a consistent indentation level. 

408 

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

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

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

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

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

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

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

416 

417 Parameters 

418 ---------- 

419 val : `str` 

420 The string to change. 

421 

422 Returns 

423 ------- 

424 strippedString : `str` 

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

426 whitespace removed. 

427 """ 

428 def splitSection(val): 

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

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

431 firstLine += " " 

432 else: 

433 firstLine = "" 

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

435 

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

437 

438 

439class option_section: # noqa: N801 

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

441 command. 

442 

443 Parameters 

444 ---------- 

445 sectionText : `str` 

446 The text to print in the section identifier. 

447 """ 

448 

449 def __init__(self, sectionText): 

450 self.sectionText = "\n" + sectionText 

451 

452 def __call__(self, f): 

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

454 # section. 

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

456 sectionText=self.sectionText, 

457 cls=OptionSection)(f) 

458 

459 

460class MWPath(click.Path): 

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

462 

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

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

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

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

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

468 that it is required to not exist). 

469 

470 Parameters 

471 ---------- 

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

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

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

475 location may exist or not. 

476 

477 For other parameters see `click.Path`. 

478 """ 

479 

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

481 writable=False, readable=True, resolve_path=False, 

482 allow_dash=False, path_type=None): 

483 self.mustNotExist = exists is False 

484 if exists is None: 

485 exists = False 

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

487 resolve_path, allow_dash, path_type) 

488 

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

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

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

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

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

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

495 

496 

497class MWOption(click.Option): 

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

499 

500 def make_metavar(self): 

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

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

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

504 implementation. 

505 

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

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

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

509 space between. 

510 

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

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

513 get_help_record. 

514 """ 

515 metavar = super().make_metavar() 

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

517 metavar += " ..." 

518 elif self.nargs != 1: 

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

520 return metavar 

521 

522 

523class MWArgument(click.Argument): 

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

525 

526 def make_metavar(self): 

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

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

529 metavar name if the option accepts multiple inputs. 

530 

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

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

533 

534 Returns 

535 ------- 

536 metavar : `str` 

537 The metavar value. 

538 """ 

539 metavar = super().make_metavar() 

540 if self.nargs != 1: 

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

542 return metavar 

543 

544 

545class OptionSection(MWOption): 

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

547 does not pass any value to the command function. 

548 

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

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

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

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

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

554 

555 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

564 

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

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

567 internals change. 

568 

569 Parameters 

570 ---------- 

571 sectionName : `str` 

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

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

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

575 auto-generated. 

576 sectionText : `str` 

577 The text to print in the section identifier. 

578 """ 

579 

580 @property 

581 def hidden(self): 

582 return True 

583 

584 @hidden.setter 

585 def hidden(self, val): 

586 pass 

587 

588 def __init__(self, sectionName, sectionText): 

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

590 self.sectionText = sectionText 

591 

592 def get_help_record(self, ctx): 

593 return (self.sectionText, "") 

594 

595 

596class MWOptionDecorator: 

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

598 and allows inspection of the shared option. 

599 """ 

600 

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

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

603 **kwargs) 

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

605 self._name = opt.name 

606 self._opts = opt.opts 

607 

608 def name(self): 

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

610 option.""" 

611 return self._name 

612 

613 def opts(self): 

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

615 line.""" 

616 return self._opts 

617 

618 @property 

619 def help(self): 

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

621 help was defined.""" 

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

623 

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

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

626 

627 

628class MWArgumentDecorator: 

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

630 declared. """ 

631 

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

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

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

635 

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

637 def decorator(f): 

638 if help is not None: 

639 self._helpText = help 

640 if self._helpText: 

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

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

643 return decorator 

644 

645 

646class MWCommand(click.Command): 

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

648 command.""" 

649 

650 extra_epilog = None 

651 

652 def parse_args(self, ctx, args): 

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

654 super().parse_args(ctx, args) 

655 

656 @property 

657 def epilog(self): 

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

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

660 """ 

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

662 if self.extra_epilog: 

663 if ret: 

664 ret += "\n\n" 

665 ret += self.extra_epilog 

666 return ret 

667 

668 @epilog.setter 

669 def epilog(self, val): 

670 self._epilog = val 

671 

672 

673class ButlerCommand(MWCommand): 

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

675 

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

677 

678 

679class OptionGroup: 

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

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

682 

683 def __call__(self, f): 

684 for decorator in reversed(self.decorators): 

685 f = decorator(f) 

686 return f 

687 

688 

689class MWCtxObj(): 

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

691 obj data to be managed in a consistent way. 

692 

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

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

695 

696 Attributes 

697 ---------- 

698 args : `list` [`str`] 

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

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

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

702 """ 

703 

704 def __init__(self): 

705 

706 self.args = None 

707 

708 @staticmethod 

709 def getFrom(ctx): 

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

711 new or already existing MWCtxObj.""" 

712 if ctx.obj is not None: 

713 return ctx.obj 

714 ctx.obj = MWCtxObj() 

715 return ctx.obj 

716 

717 

718def yaml_presets(ctx, param, value): 

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

720 YAML file. 

721 

722 Parameters 

723 ---------- 

724 ctx : `click.context` 

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

726 name. 

727 param : `str` 

728 The parameter name. 

729 value : `object` 

730 The value of the parameter. 

731 """ 

732 ctx.default_map = ctx.default_map or {} 

733 cmd_name = ctx.info_name 

734 if value: 

735 try: 

736 overrides = _read_yaml_presets(value, cmd_name) 

737 except Exception as e: 

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

739 # Override the defaults for this subcommand 

740 ctx.default_map.update(overrides) 

741 return 

742 

743 

744def _read_yaml_presets(file_uri, cmd_name): 

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

746 

747 Parameters 

748 ---------- 

749 file_uri : `str` 

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

751 They should be grouped by command name. 

752 cmd_name : `str` 

753 The subcommand name that is being modified. 

754 

755 Returns 

756 ------- 

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

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

759 """ 

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

761 config = Config(file_uri) 

762 return config[cmd_name] 

763 

764 

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

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

767 order: 

768 1. the provided named columns 

769 2. spatial and temporal columns 

770 3. the rest of the columns 

771 

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

773 

774 Parameters 

775 ---------- 

776 table : `astropy.table.Table` 

777 The table to sort 

778 dimensions : `list` [``Dimension``] 

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

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

781 spatial, temporal, or neither. 

782 sort_first : `list` [`str`] 

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

784 temporal columns. 

785 

786 Returns 

787 ------- 

788 `astropy.table.Table` 

789 For convenience, the table that has been sorted. 

790 """ 

791 # For sorting we want to ignore the id 

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

793 sort_first = sort_first or [] 

794 sort_early = [] 

795 sort_late = [] 

796 for dim in dimensions: 

797 if dim.spatial or dim.temporal: 

798 sort_early.extend(dim.required.names) 

799 else: 

800 sort_late.append(str(dim)) 

801 sort_keys = sort_first + sort_early + sort_late 

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

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

804 # (order is retained by dict creation). 

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

806 

807 table.sort(sort_keys) 

808 return table