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 io 

28import os 

29import textwrap 

30import traceback 

31from unittest.mock import MagicMock, patch 

32import uuid 

33import yaml 

34import logging 

35 

36from .cliLog import CliLog 

37from ..core.utils import iterable 

38from ..core.config import Config 

39 

40log = logging.getLogger(__name__) 

41 

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

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

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

45# verification. 

46mockEnvVarKey = "CLI_MOCK_ENV" 

47mockEnvVar = {mockEnvVarKey: "1"} 

48 

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

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

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

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

53# callback=split_kv. 

54typeStrAcceptsMultiple = "TEXT ..." 

55typeStrAcceptsSingle = "TEXT" 

56 

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

58# for those inputs. 

59split_kv_separator = "=" 

60 

61 

62def textTypeStr(multiple): 

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

64 

65 Parameters 

66 ---------- 

67 multiple : `bool` 

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

69 allowed. 

70 

71 Returns 

72 ------- 

73 textTypeStr : `str` 

74 The type string to use. 

75 """ 

76 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle 

77 

78 

79class Mocker: 

80 

81 mock = MagicMock() 

82 

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

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

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

86 

87 For convenience, constructor arguments are forwarded to the call 

88 function. 

89 """ 

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

91 

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

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

94 later be verified. 

95 """ 

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

97 

98 @classmethod 

99 def reset(cls): 

100 cls.mock.reset_mock() 

101 

102 

103class LogCliRunner(click.testing.CliRunner): 

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

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

106 was done with the CliLog interface. 

107 

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

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

110 `CliLog.defaultLsstLogLevel`.""" 

111 

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

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

114 CliLog.resetLog() 

115 return result 

116 

117 

118def clickResultMsg(result): 

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

120 

121 Parameters 

122 ---------- 

123 result : click.Result 

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

125 

126 Returns 

127 ------- 

128 msg : `str` 

129 The message string. 

130 """ 

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

132 if result.exception: 

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

134 return msg 

135 

136 

137@contextmanager 

138def command_test_env(runner, commandModule, commandName): 

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

140 provides a CLI plugin command with the given name. 

141 

142 Parameters 

143 ---------- 

144 runner : click.testing.CliRunner 

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

146 commandModule : `str` 

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

148 commandName : `str` 

149 The name of the command being published to import. 

150 """ 

151 with runner.isolated_filesystem(): 

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

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

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

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

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

157 # is properly stripped out. 

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

159 yield 

160 

161 

162def addArgumentHelp(doc, helpText): 

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

164 

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

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

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

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

169 from the order they are applied in. 

170 

171 Parameters 

172 ---------- 

173 doc : `str` 

174 The function's docstring. 

175 helpText : `str` 

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

177 docstring. 

178 

179 Returns 

180 ------- 

181 doc : `str` 

182 Updated function documentation. 

183 """ 

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

185 doc = helpText 

186 else: 

187 # See click documentation for details: 

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

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

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

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

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

193 

194 doclines = doc.splitlines() 

195 doclines.insert(1, helpText) 

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

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

198 return doc 

199 

200 

201def split_commas(context, param, values): 

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

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

204 

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

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

207 

208 Parameters 

209 ---------- 

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

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

212 callbacks. 

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

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

215 callbacks. 

216 values : [`str`] 

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

218 which will be treated as delimiters for separate values. 

219 

220 Returns 

221 ------- 

222 list of string 

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

224 list. 

225 """ 

226 if values is None: 

227 return values 

228 valueList = [] 

229 for value in iterable(values): 

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

231 return tuple(valueList) 

232 

233 

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

235 unseparated_okay=False, return_type=dict, default_key="", reverse_kv=False): 

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

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

238 all the passed-in values. 

239 

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

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

242 

243 Parameters 

244 ---------- 

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

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

247 callbacks. 

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

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

250 callbacks. 

251 values : [`str`] 

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

253 which will be treated as delimiters for separate values. 

254 choice : `click.Choice`, optional 

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

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

257 default None 

258 multiple : `bool`, optional 

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

260 default True. 

261 normalize : `bool`, optional 

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

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

264 separator : str, optional 

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

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

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

268 unseparated_okay : `bool`, optional 

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

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

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

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

273 The type of the value that should be returned. 

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

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

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

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

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

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

280 right. By default `dict`. 

281 default_key : `Any` 

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

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

284 ``unseparated_okay`` to be `True`.) 

285 reverse_kv : bool 

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

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

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

289 

290 Returns 

291 ------- 

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

293 The passed-in values in dict form. 

294 

295 Raises 

296 ------ 

297 `click.ClickException` 

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

299 are encountered. 

300 """ 

301 

302 def norm(val): 

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

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

305 choices. 

306 

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

308 instance to verify val is a valid choice. 

309 """ 

310 if normalize and choice is not None: 

311 v = val.casefold() 

312 for opt in choice.choices: 

313 if opt.casefold() == v: 

314 return opt 

315 return val 

316 

317 class RetDict: 

318 

319 def __init__(self): 

320 self.ret = {} 

321 

322 def add(self, key, val): 

323 if reverse_kv: 

324 key, val = val, key 

325 if key in self.ret: 

326 raise click.ClickException(f"Duplicate entries for '{k}' in '{values}'") 

327 self.ret[key] = val 

328 

329 def get(self): 

330 return self.ret 

331 

332 class RetTuple: 

333 

334 def __init__(self): 

335 self.ret = [] 

336 

337 def add(self, key, val): 

338 if reverse_kv: 

339 key, val = val, key 

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

341 

342 def get(self): 

343 return tuple(self.ret) 

344 

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

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

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

348 if return_type is dict: 

349 ret = RetDict() 

350 elif return_type is tuple: 

351 ret = RetTuple() 

352 else: 

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

354 if multiple: 

355 vals = split_commas(context, param, vals) 

356 for val in iterable(vals): 

357 if unseparated_okay and separator not in val: 

358 if choice is not None: 

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

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

361 else: 

362 try: 

363 k, v = val.split(separator) 

364 if choice is not None: 

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

366 except ValueError: 

367 raise click.ClickException( 

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

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

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

371 return ret.get() 

372 

373 

374def to_upper(context, param, value): 

375 """Convert a value to upper case. 

376 

377 Parameters 

378 ---------- 

379 context : click.Context 

380 

381 values : string 

382 The value to be converted. 

383 

384 Returns 

385 ------- 

386 string 

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

388 """ 

389 return value.upper() 

390 

391 

392def unwrap(val): 

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

394 a consistent indentation level. 

395 

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

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

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

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

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

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

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

403 

404 Parameters 

405 ---------- 

406 val : `str` 

407 The string to change. 

408 

409 Returns 

410 ------- 

411 strippedString : `str` 

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

413 whitespace removed. 

414 """ 

415 def splitSection(val): 

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

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

418 firstLine += " " 

419 else: 

420 firstLine = "" 

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

422 

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

424 

425 

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

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

428 ClickException if there is an Exception. 

429 

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

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

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

433 

434 Parameters 

435 ---------- 

436 func : function 

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

438 to the function. 

439 

440 Returns 

441 ------- 

442 The result of calling func. 

443 

444 Raises 

445 ------ 

446 click.ClickException 

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

448 """ 

449 if mockEnvVarKey in os.environ: 

450 Mocker(*args, **kwargs) 

451 return 

452 try: 

453 return func(*args, **kwargs) 

454 except Exception: 

455 msg = io.StringIO() 

456 msg.write("An error occurred during command execution:\n") 

457 traceback.print_exc(file=msg) 

458 raise click.ClickException(msg.getvalue()) 

459 

460 

461class option_section: # noqa: N801 

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

463 command. 

464 

465 Parameters 

466 ---------- 

467 sectionText : `str` 

468 The text to print in the section identifier. 

469 """ 

470 

471 def __init__(self, sectionText): 

472 self.sectionText = "\n" + sectionText 

473 

474 def __call__(self, f): 

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

476 # section. 

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

478 sectionText=self.sectionText, 

479 cls=OptionSection)(f) 

480 

481 

482class MWPath(click.Path): 

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

484 

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

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

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

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

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

490 that it is required to not exist). 

491 

492 Parameters 

493 ---------- 

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

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

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

497 location may exist or not. 

498 

499 For other parameters see `click.Path`. 

500 """ 

501 

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

503 writable=False, readable=True, resolve_path=False, 

504 allow_dash=False, path_type=None): 

505 self.mustNotExist = exists is False 

506 if exists is None: 

507 exists = False 

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

509 resolve_path, allow_dash, path_type) 

510 

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

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

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

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

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

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

517 

518 

519class MWOption(click.Option): 

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

521 

522 def make_metavar(self): 

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

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

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

526 implementation. 

527 

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

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

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

531 space between. 

532 

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

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

535 get_help_record. 

536 """ 

537 metavar = super().make_metavar() 

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

539 metavar += " ..." 

540 elif self.nargs != 1: 

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

542 return metavar 

543 

544 

545class MWArgument(click.Argument): 

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

547 

548 def make_metavar(self): 

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

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

551 metavar name if the option accepts multiple inputs. 

552 

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

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

555 

556 Returns 

557 ------- 

558 metavar : `str` 

559 The metavar value. 

560 """ 

561 metavar = super().make_metavar() 

562 if self.nargs != 1: 

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

564 return metavar 

565 

566 

567class OptionSection(MWOption): 

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

569 does not pass any value to the command function. 

570 

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

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

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

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

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

576 

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

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

579 internals change. 

580 

581 Parameters 

582 ---------- 

583 sectionName : `str` 

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

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

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

587 auto-generated. 

588 sectionText : `str` 

589 The text to print in the section identifier. 

590 """ 

591 

592 def __init__(self, sectionName, sectionText): 

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

594 self.sectionText = sectionText 

595 

596 def get_help_record(self, ctx): 

597 return (self.sectionText, "") 

598 

599 

600class MWOptionDecorator: 

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

602 and allows inspection of the shared option. 

603 """ 

604 

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

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

607 **kwargs) 

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

609 self._name = opt.name 

610 self._opts = opt.opts 

611 

612 def name(self): 

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

614 option.""" 

615 return self._name 

616 

617 def opts(self): 

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

619 line.""" 

620 return self._opts 

621 

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

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

624 

625 

626class MWArgumentDecorator: 

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

628 declared. """ 

629 

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

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

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

633 

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

635 def decorator(f): 

636 if help is not None: 

637 self._helpText = help 

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

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

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

641 return decorator 

642 

643 

644class MWCommand(click.Command): 

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

646 command.""" 

647 

648 def parse_args(self, ctx, args): 

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

650 super().parse_args(ctx, args) 

651 

652 

653class MWCtxObj(): 

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

655 obj data to be managed in a consistent way. 

656 

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

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

659 

660 Attributes 

661 ---------- 

662 args : `list` [`str`] 

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

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

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

666 """ 

667 

668 def __init__(self): 

669 

670 self.args = None 

671 

672 @staticmethod 

673 def getFrom(ctx): 

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

675 new or already existing MWCtxObj.""" 

676 if ctx.obj is not None: 

677 return ctx.obj 

678 ctx.obj = MWCtxObj() 

679 return ctx.obj 

680 

681 

682def yaml_presets(ctx, param, value): 

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

684 YAML file. 

685 

686 Parameters 

687 ---------- 

688 ctx : `click.context` 

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

690 name. 

691 param : `str` 

692 The parameter name. 

693 value : `object` 

694 The value of the parameter. 

695 """ 

696 ctx.default_map = ctx.default_map or {} 

697 cmd_name = ctx.info_name 

698 if value: 

699 try: 

700 overrides = _read_yaml_presets(value, cmd_name) 

701 except Exception as e: 

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

703 # Override the defaults for this subcommand 

704 ctx.default_map.update(overrides) 

705 return 

706 

707 

708def _read_yaml_presets(file_uri, cmd_name): 

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

710 

711 Parameters 

712 ---------- 

713 file_uri : `str` 

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

715 They should be grouped by command name. 

716 cmd_name : `str` 

717 The subcommand name that is being modified. 

718 

719 Returns 

720 ------- 

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

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

723 """ 

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

725 config = Config(file_uri) 

726 return config[cmd_name] 

727 

728 

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

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

731 order: 

732 1. the provided named columns 

733 2. spatial and temporal columns 

734 3. the rest of the columns 

735 

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

737 

738 Parameters 

739 ---------- 

740 table : `astropy.table.Table` 

741 The table to sort 

742 dimensions : `list` [``Dimension``] 

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

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

745 spatial, temporal, or neither. 

746 sort_first : `list` [`str`] 

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

748 temporal columns. 

749 

750 Returns 

751 ------- 

752 `astropy.table.Table` 

753 For convenience, the table that has been sorted. 

754 """ 

755 # For sorting we want to ignore the id 

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

757 sort_first = sort_first or [] 

758 sort_early = [] 

759 sort_late = [] 

760 for dim in dimensions: 

761 if dim.spatial or dim.temporal: 

762 sort_early.extend(dim.required.names) 

763 else: 

764 sort_late.append(str(dim)) 

765 sort_keys = sort_first + sort_early + sort_late 

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

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

768 # (order is retained by dict creation). 

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

770 

771 table.sort(sort_keys) 

772 return table