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 

55def textTypeStr(multiple): 

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

57 

58 Parameters 

59 ---------- 

60 multiple : `bool` 

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

62 allowed. 

63 

64 Returns 

65 ------- 

66 textTypeStr : `str` 

67 The type string to use. 

68 """ 

69 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

70 

71 

72class LogCliRunner(click.testing.CliRunner): 

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

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

75 was done with the CliLog interface. 

76 

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

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

79 `CliLog.defaultLsstLogLevel`.""" 

80 

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

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

83 CliLog.resetLog() 

84 return result 

85 

86 

87def clickResultMsg(result): 

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

89 

90 Parameters 

91 ---------- 

92 result : click.Result 

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

94 

95 Returns 

96 ------- 

97 msg : `str` 

98 The message string. 

99 """ 

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

101 if result.exception: 

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

103 return msg 

104 

105 

106@contextmanager 

107def command_test_env(runner, commandModule, commandName): 

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

109 provides a CLI plugin command with the given name. 

110 

111 Parameters 

112 ---------- 

113 runner : click.testing.CliRunner 

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

115 commandModule : `str` 

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

117 commandName : `str` 

118 The name of the command being published to import. 

119 """ 

120 with runner.isolated_filesystem(): 

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

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

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

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

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

126 # is properly stripped out. 

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

128 yield 

129 

130 

131def addArgumentHelp(doc, helpText): 

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

133 

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

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

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

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

138 from the order they are applied in. 

139 

140 Parameters 

141 ---------- 

142 doc : `str` 

143 The function's docstring. 

144 helpText : `str` 

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

146 docstring. 

147 

148 Returns 

149 ------- 

150 doc : `str` 

151 Updated function documentation. 

152 """ 

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

154 doc = helpText 

155 else: 

156 # See click documentation for details: 

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

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

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

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

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

162 

163 doclines = doc.splitlines() 

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

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

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

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

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

169 doclines.insert(1, helpText) 

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

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

172 return doc 

173 

174 

175def split_commas(context, param, values): 

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

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

178 

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

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

181 

182 Parameters 

183 ---------- 

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

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

186 callbacks. 

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

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

189 callbacks. 

190 values : [`str`] 

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

192 which will be treated as delimiters for separate values. 

193 

194 Returns 

195 ------- 

196 list of string 

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

198 list. 

199 """ 

200 if values is None: 

201 return values 

202 valueList = [] 

203 for value in iterable(values): 

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

205 return tuple(valueList) 

206 

207 

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

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

210 add_to_default=False): 

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

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

213 all the passed-in values. 

214 

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

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

217 

218 Parameters 

219 ---------- 

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

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

222 callbacks. 

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

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

225 callbacks. 

226 values : [`str`] 

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

228 which will be treated as delimiters for separate values. 

229 choice : `click.Choice`, optional 

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

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

232 default None 

233 multiple : `bool`, optional 

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

235 default True. 

236 normalize : `bool`, optional 

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

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

239 separator : str, optional 

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

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

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

243 unseparated_okay : `bool`, optional 

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

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

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

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

248 The type of the value that should be returned. 

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

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

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

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

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

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

255 right. By default `dict`. 

256 default_key : `Any` 

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

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

259 ``unseparated_okay`` to be `True`.) 

260 reverse_kv : bool 

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

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

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

264 add_to_default : `bool`, optional 

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

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

267 same key(s) as the default value. 

268 

269 Returns 

270 ------- 

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

272 The passed-in values in dict form. 

273 

274 Raises 

275 ------ 

276 `click.ClickException` 

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

278 are encountered. 

279 """ 

280 

281 def norm(val): 

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

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

284 choices. 

285 

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

287 instance to verify val is a valid choice. 

288 """ 

289 if normalize and choice is not None: 

290 v = val.casefold() 

291 for opt in choice.choices: 

292 if opt.casefold() == v: 

293 return opt 

294 return val 

295 

296 class RetDict: 

297 

298 def __init__(self): 

299 self.ret = {} 

300 

301 def add(self, key, val): 

302 if reverse_kv: 

303 key, val = val, key 

304 self.ret[key] = val 

305 

306 def get(self): 

307 return self.ret 

308 

309 class RetTuple: 

310 

311 def __init__(self): 

312 self.ret = [] 

313 

314 def add(self, key, val): 

315 if reverse_kv: 

316 key, val = val, key 

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

318 

319 def get(self): 

320 return tuple(self.ret) 

321 

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

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

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

325 

326 if add_to_default: 

327 default = param.get_default(context) 

328 if default: 

329 vals = itertools.chain(default, vals) 

330 

331 if return_type is dict: 

332 ret = RetDict() 

333 elif return_type is tuple: 

334 ret = RetTuple() 

335 else: 

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

337 if multiple: 

338 vals = split_commas(context, param, vals) 

339 for val in iterable(vals): 

340 if unseparated_okay and separator not in val: 

341 if choice is not None: 

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

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

344 else: 

345 try: 

346 k, v = val.split(separator) 

347 if choice is not None: 

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

349 except ValueError: 

350 raise click.ClickException( 

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

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

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

354 return ret.get() 

355 

356 

357def to_upper(context, param, value): 

358 """Convert a value to upper case. 

359 

360 Parameters 

361 ---------- 

362 context : click.Context 

363 

364 values : string 

365 The value to be converted. 

366 

367 Returns 

368 ------- 

369 string 

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

371 """ 

372 return value.upper() 

373 

374 

375def unwrap(val): 

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

377 a consistent indentation level. 

378 

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

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

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

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

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

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

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

386 

387 Parameters 

388 ---------- 

389 val : `str` 

390 The string to change. 

391 

392 Returns 

393 ------- 

394 strippedString : `str` 

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

396 whitespace removed. 

397 """ 

398 def splitSection(val): 

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

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

401 firstLine += " " 

402 else: 

403 firstLine = "" 

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

405 

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

407 

408 

409class option_section: # noqa: N801 

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

411 command. 

412 

413 Parameters 

414 ---------- 

415 sectionText : `str` 

416 The text to print in the section identifier. 

417 """ 

418 

419 def __init__(self, sectionText): 

420 self.sectionText = "\n" + sectionText 

421 

422 def __call__(self, f): 

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

424 # section. 

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

426 sectionText=self.sectionText, 

427 cls=OptionSection)(f) 

428 

429 

430class MWPath(click.Path): 

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

432 

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

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

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

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

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

438 that it is required to not exist). 

439 

440 Parameters 

441 ---------- 

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

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

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

445 location may exist or not. 

446 

447 For other parameters see `click.Path`. 

448 """ 

449 

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

451 writable=False, readable=True, resolve_path=False, 

452 allow_dash=False, path_type=None): 

453 self.mustNotExist = exists is False 

454 if exists is None: 

455 exists = False 

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

457 resolve_path, allow_dash, path_type) 

458 

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

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

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

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

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

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

465 

466 

467class MWOption(click.Option): 

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

469 

470 def make_metavar(self): 

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

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

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

474 implementation. 

475 

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

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

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

479 space between. 

480 

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

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

483 get_help_record. 

484 """ 

485 metavar = super().make_metavar() 

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

487 metavar += " ..." 

488 elif self.nargs != 1: 

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

490 return metavar 

491 

492 

493class MWArgument(click.Argument): 

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

495 

496 def make_metavar(self): 

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

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

499 metavar name if the option accepts multiple inputs. 

500 

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

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

503 

504 Returns 

505 ------- 

506 metavar : `str` 

507 The metavar value. 

508 """ 

509 metavar = super().make_metavar() 

510 if self.nargs != 1: 

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

512 return metavar 

513 

514 

515class OptionSection(MWOption): 

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

517 does not pass any value to the command function. 

518 

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

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

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

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

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

524 

525 This class overrides the hidden attribute because our documentation build 

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

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

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

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

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

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

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

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

534 

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

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

537 internals change. 

538 

539 Parameters 

540 ---------- 

541 sectionName : `str` 

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

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

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

545 auto-generated. 

546 sectionText : `str` 

547 The text to print in the section identifier. 

548 """ 

549 

550 @property 

551 def hidden(self): 

552 return True 

553 

554 @hidden.setter 

555 def hidden(self, val): 

556 pass 

557 

558 def __init__(self, sectionName, sectionText): 

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

560 self.sectionText = sectionText 

561 

562 def get_help_record(self, ctx): 

563 return (self.sectionText, "") 

564 

565 

566class MWOptionDecorator: 

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

568 and allows inspection of the shared option. 

569 """ 

570 

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

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

573 **kwargs) 

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

575 self._name = opt.name 

576 self._opts = opt.opts 

577 

578 def name(self): 

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

580 option.""" 

581 return self._name 

582 

583 def opts(self): 

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

585 line.""" 

586 return self._opts 

587 

588 @property 

589 def help(self): 

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

591 help was defined.""" 

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

593 

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

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

596 

597 

598class MWArgumentDecorator: 

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

600 declared. """ 

601 

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

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

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

605 

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

607 def decorator(f): 

608 if help is not None: 

609 self._helpText = help 

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

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

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

613 return decorator 

614 

615 

616class MWCommand(click.Command): 

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

618 command.""" 

619 

620 extra_epilog = None 

621 

622 def parse_args(self, ctx, args): 

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

624 super().parse_args(ctx, args) 

625 

626 @property 

627 def epilog(self): 

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

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

630 """ 

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

632 if self.extra_epilog: 

633 if ret: 

634 ret += "\n\n" 

635 ret += self.extra_epilog 

636 return ret 

637 

638 @epilog.setter 

639 def epilog(self, val): 

640 self._epilog = val 

641 

642 

643class ButlerCommand(MWCommand): 

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

645 

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

647 

648 

649class MWCtxObj(): 

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

651 obj data to be managed in a consistent way. 

652 

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

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

655 

656 Attributes 

657 ---------- 

658 args : `list` [`str`] 

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

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

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

662 """ 

663 

664 def __init__(self): 

665 

666 self.args = None 

667 

668 @staticmethod 

669 def getFrom(ctx): 

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

671 new or already existing MWCtxObj.""" 

672 if ctx.obj is not None: 

673 return ctx.obj 

674 ctx.obj = MWCtxObj() 

675 return ctx.obj 

676 

677 

678def yaml_presets(ctx, param, value): 

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

680 YAML file. 

681 

682 Parameters 

683 ---------- 

684 ctx : `click.context` 

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

686 name. 

687 param : `str` 

688 The parameter name. 

689 value : `object` 

690 The value of the parameter. 

691 """ 

692 ctx.default_map = ctx.default_map or {} 

693 cmd_name = ctx.info_name 

694 if value: 

695 try: 

696 overrides = _read_yaml_presets(value, cmd_name) 

697 except Exception as e: 

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

699 # Override the defaults for this subcommand 

700 ctx.default_map.update(overrides) 

701 return 

702 

703 

704def _read_yaml_presets(file_uri, cmd_name): 

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

706 

707 Parameters 

708 ---------- 

709 file_uri : `str` 

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

711 They should be grouped by command name. 

712 cmd_name : `str` 

713 The subcommand name that is being modified. 

714 

715 Returns 

716 ------- 

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

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

719 """ 

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

721 config = Config(file_uri) 

722 return config[cmd_name] 

723 

724 

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

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

727 order: 

728 1. the provided named columns 

729 2. spatial and temporal columns 

730 3. the rest of the columns 

731 

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

733 

734 Parameters 

735 ---------- 

736 table : `astropy.table.Table` 

737 The table to sort 

738 dimensions : `list` [``Dimension``] 

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

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

741 spatial, temporal, or neither. 

742 sort_first : `list` [`str`] 

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

744 temporal columns. 

745 

746 Returns 

747 ------- 

748 `astropy.table.Table` 

749 For convenience, the table that has been sorted. 

750 """ 

751 # For sorting we want to ignore the id 

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

753 sort_first = sort_first or [] 

754 sort_early = [] 

755 sort_late = [] 

756 for dim in dimensions: 

757 if dim.spatial or dim.temporal: 

758 sort_early.extend(dim.required.names) 

759 else: 

760 sort_late.append(str(dim)) 

761 sort_keys = sort_first + sort_early + sort_late 

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

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

764 # (order is retained by dict creation). 

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

766 

767 table.sort(sort_keys) 

768 return table