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 # Add standard indent to help text for proper alignment with command
200 # function documentation:
201 helpText = " " + helpText
202 doclines.insert(1, helpText)
203 doclines.insert(1, "\n")
204 doc = "\n".join(doclines)
205 return doc
208def split_commas(context, param, values):
209 """Process a tuple of values, where each value may contain comma-separated
210 values, and return a single list of all the passed-in values.
212 This function can be passed to the 'callback' argument of a click.option to
213 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
215 Parameters
216 ----------
217 context : `click.Context` or `None`
218 The current execution context. Unused, but Click always passes it to
219 callbacks.
220 param : `click.core.Option` or `None`
221 The parameter being handled. Unused, but Click always passes it to
222 callbacks.
223 values : [`str`]
224 All the values passed for this option. Strings may contain commas,
225 which will be treated as delimiters for separate values.
227 Returns
228 -------
229 list of string
230 The passed in values separated by commas and combined into a single
231 list.
232 """
233 if values is None:
234 return values
235 valueList = []
236 for value in ensure_iterable(values):
237 valueList.extend(value.split(","))
238 return tuple(valueList)
241def split_kv(
242 context,
243 param,
244 values,
245 choice=None,
246 multiple=True,
247 normalize=False,
248 separator="=",
249 unseparated_okay=False,
250 return_type=dict,
251 default_key="",
252 reverse_kv=False,
253 add_to_default=False,
254):
255 """Process a tuple of values that are key-value pairs separated by a given
256 separator. Multiple pairs may be comma separated. Return a dictionary of
257 all the passed-in values.
259 This function can be passed to the 'callback' argument of a click.option to
260 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
262 Parameters
263 ----------
264 context : `click.Context` or `None`
265 The current execution context. Unused, but Click always passes it to
266 callbacks.
267 param : `click.core.Option` or `None`
268 The parameter being handled. Unused, but Click always passes it to
269 callbacks.
270 values : [`str`]
271 All the values passed for this option. Strings may contain commas,
272 which will be treated as delimiters for separate values.
273 choice : `click.Choice`, optional
274 If provided, verify each value is a valid choice using the provided
275 `click.Choice` instance. If None, no verification will be done. By
276 default None
277 multiple : `bool`, optional
278 If true, the value may contain multiple comma-separated values. By
279 default True.
280 normalize : `bool`, optional
281 If True and `choice.case_sensitive == False`, normalize the string the
282 user provided to match the choice's case. By default False.
283 separator : str, optional
284 The character that separates key-value pairs. May not be a comma or an
285 empty space (for space separators use Click's default implementation
286 for tuples; `type=(str, str)`). By default "=".
287 unseparated_okay : `bool`, optional
288 If True, allow values that do not have a separator. They will be
289 returned in the values dict as a tuple of values in the key '', that
290 is: `values[''] = (unseparated_values, )`. By default False.
291 return_type : `type`, must be `dict` or `tuple`
292 The type of the value that should be returned.
293 If `dict` then the returned object will be a dict, for each item in
294 values, the value to the left of the separator will be the key and the
295 value to the right of the separator will be the value.
296 If `tuple` then the returned object will be a tuple. Each item in the
297 tuple will be 2-item tuple, the first item will be the value to the
298 left of the separator and the second item will be the value to the
299 right. By default `dict`.
300 default_key : `Any`
301 The key to use if a value is passed that is not a key-value pair.
302 (Passing values that are not key-value pairs requires
303 ``unseparated_okay`` to be `True`.)
304 reverse_kv : bool
305 If true then for each item in values, the value to the left of the
306 separator is treated as the value and the value to the right of the
307 separator is treated as the key. By default False.
308 add_to_default : `bool`, optional
309 If True, then passed-in values will not overwrite the default value
310 unless the ``return_type`` is `dict` and passed-in value(s) have the
311 same key(s) as the default value.
313 Returns
314 -------
315 values : `dict` [`str`, `str`]
316 The passed-in values in dict form.
318 Raises
319 ------
320 `click.ClickException`
321 Raised if the separator is not found in an entry, or if duplicate keys
322 are encountered.
323 """
325 def norm(val):
326 """If `normalize` is True and `choice` is not `None`, find the value
327 in the available choices and return the value as spelled in the
328 choices.
330 Assumes that val exists in choices; `split_kv` uses the `choice`
331 instance to verify val is a valid choice.
332 """
333 if normalize and choice is not None:
334 v = val.casefold()
335 for opt in choice.choices:
336 if opt.casefold() == v:
337 return opt
338 return val
340 class RetDict:
341 def __init__(self):
342 self.ret = {}
344 def add(self, key, val):
345 if reverse_kv:
346 key, val = val, key
347 self.ret[key] = val
349 def get(self):
350 return self.ret
352 class RetTuple:
353 def __init__(self):
354 self.ret = []
356 def add(self, key, val):
357 if reverse_kv:
358 key, val = val, key
359 self.ret.append((key, val))
361 def get(self):
362 return tuple(self.ret)
364 if separator in (",", " "):
365 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
366 vals = values # preserve the original argument for error reporting below.
368 if add_to_default:
369 default = param.get_default(context)
370 if default:
371 vals = itertools.chain(default, vals)
373 if return_type is dict:
374 ret = RetDict()
375 elif return_type is tuple:
376 ret = RetTuple()
377 else:
378 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
379 if multiple:
380 vals = split_commas(context, param, vals)
381 for val in ensure_iterable(vals):
382 if unseparated_okay and separator not in val:
383 if choice is not None:
384 choice(val) # will raise if val is an invalid choice
385 ret.add(default_key, norm(val))
386 else:
387 try:
388 k, v = val.split(separator)
389 if choice is not None:
390 choice(v) # will raise if val is an invalid choice
391 except ValueError:
392 raise click.ClickException(
393 f"Could not parse key-value pair '{val}' using separator '{separator}', "
394 f"with multiple values {'allowed' if multiple else 'not allowed'}."
395 )
396 ret.add(k, norm(v))
397 return ret.get()
400def to_upper(context, param, value):
401 """Convert a value to upper case.
403 Parameters
404 ----------
405 context : click.Context
407 values : string
408 The value to be converted.
410 Returns
411 -------
412 string
413 A copy of the passed-in value, converted to upper case.
414 """
415 return value.upper()
418def unwrap(val):
419 """Remove newlines and leading whitespace from a multi-line string with
420 a consistent indentation level.
422 The first line of the string may be only a newline or may contain text
423 followed by a newline, either is ok. After the first line, each line must
424 begin with a consistant amount of whitespace. So, content of a
425 triple-quoted string may begin immediately after the quotes, or the string
426 may start with a newline. Each line after that must be the same amount of
427 indentation/whitespace followed by text and a newline. The last line may
428 end with a new line but is not required to do so.
430 Parameters
431 ----------
432 val : `str`
433 The string to change.
435 Returns
436 -------
437 strippedString : `str`
438 The string with newlines, indentation, and leading and trailing
439 whitespace removed.
440 """
442 def splitSection(val):
443 if not val.startswith("\n"): 443 ↛ 447line 443 didn't jump to line 447, because the condition on line 443 was never false
444 firstLine, _, val = val.partition("\n")
445 firstLine += " "
446 else:
447 firstLine = ""
448 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
450 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
453class option_section: # noqa: N801
454 """Decorator to add a section label between options in the help text of a
455 command.
457 Parameters
458 ----------
459 sectionText : `str`
460 The text to print in the section identifier.
461 """
463 def __init__(self, sectionText):
464 self.sectionText = "\n" + sectionText
466 def __call__(self, f):
467 # Generate a parameter declaration that will be unique for this
468 # section.
469 return click.option(
470 f"--option-section-{str(uuid.uuid4())}", sectionText=self.sectionText, cls=OptionSection
471 )(f)
474class MWPath(click.Path):
475 """Overrides click.Path to implement file-does-not-exist checking.
477 Changes the definition of ``exists` so that `True` indicates the location
478 (file or directory) must exist, `False` indicates the location must *not*
479 exist, and `None` indicates that the file may exist or not. The standard
480 definition for the `click.Path` ``exists`` parameter is that for `True` a
481 location must exist, but `False` means it is not required to exist (not
482 that it is required to not exist).
484 Parameters
485 ----------
486 exists : `True`, `False`, or `None`
487 If `True`, the location (file or directory) indicated by the caller
488 must exist. If `False` the location must not exist. If `None`, the
489 location may exist or not.
491 For other parameters see `click.Path`.
492 """
494 def __init__(
495 self,
496 exists=None,
497 file_okay=True,
498 dir_okay=True,
499 writable=False,
500 readable=True,
501 resolve_path=False,
502 allow_dash=False,
503 path_type=None,
504 ):
505 self.mustNotExist = exists is False
506 if exists is None: 506 ↛ 508line 506 didn't jump to line 508, because the condition on line 506 was never false
507 exists = False
508 super().__init__(exists, file_okay, dir_okay, writable, readable, resolve_path, allow_dash, path_type)
510 def convert(self, value, param, ctx):
511 """Called by click.ParamType to "convert values through types".
512 `click.Path` uses this step to verify Path conditions."""
513 if self.mustNotExist and os.path.exists(value):
514 self.fail(f'Path "{value}" should not exist.')
515 return super().convert(value, param, ctx)
518class MWOption(click.Option):
519 """Overrides click.Option with desired behaviors."""
521 def make_metavar(self):
522 """Overrides `click.Option.make_metavar`. Makes the metavar for the
523 help menu. Adds a space and an elipsis after the metavar name if
524 the option accepts multiple inputs, otherwise defers to the base
525 implementation.
527 By default click does not add an elipsis when multiple is True and
528 nargs is 1. And when nargs does not equal 1 click adds an elipsis
529 without a space between the metavar and the elipsis, but we prefer a
530 space between.
532 Does not get called for some option types (e.g. flag) so metavar
533 transformation that must apply to all types should be applied in
534 get_help_record.
535 """
536 metavar = super().make_metavar()
537 if self.multiple and self.nargs == 1:
538 metavar += " ..."
539 elif self.nargs != 1:
540 metavar = f"{metavar[:-3]} ..."
541 return metavar
544class MWArgument(click.Argument):
545 """Overrides click.Argument with desired behaviors."""
547 def make_metavar(self):
548 """Overrides `click.Option.make_metavar`. Makes the metavar for the
549 help menu. Always adds a space and an elipsis (' ...') after the
550 metavar name if the option accepts multiple inputs.
552 By default click adds an elipsis without a space between the metavar
553 and the elipsis, but we prefer a space between.
555 Returns
556 -------
557 metavar : `str`
558 The metavar value.
559 """
560 metavar = super().make_metavar()
561 if self.nargs != 1:
562 metavar = f"{metavar[:-3]} ..."
563 return metavar
566class OptionSection(MWOption):
567 """Implements an Option that prints a section label in the help text and
568 does not pass any value to the command function.
570 This class does a bit of hackery to add a section label to a click command
571 help output: first, `expose_value` is set to `False` so that no value is
572 passed to the command function. Second, this class overrides
573 `click.Option.get_help_record` to return the section label string without
574 any prefix so that it stands out as a section label.
576 This class overrides the hidden attribute because our documentation build
577 tool, sphinx-click, implements its own `get_help_record` function which
578 builds the record from other option values (e.g. `name`, `opts`), which
579 breaks the hack we use to make `get_help_record` only return the
580 `sectionText`. Fortunately, Click gets the value of `hidden` inside the
581 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before
582 entering its `_get_help_record` function. So, making the hidden property
583 return True hides this option from sphinx-click, while allowing the section
584 text to be returned by our `get_help_record` method when using Click.
586 The intention for this implementation is to do minimally invasive overrides
587 of the click classes so as to be robust and easy to fix if the click
588 internals change.
590 Parameters
591 ----------
592 sectionName : `str`
593 The parameter declaration for this option. It is not shown to the user,
594 it must be unique within the command. If using the `section` decorator
595 to add a section to a command's options, the section name is
596 auto-generated.
597 sectionText : `str`
598 The text to print in the section identifier.
599 """
601 @property
602 def hidden(self):
603 return True
605 @hidden.setter
606 def hidden(self, val):
607 pass
609 def __init__(self, sectionName, sectionText):
610 super().__init__(sectionName, expose_value=False)
611 self.sectionText = sectionText
613 def get_help_record(self, ctx):
614 return (self.sectionText, "")
617class MWOptionDecorator:
618 """Wraps the click.option decorator to enable shared options to be declared
619 and allows inspection of the shared option.
620 """
622 def __init__(self, *param_decls, **kwargs):
623 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption), **kwargs)
624 opt = click.Option(param_decls, **kwargs)
625 self._name = opt.name
626 self._opts = opt.opts
628 def name(self):
629 """Get the name that will be passed to the command function for this
630 option."""
631 return self._name
633 def opts(self):
634 """Get the flags that will be used for this option on the command
635 line."""
636 return self._opts
638 @property
639 def help(self):
640 """Get the help text for this option. Returns an empty string if no
641 help was defined."""
642 return self.partialOpt.keywords.get("help", "")
644 def __call__(self, *args, **kwargs):
645 return self.partialOpt(*args, **kwargs)
648class MWArgumentDecorator:
649 """Wraps the click.argument decorator to enable shared arguments to be
650 declared."""
652 def __init__(self, *param_decls, **kwargs):
653 self._helpText = kwargs.pop("help", None)
654 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
656 def __call__(self, *args, help=None, **kwargs):
657 def decorator(f):
658 if help is not None:
659 self._helpText = help
660 if self._helpText:
661 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
662 return self.partialArg(*args, **kwargs)(f)
664 return decorator
667class MWCommand(click.Command):
668 """Command subclass that stores a copy of the args list for use by the
669 command."""
671 extra_epilog = None
673 def __init__(self, *args, **kwargs):
674 # wrap callback method with catch_and_exit decorator
675 callback = kwargs.get("callback")
676 if callback is not None: 676 ↛ 679line 676 didn't jump to line 679, because the condition on line 676 was never false
677 kwargs = kwargs.copy()
678 kwargs["callback"] = catch_and_exit(callback)
679 super().__init__(*args, **kwargs)
681 def parse_args(self, ctx, args):
682 MWCtxObj.getFrom(ctx).args = copy.copy(args)
683 super().parse_args(ctx, args)
685 @property
686 def epilog(self):
687 """Override the epilog attribute to add extra_epilog (if defined by a
688 subclass) to the end of any epilog provided by a subcommand.
689 """
690 ret = self._epilog if self._epilog else ""
691 if self.extra_epilog:
692 if ret:
693 ret += "\n\n"
694 ret += self.extra_epilog
695 return ret
697 @epilog.setter
698 def epilog(self, val):
699 self._epilog = val
702class ButlerCommand(MWCommand):
703 """Command subclass with butler-command specific overrides."""
705 extra_epilog = "See 'butler --help' for more options."
708class OptionGroup:
709 """Base class for an option group decorator. Requires the option group
710 subclass to have a property called `decorator`."""
712 def __call__(self, f):
713 for decorator in reversed(self.decorators):
714 f = decorator(f)
715 return f
718class MWCtxObj:
719 """Helper object for managing the `click.Context.obj` parameter, allows
720 obj data to be managed in a consistent way.
722 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
723 initialize the obj if needed and return a new or existing MWCtxObj.
725 Attributes
726 ----------
727 args : `list` [`str`]
728 The list of arguments (argument values, option flags, and option
729 values), split using whitespace, that were passed in on the command
730 line for the subcommand represented by the parent context object.
731 """
733 def __init__(self):
735 self.args = None
737 @staticmethod
738 def getFrom(ctx):
739 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
740 new or already existing MWCtxObj."""
741 if ctx.obj is not None:
742 return ctx.obj
743 ctx.obj = MWCtxObj()
744 return ctx.obj
747def yaml_presets(ctx, param, value):
748 """Click callback that reads additional values from the supplied
749 YAML file.
751 Parameters
752 ----------
753 ctx : `click.context`
754 The context for the click operation. Used to extract the subcommand
755 name and translate option & argument names.
756 param : `str`
757 The parameter name.
758 value : `object`
759 The value of the parameter.
760 """
762 def _name_for_option(ctx: click.Context, option: str) -> str:
763 """Use a CLI option name to find the name of the argument to the
764 command function.
766 Parameters
767 ----------
768 ctx : `click.Context`
769 The context for the click operation.
770 option : `str`
771 The option/argument name from the yaml file.
773 Returns
774 -------
775 name : str
776 The name of the argument to use when calling the click.command
777 function, as it should appear in the `ctx.default_map`.
779 Raises
780 ------
781 RuntimeError
782 Raised if the option name from the yaml file does not exist in the
783 command parameters. This catches misspellings and incorrect useage
784 in the yaml file.
785 """
786 for param in ctx.command.params:
787 # Remove leading dashes: they are not used for option names in the
788 # yaml file.
789 if option in [opt.lstrip("-") for opt in param.opts]:
790 return param.name
791 raise RuntimeError(f"'{option}' is not a valid option for {ctx.info_name}")
793 ctx.default_map = ctx.default_map or {}
794 cmd_name = ctx.info_name
795 if value:
796 try:
797 overrides = _read_yaml_presets(value, cmd_name)
798 options = list(overrides.keys())
799 for option in options:
800 name = _name_for_option(ctx, option)
801 if name == option:
802 continue
803 overrides[name] = overrides.pop(option)
804 except Exception as e:
805 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
806 # Override the defaults for this subcommand
807 ctx.default_map.update(overrides)
808 return
811def _read_yaml_presets(file_uri, cmd_name):
812 """Read file command line overrides from YAML config file.
814 Parameters
815 ----------
816 file_uri : `str`
817 URI of override YAML file containing the command line overrides.
818 They should be grouped by command name.
819 cmd_name : `str`
820 The subcommand name that is being modified.
822 Returns
823 -------
824 overrides : `dict` of [`str`, Any]
825 The relevant command line options read from the override file.
826 """
827 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
828 config = Config(file_uri)
829 return config[cmd_name]
832def sortAstropyTable(table, dimensions, sort_first=None):
833 """Sort an astropy table, with prioritization given to columns in this
834 order:
835 1. the provided named columns
836 2. spatial and temporal columns
837 3. the rest of the columns
839 The table is sorted in-place, and is also returned for convenience.
841 Parameters
842 ----------
843 table : `astropy.table.Table`
844 The table to sort
845 dimensions : `list` [``Dimension``]
846 The dimensions of the dataIds in the table (the dimensions should be
847 the same for all the dataIds). Used to determine if the column is
848 spatial, temporal, or neither.
849 sort_first : `list` [`str`]
850 The names of columns that should be sorted first, before spatial and
851 temporal columns.
853 Returns
854 -------
855 `astropy.table.Table`
856 For convenience, the table that has been sorted.
857 """
858 # For sorting we want to ignore the id
859 # We also want to move temporal or spatial dimensions earlier
860 sort_first = sort_first or []
861 sort_early = []
862 sort_late = []
863 for dim in dimensions:
864 if dim.spatial or dim.temporal:
865 sort_early.extend(dim.required.names)
866 else:
867 sort_late.append(str(dim))
868 sort_keys = sort_first + sort_early + sort_late
869 # The required names above means that we have the possibility of
870 # repeats of sort keys. Now have to remove them
871 # (order is retained by dict creation).
872 sort_keys = list(dict.fromkeys(sort_keys).keys())
874 table.sort(sort_keys)
875 return table
878def catch_and_exit(func):
879 """Decorator which catches all exceptions, prints an exception traceback
880 and signals click to exit.
881 """
883 @wraps(func)
884 def inner(*args, **kwargs):
885 try:
886 func(*args, **kwargs)
887 except (click.exceptions.ClickException, click.exceptions.Exit, click.exceptions.Abort):
888 # this is handled by click itself
889 raise
890 except Exception:
891 exc_type, exc_value, exc_tb = sys.exc_info()
892 if exc_tb.tb_next:
893 # do not show this decorator in traceback
894 exc_tb = exc_tb.tb_next
895 log.exception(
896 "Caught an exception, details are in traceback:", exc_info=(exc_type, exc_value, exc_tb)
897 )
898 # tell click to stop, this never returns.
899 click.get_current_context().exit(1)
901 return inner