Coverage for python/lsst/daf/butler/cli/utils.py: 42%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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/>.
22import copy
23import itertools
24import logging
25import os
26import sys
27import textwrap
28import traceback
29import uuid
30from contextlib import contextmanager
31from functools import partial, wraps
32from unittest.mock import patch
34import click
35import click.exceptions
36import click.testing
37import yaml
38from lsst.utils.iteration import ensure_iterable
40from ..core.config import Config
41from .cliLog import CliLog
43log = logging.getLogger(__name__)
45# This is used as the metavar argument to Options that accept multiple string
46# inputs, which may be comma-separarated. For example:
47# --my-opt foo,bar --my-opt baz.
48# Other arguments to the Option should include multiple=true and
49# callback=split_kv.
50typeStrAcceptsMultiple = "TEXT ..."
51typeStrAcceptsSingle = "TEXT"
53# The standard help string for the --where option when it takes a WHERE clause.
54where_help = (
55 "A string expression similar to a SQL WHERE clause. May involve any column of a "
56 "dimension table or a dimension name as a shortcut for the primary key column of a "
57 "dimension table."
58)
61def astropyTablesToStr(tables):
62 """Render astropy tables to string as they are displayed in the CLI.
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
74def printAstropyTables(tables):
75 """Print astropy tables to be displayed in the CLI.
77 Output formatting matches ``astropyTablesToStr``.
78 """
79 for table in tables:
80 print("")
81 table.pprint_all()
82 print("")
85def textTypeStr(multiple):
86 """Get the text type string for CLI help documentation.
88 Parameters
89 ----------
90 multiple : `bool`
91 True if multiple text values are allowed, False if only one value is
92 allowed.
94 Returns
95 -------
96 textTypeStr : `str`
97 The type string to use.
98 """
99 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
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.
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`."""
111 def invoke(self, *args, **kwargs):
112 result = super().invoke(*args, **kwargs)
113 CliLog.resetLog()
114 return result
117def clickResultMsg(result):
118 """Get a standard assert message from a click result
120 Parameters
121 ----------
122 result : click.Result
123 The result object returned from click.testing.CliRunner.invoke
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
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.
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
161def addArgumentHelp(doc, helpText):
162 """Add a Click argument's help message to a function's documentation.
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.
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.
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]
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
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.
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").
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.
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 ensure_iterable(values):
234 valueList.extend(value.split(","))
235 return tuple(valueList)
238def split_kv(
239 context,
240 param,
241 values,
242 choice=None,
243 multiple=True,
244 normalize=False,
245 separator="=",
246 unseparated_okay=False,
247 return_type=dict,
248 default_key="",
249 reverse_kv=False,
250 add_to_default=False,
251):
252 """Process a tuple of values that are key-value pairs separated by a given
253 separator. Multiple pairs may be comma separated. Return a dictionary of
254 all the passed-in values.
256 This function can be passed to the 'callback' argument of a click.option to
257 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
259 Parameters
260 ----------
261 context : `click.Context` or `None`
262 The current execution context. Unused, but Click always passes it to
263 callbacks.
264 param : `click.core.Option` or `None`
265 The parameter being handled. Unused, but Click always passes it to
266 callbacks.
267 values : [`str`]
268 All the values passed for this option. Strings may contain commas,
269 which will be treated as delimiters for separate values.
270 choice : `click.Choice`, optional
271 If provided, verify each value is a valid choice using the provided
272 `click.Choice` instance. If None, no verification will be done. By
273 default None
274 multiple : `bool`, optional
275 If true, the value may contain multiple comma-separated values. By
276 default True.
277 normalize : `bool`, optional
278 If True and `choice.case_sensitive == False`, normalize the string the
279 user provided to match the choice's case. By default False.
280 separator : str, optional
281 The character that separates key-value pairs. May not be a comma or an
282 empty space (for space separators use Click's default implementation
283 for tuples; `type=(str, str)`). By default "=".
284 unseparated_okay : `bool`, optional
285 If True, allow values that do not have a separator. They will be
286 returned in the values dict as a tuple of values in the key '', that
287 is: `values[''] = (unseparated_values, )`. By default False.
288 return_type : `type`, must be `dict` or `tuple`
289 The type of the value that should be returned.
290 If `dict` then the returned object will be a dict, for each item in
291 values, the value to the left of the separator will be the key and the
292 value to the right of the separator will be the value.
293 If `tuple` then the returned object will be a tuple. Each item in the
294 tuple will be 2-item tuple, the first item will be the value to the
295 left of the separator and the second item will be the value to the
296 right. By default `dict`.
297 default_key : `Any`
298 The key to use if a value is passed that is not a key-value pair.
299 (Passing values that are not key-value pairs requires
300 ``unseparated_okay`` to be `True`.)
301 reverse_kv : bool
302 If true then for each item in values, the value to the left of the
303 separator is treated as the value and the value to the right of the
304 separator is treated as the key. By default False.
305 add_to_default : `bool`, optional
306 If True, then passed-in values will not overwrite the default value
307 unless the ``return_type`` is `dict` and passed-in value(s) have the
308 same key(s) as the default value.
310 Returns
311 -------
312 values : `dict` [`str`, `str`]
313 The passed-in values in dict form.
315 Raises
316 ------
317 `click.ClickException`
318 Raised if the separator is not found in an entry, or if duplicate keys
319 are encountered.
320 """
322 def norm(val):
323 """If `normalize` is True and `choice` is not `None`, find the value
324 in the available choices and return the value as spelled in the
325 choices.
327 Assumes that val exists in choices; `split_kv` uses the `choice`
328 instance to verify val is a valid choice.
329 """
330 if normalize and choice is not None:
331 v = val.casefold()
332 for opt in choice.choices:
333 if opt.casefold() == v:
334 return opt
335 return val
337 class RetDict:
338 def __init__(self):
339 self.ret = {}
341 def add(self, key, val):
342 if reverse_kv:
343 key, val = val, key
344 self.ret[key] = val
346 def get(self):
347 return self.ret
349 class RetTuple:
350 def __init__(self):
351 self.ret = []
353 def add(self, key, val):
354 if reverse_kv:
355 key, val = val, key
356 self.ret.append((key, val))
358 def get(self):
359 return tuple(self.ret)
361 if separator in (",", " "):
362 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
363 vals = values # preserve the original argument for error reporting below.
365 if add_to_default:
366 default = param.get_default(context)
367 if default:
368 vals = itertools.chain(default, vals)
370 if return_type is dict:
371 ret = RetDict()
372 elif return_type is tuple:
373 ret = RetTuple()
374 else:
375 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
376 if multiple:
377 vals = split_commas(context, param, vals)
378 for val in ensure_iterable(vals):
379 if unseparated_okay and separator not in val:
380 if choice is not None:
381 choice(val) # will raise if val is an invalid choice
382 ret.add(default_key, norm(val))
383 else:
384 try:
385 k, v = val.split(separator)
386 if choice is not None:
387 choice(v) # will raise if val is an invalid choice
388 except ValueError:
389 raise click.ClickException(
390 f"Could not parse key-value pair '{val}' using separator '{separator}', "
391 f"with multiple values {'allowed' if multiple else 'not allowed'}."
392 )
393 ret.add(k, norm(v))
394 return ret.get()
397def to_upper(context, param, value):
398 """Convert a value to upper case.
400 Parameters
401 ----------
402 context : click.Context
404 values : string
405 The value to be converted.
407 Returns
408 -------
409 string
410 A copy of the passed-in value, converted to upper case.
411 """
412 return value.upper()
415def unwrap(val):
416 """Remove newlines and leading whitespace from a multi-line string with
417 a consistent indentation level.
419 The first line of the string may be only a newline or may contain text
420 followed by a newline, either is ok. After the first line, each line must
421 begin with a consistant amount of whitespace. So, content of a
422 triple-quoted string may begin immediately after the quotes, or the string
423 may start with a newline. Each line after that must be the same amount of
424 indentation/whitespace followed by text and a newline. The last line may
425 end with a new line but is not required to do so.
427 Parameters
428 ----------
429 val : `str`
430 The string to change.
432 Returns
433 -------
434 strippedString : `str`
435 The string with newlines, indentation, and leading and trailing
436 whitespace removed.
437 """
439 def splitSection(val):
440 if not val.startswith("\n"): 440 ↛ 444line 440 didn't jump to line 444, because the condition on line 440 was never false
441 firstLine, _, val = val.partition("\n")
442 firstLine += " "
443 else:
444 firstLine = ""
445 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
447 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
450class option_section: # noqa: N801
451 """Decorator to add a section label between options in the help text of a
452 command.
454 Parameters
455 ----------
456 sectionText : `str`
457 The text to print in the section identifier.
458 """
460 def __init__(self, sectionText):
461 self.sectionText = "\n" + sectionText
463 def __call__(self, f):
464 # Generate a parameter declaration that will be unique for this
465 # section.
466 return click.option(
467 f"--option-section-{str(uuid.uuid4())}", sectionText=self.sectionText, cls=OptionSection
468 )(f)
471class MWPath(click.Path):
472 """Overrides click.Path to implement file-does-not-exist checking.
474 Changes the definition of ``exists` so that `True` indicates the location
475 (file or directory) must exist, `False` indicates the location must *not*
476 exist, and `None` indicates that the file may exist or not. The standard
477 definition for the `click.Path` ``exists`` parameter is that for `True` a
478 location must exist, but `False` means it is not required to exist (not
479 that it is required to not exist).
481 Parameters
482 ----------
483 exists : `True`, `False`, or `None`
484 If `True`, the location (file or directory) indicated by the caller
485 must exist. If `False` the location must not exist. If `None`, the
486 location may exist or not.
488 For other parameters see `click.Path`.
489 """
491 def __init__(
492 self,
493 exists=None,
494 file_okay=True,
495 dir_okay=True,
496 writable=False,
497 readable=True,
498 resolve_path=False,
499 allow_dash=False,
500 path_type=None,
501 ):
502 self.mustNotExist = exists is False
503 if exists is None: 503 ↛ 505line 503 didn't jump to line 505, because the condition on line 503 was never false
504 exists = False
505 super().__init__(exists, file_okay, dir_okay, writable, readable, resolve_path, allow_dash, path_type)
507 def convert(self, value, param, ctx):
508 """Called by click.ParamType to "convert values through types".
509 `click.Path` uses this step to verify Path conditions."""
510 if self.mustNotExist and os.path.exists(value):
511 self.fail(f'Path "{value}" should not exist.')
512 return super().convert(value, param, ctx)
515class MWOption(click.Option):
516 """Overrides click.Option with desired behaviors."""
518 def make_metavar(self):
519 """Overrides `click.Option.make_metavar`. Makes the metavar for the
520 help menu. Adds a space and an elipsis after the metavar name if
521 the option accepts multiple inputs, otherwise defers to the base
522 implementation.
524 By default click does not add an elipsis when multiple is True and
525 nargs is 1. And when nargs does not equal 1 click adds an elipsis
526 without a space between the metavar and the elipsis, but we prefer a
527 space between.
529 Does not get called for some option types (e.g. flag) so metavar
530 transformation that must apply to all types should be applied in
531 get_help_record.
532 """
533 metavar = super().make_metavar()
534 if self.multiple and self.nargs == 1:
535 metavar += " ..."
536 elif self.nargs != 1:
537 metavar = f"{metavar[:-3]} ..."
538 return metavar
541class MWArgument(click.Argument):
542 """Overrides click.Argument with desired behaviors."""
544 def make_metavar(self):
545 """Overrides `click.Option.make_metavar`. Makes the metavar for the
546 help menu. Always adds a space and an elipsis (' ...') after the
547 metavar name if the option accepts multiple inputs.
549 By default click adds an elipsis without a space between the metavar
550 and the elipsis, but we prefer a space between.
552 Returns
553 -------
554 metavar : `str`
555 The metavar value.
556 """
557 metavar = super().make_metavar()
558 if self.nargs != 1:
559 metavar = f"{metavar[:-3]} ..."
560 return metavar
563class OptionSection(MWOption):
564 """Implements an Option that prints a section label in the help text and
565 does not pass any value to the command function.
567 This class does a bit of hackery to add a section label to a click command
568 help output: first, `expose_value` is set to `False` so that no value is
569 passed to the command function. Second, this class overrides
570 `click.Option.get_help_record` to return the section label string without
571 any prefix so that it stands out as a section label.
573 This class overrides the hidden attribute because our documentation build
574 tool, sphinx-click, implements its own `get_help_record` function which
575 builds the record from other option values (e.g. `name`, `opts`), which
576 breaks the hack we use to make `get_help_record` only return the
577 `sectionText`. Fortunately, Click gets the value of `hidden` inside the
578 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before
579 entering its `_get_help_record` function. So, making the hidden property
580 return True hides this option from sphinx-click, while allowing the section
581 text to be returned by our `get_help_record` method when using Click.
583 The intention for this implementation is to do minimally invasive overrides
584 of the click classes so as to be robust and easy to fix if the click
585 internals change.
587 Parameters
588 ----------
589 sectionName : `str`
590 The parameter declaration for this option. It is not shown to the user,
591 it must be unique within the command. If using the `section` decorator
592 to add a section to a command's options, the section name is
593 auto-generated.
594 sectionText : `str`
595 The text to print in the section identifier.
596 """
598 @property
599 def hidden(self):
600 return True
602 @hidden.setter
603 def hidden(self, val):
604 pass
606 def __init__(self, sectionName, sectionText):
607 super().__init__(sectionName, expose_value=False)
608 self.sectionText = sectionText
610 def get_help_record(self, ctx):
611 return (self.sectionText, "")
614class MWOptionDecorator:
615 """Wraps the click.option decorator to enable shared options to be declared
616 and allows inspection of the shared option.
617 """
619 def __init__(self, *param_decls, **kwargs):
620 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption), **kwargs)
621 opt = click.Option(param_decls, **kwargs)
622 self._name = opt.name
623 self._opts = opt.opts
625 def name(self):
626 """Get the name that will be passed to the command function for this
627 option."""
628 return self._name
630 def opts(self):
631 """Get the flags that will be used for this option on the command
632 line."""
633 return self._opts
635 @property
636 def help(self):
637 """Get the help text for this option. Returns an empty string if no
638 help was defined."""
639 return self.partialOpt.keywords.get("help", "")
641 def __call__(self, *args, **kwargs):
642 return self.partialOpt(*args, **kwargs)
645class MWArgumentDecorator:
646 """Wraps the click.argument decorator to enable shared arguments to be
647 declared."""
649 def __init__(self, *param_decls, **kwargs):
650 self._helpText = kwargs.pop("help", None)
651 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
653 def __call__(self, *args, help=None, **kwargs):
654 def decorator(f):
655 if help is not None:
656 self._helpText = help
657 if self._helpText:
658 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
659 return self.partialArg(*args, **kwargs)(f)
661 return decorator
664class MWCommand(click.Command):
665 """Command subclass that stores a copy of the args list for use by the
666 command."""
668 extra_epilog = None
670 def __init__(self, *args, **kwargs):
671 # wrap callback method with catch_and_exit decorator
672 callback = kwargs.get("callback")
673 if callback is not None: 673 ↛ 676line 673 didn't jump to line 676, because the condition on line 673 was never false
674 kwargs = kwargs.copy()
675 kwargs["callback"] = catch_and_exit(callback)
676 super().__init__(*args, **kwargs)
678 def parse_args(self, ctx, args):
679 MWCtxObj.getFrom(ctx).args = copy.copy(args)
680 super().parse_args(ctx, args)
682 @property
683 def epilog(self):
684 """Override the epilog attribute to add extra_epilog (if defined by a
685 subclass) to the end of any epilog provided by a subcommand.
686 """
687 ret = self._epilog if self._epilog else ""
688 if self.extra_epilog:
689 if ret:
690 ret += "\n\n"
691 ret += self.extra_epilog
692 return ret
694 @epilog.setter
695 def epilog(self, val):
696 self._epilog = val
699class ButlerCommand(MWCommand):
700 """Command subclass with butler-command specific overrides."""
702 extra_epilog = "See 'butler --help' for more options."
705class OptionGroup:
706 """Base class for an option group decorator. Requires the option group
707 subclass to have a property called `decorator`."""
709 def __call__(self, f):
710 for decorator in reversed(self.decorators):
711 f = decorator(f)
712 return f
715class MWCtxObj:
716 """Helper object for managing the `click.Context.obj` parameter, allows
717 obj data to be managed in a consistent way.
719 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
720 initialize the obj if needed and return a new or existing MWCtxObj.
722 Attributes
723 ----------
724 args : `list` [`str`]
725 The list of arguments (argument values, option flags, and option
726 values), split using whitespace, that were passed in on the command
727 line for the subcommand represented by the parent context object.
728 """
730 def __init__(self):
732 self.args = None
734 @staticmethod
735 def getFrom(ctx):
736 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
737 new or already existing MWCtxObj."""
738 if ctx.obj is not None:
739 return ctx.obj
740 ctx.obj = MWCtxObj()
741 return ctx.obj
744def yaml_presets(ctx, param, value):
745 """Click callback that reads additional values from the supplied
746 YAML file.
748 Parameters
749 ----------
750 ctx : `click.context`
751 The context for the click operation. Used to extract the subcommand
752 name and translate option & argument names.
753 param : `str`
754 The parameter name.
755 value : `object`
756 The value of the parameter.
757 """
759 def _name_for_option(ctx: click.Context, option: str) -> str:
760 """Use a CLI option name to find the name of the argument to the
761 command function.
763 Parameters
764 ----------
765 ctx : `click.Context`
766 The context for the click operation.
767 option : `str`
768 The option/argument name from the yaml file.
770 Returns
771 -------
772 name : str
773 The name of the argument to use when calling the click.command
774 function, as it should appear in the `ctx.default_map`.
776 Raises
777 ------
778 RuntimeError
779 Raised if the option name from the yaml file does not exist in the
780 command parameters. This catches misspellings and incorrect useage
781 in the yaml file.
782 """
783 for param in ctx.command.params:
784 # Remove leading dashes: they are not used for option names in the
785 # yaml file.
786 if option in [opt.lstrip("-") for opt in param.opts]:
787 return param.name
788 raise RuntimeError(f"'{option}' is not a valid option for {ctx.info_name}")
790 ctx.default_map = ctx.default_map or {}
791 cmd_name = ctx.info_name
792 if value:
793 try:
794 overrides = _read_yaml_presets(value, cmd_name)
795 options = list(overrides.keys())
796 for option in options:
797 name = _name_for_option(ctx, option)
798 if name == option:
799 continue
800 overrides[name] = overrides.pop(option)
801 except Exception as e:
802 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
803 # Override the defaults for this subcommand
804 ctx.default_map.update(overrides)
805 return
808def _read_yaml_presets(file_uri, cmd_name):
809 """Read file command line overrides from YAML config file.
811 Parameters
812 ----------
813 file_uri : `str`
814 URI of override YAML file containing the command line overrides.
815 They should be grouped by command name.
816 cmd_name : `str`
817 The subcommand name that is being modified.
819 Returns
820 -------
821 overrides : `dict` of [`str`, Any]
822 The relevant command line options read from the override file.
823 """
824 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
825 config = Config(file_uri)
826 return config[cmd_name]
829def sortAstropyTable(table, dimensions, sort_first=None):
830 """Sort an astropy table, with prioritization given to columns in this
831 order:
832 1. the provided named columns
833 2. spatial and temporal columns
834 3. the rest of the columns
836 The table is sorted in-place, and is also returned for convenience.
838 Parameters
839 ----------
840 table : `astropy.table.Table`
841 The table to sort
842 dimensions : `list` [``Dimension``]
843 The dimensions of the dataIds in the table (the dimensions should be
844 the same for all the dataIds). Used to determine if the column is
845 spatial, temporal, or neither.
846 sort_first : `list` [`str`]
847 The names of columns that should be sorted first, before spatial and
848 temporal columns.
850 Returns
851 -------
852 `astropy.table.Table`
853 For convenience, the table that has been sorted.
854 """
855 # For sorting we want to ignore the id
856 # We also want to move temporal or spatial dimensions earlier
857 sort_first = sort_first or []
858 sort_early = []
859 sort_late = []
860 for dim in dimensions:
861 if dim.spatial or dim.temporal:
862 sort_early.extend(dim.required.names)
863 else:
864 sort_late.append(str(dim))
865 sort_keys = sort_first + sort_early + sort_late
866 # The required names above means that we have the possibility of
867 # repeats of sort keys. Now have to remove them
868 # (order is retained by dict creation).
869 sort_keys = list(dict.fromkeys(sort_keys).keys())
871 table.sort(sort_keys)
872 return table
875def catch_and_exit(func):
876 """Decorator which catches all exceptions, prints an exception traceback
877 and signals click to exit.
878 """
880 @wraps(func)
881 def inner(*args, **kwargs):
882 try:
883 func(*args, **kwargs)
884 except (click.exceptions.ClickException, click.exceptions.Exit, click.exceptions.Abort):
885 # this is handled by click itself
886 raise
887 except Exception:
888 exc_type, exc_value, exc_tb = sys.exc_info()
889 if exc_tb.tb_next:
890 # do not show this decorator in traceback
891 exc_tb = exc_tb.tb_next
892 log.exception(
893 "Caught an exception, details are in traceback:", exc_info=(exc_type, exc_value, exc_tb)
894 )
895 # tell click to stop, this never returns.
896 click.get_current_context().exit(1)
898 return inner