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# For parameters that support key-value inputs, this defines the separator
54# for those inputs.
55split_kv_separator = "="
58# The standard help string for the --where option when it takes a WHERE clause.
59where_help = (
60 "A string expression similar to a SQL WHERE clause. May involve any column of a "
61 "dimension table or a dimension name as a shortcut for the primary key column of a "
62 "dimension table."
63)
66def astropyTablesToStr(tables):
67 """Render astropy tables to string as they are displayed in the CLI.
69 Output formatting matches ``printAstropyTables``.
70 """
71 ret = ""
72 for table in tables:
73 ret += "\n"
74 table.pformat_all()
75 ret += "\n"
76 return ret
79def printAstropyTables(tables):
80 """Print astropy tables to be displayed in the CLI.
82 Output formatting matches ``astropyTablesToStr``.
83 """
84 for table in tables:
85 print("")
86 table.pprint_all()
87 print("")
90def textTypeStr(multiple):
91 """Get the text type string for CLI help documentation.
93 Parameters
94 ----------
95 multiple : `bool`
96 True if multiple text values are allowed, False if only one value is
97 allowed.
99 Returns
100 -------
101 textTypeStr : `str`
102 The type string to use.
103 """
104 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
107class LogCliRunner(click.testing.CliRunner):
108 """A test runner to use when the logging system will be initialized by code
109 under test, calls CliLog.resetLog(), which undoes any logging setup that
110 was done with the CliLog interface.
112 lsst.log modules can not be set back to an uninitialized state (python
113 logging modules can be set back to NOTSET), instead they are set to
114 `CliLog.defaultLsstLogLevel`."""
116 def invoke(self, *args, **kwargs):
117 result = super().invoke(*args, **kwargs)
118 CliLog.resetLog()
119 return result
122def clickResultMsg(result):
123 """Get a standard assert message from a click result
125 Parameters
126 ----------
127 result : click.Result
128 The result object returned from click.testing.CliRunner.invoke
130 Returns
131 -------
132 msg : `str`
133 The message string.
134 """
135 msg = f"""\noutput: {result.output}\nexception: {result.exception}"""
136 if result.exception:
137 msg += f"""\ntraceback: {"".join(traceback.format_tb(result.exception.__traceback__))}"""
138 return msg
141@contextmanager
142def command_test_env(runner, commandModule, commandName):
143 """A context manager that creates (and then cleans up) an environment that
144 provides a CLI plugin command with the given name.
146 Parameters
147 ----------
148 runner : click.testing.CliRunner
149 The test runner to use to create the isolated filesystem.
150 commandModule : `str`
151 The importable module that the command can be imported from.
152 commandName : `str`
153 The name of the command being published to import.
154 """
155 with runner.isolated_filesystem():
156 with open("resources.yaml", "w") as f:
157 f.write(yaml.dump({"cmd": {"import": commandModule, "commands": [commandName]}}))
158 # Add a colon to the end of the path on the next line, this tests the
159 # case where the lookup in LoaderCLI._getPluginList generates an empty
160 # string in one of the list entries and verifies that the empty string
161 # is properly stripped out.
162 with patch.dict("os.environ", {"DAF_BUTLER_PLUGINS": f"{os.path.realpath(f.name)}:"}):
163 yield
166def addArgumentHelp(doc, helpText):
167 """Add a Click argument's help message to a function's documentation.
169 This is needed because click presents arguments in the order the argument
170 decorators are applied to a function, top down. But, the evaluation of the
171 decorators happens bottom up, so if arguments just append their help to the
172 function's docstring, the argument descriptions appear in reverse order
173 from the order they are applied in.
175 Parameters
176 ----------
177 doc : `str`
178 The function's docstring.
179 helpText : `str`
180 The argument's help string to be inserted into the function's
181 docstring.
183 Returns
184 -------
185 doc : `str`
186 Updated function documentation.
187 """
188 if doc is None: 188 ↛ 189line 188 didn't jump to line 189, because the condition on line 188 was never true
189 doc = helpText
190 else:
191 # See click documentation for details:
192 # https://click.palletsprojects.com/en/7.x/documentation/#truncating-help-texts
193 # In short, text for the click command help can be truncated by putting
194 # "\f" in the docstring, everything after it should be removed
195 if "\f" in doc: 195 ↛ 196line 195 didn't jump to line 196, because the condition on line 195 was never true
196 doc = doc.split("\f")[0]
198 doclines = doc.splitlines()
199 # The function's docstring may span multiple lines, so combine the
200 # docstring from all the first lines until a blank line is encountered.
201 # (Lines after the first blank line will be argument help.)
202 while len(doclines) > 1 and doclines[1]:
203 doclines[0] = " ".join((doclines[0], doclines.pop(1).strip()))
204 doclines.insert(1, helpText)
205 doclines.insert(1, "\n")
206 doc = "\n".join(doclines)
207 return doc
210def split_commas(context, param, values):
211 """Process a tuple of values, where each value may contain comma-separated
212 values, and return a single list of all the passed-in values.
214 This function can be passed to the 'callback' argument of a click.option to
215 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
217 Parameters
218 ----------
219 context : `click.Context` or `None`
220 The current execution context. Unused, but Click always passes it to
221 callbacks.
222 param : `click.core.Option` or `None`
223 The parameter being handled. Unused, but Click always passes it to
224 callbacks.
225 values : [`str`]
226 All the values passed for this option. Strings may contain commas,
227 which will be treated as delimiters for separate values.
229 Returns
230 -------
231 list of string
232 The passed in values separated by commas and combined into a single
233 list.
234 """
235 if values is None:
236 return values
237 valueList = []
238 for value in ensure_iterable(values):
239 valueList.extend(value.split(","))
240 return tuple(valueList)
243def split_kv(
244 context,
245 param,
246 values,
247 choice=None,
248 multiple=True,
249 normalize=False,
250 separator="=",
251 unseparated_okay=False,
252 return_type=dict,
253 default_key="",
254 reverse_kv=False,
255 add_to_default=False,
256):
257 """Process a tuple of values that are key-value pairs separated by a given
258 separator. Multiple pairs may be comma separated. Return a dictionary of
259 all the passed-in values.
261 This function can be passed to the 'callback' argument of a click.option to
262 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
264 Parameters
265 ----------
266 context : `click.Context` or `None`
267 The current execution context. Unused, but Click always passes it to
268 callbacks.
269 param : `click.core.Option` or `None`
270 The parameter being handled. Unused, but Click always passes it to
271 callbacks.
272 values : [`str`]
273 All the values passed for this option. Strings may contain commas,
274 which will be treated as delimiters for separate values.
275 choice : `click.Choice`, optional
276 If provided, verify each value is a valid choice using the provided
277 `click.Choice` instance. If None, no verification will be done. By
278 default None
279 multiple : `bool`, optional
280 If true, the value may contain multiple comma-separated values. By
281 default True.
282 normalize : `bool`, optional
283 If True and `choice.case_sensitive == False`, normalize the string the
284 user provided to match the choice's case. By default False.
285 separator : str, optional
286 The character that separates key-value pairs. May not be a comma or an
287 empty space (for space separators use Click's default implementation
288 for tuples; `type=(str, str)`). By default "=".
289 unseparated_okay : `bool`, optional
290 If True, allow values that do not have a separator. They will be
291 returned in the values dict as a tuple of values in the key '', that
292 is: `values[''] = (unseparated_values, )`. By default False.
293 return_type : `type`, must be `dict` or `tuple`
294 The type of the value that should be returned.
295 If `dict` then the returned object will be a dict, for each item in
296 values, the value to the left of the separator will be the key and the
297 value to the right of the separator will be the value.
298 If `tuple` then the returned object will be a tuple. Each item in the
299 tuple will be 2-item tuple, the first item will be the value to the
300 left of the separator and the second item will be the value to the
301 right. By default `dict`.
302 default_key : `Any`
303 The key to use if a value is passed that is not a key-value pair.
304 (Passing values that are not key-value pairs requires
305 ``unseparated_okay`` to be `True`.)
306 reverse_kv : bool
307 If true then for each item in values, the value to the left of the
308 separator is treated as the value and the value to the right of the
309 separator is treated as the key. By default False.
310 add_to_default : `bool`, optional
311 If True, then passed-in values will not overwrite the default value
312 unless the ``return_type`` is `dict` and passed-in value(s) have the
313 same key(s) as the default value.
315 Returns
316 -------
317 values : `dict` [`str`, `str`]
318 The passed-in values in dict form.
320 Raises
321 ------
322 `click.ClickException`
323 Raised if the separator is not found in an entry, or if duplicate keys
324 are encountered.
325 """
327 def norm(val):
328 """If `normalize` is True and `choice` is not `None`, find the value
329 in the available choices and return the value as spelled in the
330 choices.
332 Assumes that val exists in choices; `split_kv` uses the `choice`
333 instance to verify val is a valid choice.
334 """
335 if normalize and choice is not None:
336 v = val.casefold()
337 for opt in choice.choices:
338 if opt.casefold() == v:
339 return opt
340 return val
342 class RetDict:
343 def __init__(self):
344 self.ret = {}
346 def add(self, key, val):
347 if reverse_kv:
348 key, val = val, key
349 self.ret[key] = val
351 def get(self):
352 return self.ret
354 class RetTuple:
355 def __init__(self):
356 self.ret = []
358 def add(self, key, val):
359 if reverse_kv:
360 key, val = val, key
361 self.ret.append((key, val))
363 def get(self):
364 return tuple(self.ret)
366 if separator in (",", " "):
367 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
368 vals = values # preserve the original argument for error reporting below.
370 if add_to_default:
371 default = param.get_default(context)
372 if default:
373 vals = itertools.chain(default, vals)
375 if return_type is dict:
376 ret = RetDict()
377 elif return_type is tuple:
378 ret = RetTuple()
379 else:
380 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
381 if multiple:
382 vals = split_commas(context, param, vals)
383 for val in ensure_iterable(vals):
384 if unseparated_okay and separator not in val:
385 if choice is not None:
386 choice(val) # will raise if val is an invalid choice
387 ret.add(default_key, norm(val))
388 else:
389 try:
390 k, v = val.split(separator)
391 if choice is not None:
392 choice(v) # will raise if val is an invalid choice
393 except ValueError:
394 raise click.ClickException(
395 f"Could not parse key-value pair '{val}' using separator '{separator}', "
396 f"with multiple values {'allowed' if multiple else 'not allowed'}."
397 )
398 ret.add(k, norm(v))
399 return ret.get()
402def to_upper(context, param, value):
403 """Convert a value to upper case.
405 Parameters
406 ----------
407 context : click.Context
409 values : string
410 The value to be converted.
412 Returns
413 -------
414 string
415 A copy of the passed-in value, converted to upper case.
416 """
417 return value.upper()
420def unwrap(val):
421 """Remove newlines and leading whitespace from a multi-line string with
422 a consistent indentation level.
424 The first line of the string may be only a newline or may contain text
425 followed by a newline, either is ok. After the first line, each line must
426 begin with a consistant amount of whitespace. So, content of a
427 triple-quoted string may begin immediately after the quotes, or the string
428 may start with a newline. Each line after that must be the same amount of
429 indentation/whitespace followed by text and a newline. The last line may
430 end with a new line but is not required to do so.
432 Parameters
433 ----------
434 val : `str`
435 The string to change.
437 Returns
438 -------
439 strippedString : `str`
440 The string with newlines, indentation, and leading and trailing
441 whitespace removed.
442 """
444 def splitSection(val):
445 if not val.startswith("\n"): 445 ↛ 449line 445 didn't jump to line 449, because the condition on line 445 was never false
446 firstLine, _, val = val.partition("\n")
447 firstLine += " "
448 else:
449 firstLine = ""
450 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
452 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
455class option_section: # noqa: N801
456 """Decorator to add a section label between options in the help text of a
457 command.
459 Parameters
460 ----------
461 sectionText : `str`
462 The text to print in the section identifier.
463 """
465 def __init__(self, sectionText):
466 self.sectionText = "\n" + sectionText
468 def __call__(self, f):
469 # Generate a parameter declaration that will be unique for this
470 # section.
471 return click.option(
472 f"--option-section-{str(uuid.uuid4())}", sectionText=self.sectionText, cls=OptionSection
473 )(f)
476class MWPath(click.Path):
477 """Overrides click.Path to implement file-does-not-exist checking.
479 Changes the definition of ``exists` so that `True` indicates the location
480 (file or directory) must exist, `False` indicates the location must *not*
481 exist, and `None` indicates that the file may exist or not. The standard
482 definition for the `click.Path` ``exists`` parameter is that for `True` a
483 location must exist, but `False` means it is not required to exist (not
484 that it is required to not exist).
486 Parameters
487 ----------
488 exists : `True`, `False`, or `None`
489 If `True`, the location (file or directory) indicated by the caller
490 must exist. If `False` the location must not exist. If `None`, the
491 location may exist or not.
493 For other parameters see `click.Path`.
494 """
496 def __init__(
497 self,
498 exists=None,
499 file_okay=True,
500 dir_okay=True,
501 writable=False,
502 readable=True,
503 resolve_path=False,
504 allow_dash=False,
505 path_type=None,
506 ):
507 self.mustNotExist = exists is False
508 if exists is None: 508 ↛ 510line 508 didn't jump to line 510, because the condition on line 508 was never false
509 exists = False
510 super().__init__(exists, file_okay, dir_okay, writable, readable, resolve_path, allow_dash, path_type)
512 def convert(self, value, param, ctx):
513 """Called by click.ParamType to "convert values through types".
514 `click.Path` uses this step to verify Path conditions."""
515 if self.mustNotExist and os.path.exists(value):
516 self.fail(f'Path "{value}" should not exist.')
517 return super().convert(value, param, ctx)
520class MWOption(click.Option):
521 """Overrides click.Option with desired behaviors."""
523 def make_metavar(self):
524 """Overrides `click.Option.make_metavar`. Makes the metavar for the
525 help menu. Adds a space and an elipsis after the metavar name if
526 the option accepts multiple inputs, otherwise defers to the base
527 implementation.
529 By default click does not add an elipsis when multiple is True and
530 nargs is 1. And when nargs does not equal 1 click adds an elipsis
531 without a space between the metavar and the elipsis, but we prefer a
532 space between.
534 Does not get called for some option types (e.g. flag) so metavar
535 transformation that must apply to all types should be applied in
536 get_help_record.
537 """
538 metavar = super().make_metavar()
539 if self.multiple and self.nargs == 1:
540 metavar += " ..."
541 elif self.nargs != 1:
542 metavar = f"{metavar[:-3]} ..."
543 return metavar
546class MWArgument(click.Argument):
547 """Overrides click.Argument with desired behaviors."""
549 def make_metavar(self):
550 """Overrides `click.Option.make_metavar`. Makes the metavar for the
551 help menu. Always adds a space and an elipsis (' ...') after the
552 metavar name if the option accepts multiple inputs.
554 By default click adds an elipsis without a space between the metavar
555 and the elipsis, but we prefer a space between.
557 Returns
558 -------
559 metavar : `str`
560 The metavar value.
561 """
562 metavar = super().make_metavar()
563 if self.nargs != 1:
564 metavar = f"{metavar[:-3]} ..."
565 return metavar
568class OptionSection(MWOption):
569 """Implements an Option that prints a section label in the help text and
570 does not pass any value to the command function.
572 This class does a bit of hackery to add a section label to a click command
573 help output: first, `expose_value` is set to `False` so that no value is
574 passed to the command function. Second, this class overrides
575 `click.Option.get_help_record` to return the section label string without
576 any prefix so that it stands out as a section label.
578 This class overrides the hidden attribute because our documentation build
579 tool, sphinx-click, implements its own `get_help_record` function which
580 builds the record from other option values (e.g. `name`, `opts`), which
581 breaks the hack we use to make `get_help_record` only return the
582 `sectionText`. Fortunately, Click gets the value of `hidden` inside the
583 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before
584 entering its `_get_help_record` function. So, making the hidden property
585 return True hides this option from sphinx-click, while allowing the section
586 text to be returned by our `get_help_record` method when using Click.
588 The intention for this implementation is to do minimally invasive overrides
589 of the click classes so as to be robust and easy to fix if the click
590 internals change.
592 Parameters
593 ----------
594 sectionName : `str`
595 The parameter declaration for this option. It is not shown to the user,
596 it must be unique within the command. If using the `section` decorator
597 to add a section to a command's options, the section name is
598 auto-generated.
599 sectionText : `str`
600 The text to print in the section identifier.
601 """
603 @property
604 def hidden(self):
605 return True
607 @hidden.setter
608 def hidden(self, val):
609 pass
611 def __init__(self, sectionName, sectionText):
612 super().__init__(sectionName, expose_value=False)
613 self.sectionText = sectionText
615 def get_help_record(self, ctx):
616 return (self.sectionText, "")
619class MWOptionDecorator:
620 """Wraps the click.option decorator to enable shared options to be declared
621 and allows inspection of the shared option.
622 """
624 def __init__(self, *param_decls, **kwargs):
625 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption), **kwargs)
626 opt = click.Option(param_decls, **kwargs)
627 self._name = opt.name
628 self._opts = opt.opts
630 def name(self):
631 """Get the name that will be passed to the command function for this
632 option."""
633 return self._name
635 def opts(self):
636 """Get the flags that will be used for this option on the command
637 line."""
638 return self._opts
640 @property
641 def help(self):
642 """Get the help text for this option. Returns an empty string if no
643 help was defined."""
644 return self.partialOpt.keywords.get("help", "")
646 def __call__(self, *args, **kwargs):
647 return self.partialOpt(*args, **kwargs)
650class MWArgumentDecorator:
651 """Wraps the click.argument decorator to enable shared arguments to be
652 declared."""
654 def __init__(self, *param_decls, **kwargs):
655 self._helpText = kwargs.pop("help", None)
656 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
658 def __call__(self, *args, help=None, **kwargs):
659 def decorator(f):
660 if help is not None:
661 self._helpText = help
662 if self._helpText:
663 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
664 return self.partialArg(*args, **kwargs)(f)
666 return decorator
669class MWCommand(click.Command):
670 """Command subclass that stores a copy of the args list for use by the
671 command."""
673 extra_epilog = None
675 def __init__(self, *args, **kwargs):
676 # wrap callback method with catch_and_exit decorator
677 callback = kwargs.get("callback")
678 if callback is not None: 678 ↛ 681line 678 didn't jump to line 681, because the condition on line 678 was never false
679 kwargs = kwargs.copy()
680 kwargs["callback"] = catch_and_exit(callback)
681 super().__init__(*args, **kwargs)
683 def parse_args(self, ctx, args):
684 MWCtxObj.getFrom(ctx).args = copy.copy(args)
685 super().parse_args(ctx, args)
687 @property
688 def epilog(self):
689 """Override the epilog attribute to add extra_epilog (if defined by a
690 subclass) to the end of any epilog provided by a subcommand.
691 """
692 ret = self._epilog if self._epilog else ""
693 if self.extra_epilog:
694 if ret:
695 ret += "\n\n"
696 ret += self.extra_epilog
697 return ret
699 @epilog.setter
700 def epilog(self, val):
701 self._epilog = val
704class ButlerCommand(MWCommand):
705 """Command subclass with butler-command specific overrides."""
707 extra_epilog = "See 'butler --help' for more options."
710class OptionGroup:
711 """Base class for an option group decorator. Requires the option group
712 subclass to have a property called `decorator`."""
714 def __call__(self, f):
715 for decorator in reversed(self.decorators):
716 f = decorator(f)
717 return f
720class MWCtxObj:
721 """Helper object for managing the `click.Context.obj` parameter, allows
722 obj data to be managed in a consistent way.
724 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
725 initialize the obj if needed and return a new or existing MWCtxObj.
727 Attributes
728 ----------
729 args : `list` [`str`]
730 The list of arguments (argument values, option flags, and option
731 values), split using whitespace, that were passed in on the command
732 line for the subcommand represented by the parent context object.
733 """
735 def __init__(self):
737 self.args = None
739 @staticmethod
740 def getFrom(ctx):
741 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
742 new or already existing MWCtxObj."""
743 if ctx.obj is not None:
744 return ctx.obj
745 ctx.obj = MWCtxObj()
746 return ctx.obj
749def yaml_presets(ctx, param, value):
750 """Click callback that reads additional values from the supplied
751 YAML file.
753 Parameters
754 ----------
755 ctx : `click.context`
756 The context for the click operation. Used to extract the subcommand
757 name and translate option & argument names.
758 param : `str`
759 The parameter name.
760 value : `object`
761 The value of the parameter.
762 """
764 def _name_for_option(ctx: click.Context, option: str) -> str:
765 """Use a CLI option name to find the name of the argument to the
766 command function.
768 Parameters
769 ----------
770 ctx : `click.Context`
771 The context for the click operation.
772 option : `str`
773 The option/argument name from the yaml file.
775 Returns
776 -------
777 name : str
778 The name of the argument to use when calling the click.command
779 function, as it should appear in the `ctx.default_map`.
781 Raises
782 ------
783 RuntimeError
784 Raised if the option name from the yaml file does not exist in the
785 command parameters. This catches misspellings and incorrect useage
786 in the yaml file.
787 """
788 for param in ctx.command.params:
789 # Remove leading dashes: they are not used for option names in the
790 # yaml file.
791 if option in [opt.lstrip("-") for opt in param.opts]:
792 return param.name
793 raise RuntimeError(f"'{option}' is not a valid option for {ctx.info_name}")
795 ctx.default_map = ctx.default_map or {}
796 cmd_name = ctx.info_name
797 if value:
798 try:
799 overrides = _read_yaml_presets(value, cmd_name)
800 options = list(overrides.keys())
801 for option in options:
802 name = _name_for_option(ctx, option)
803 if name == option:
804 continue
805 overrides[name] = overrides.pop(option)
806 except Exception as e:
807 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
808 # Override the defaults for this subcommand
809 ctx.default_map.update(overrides)
810 return
813def _read_yaml_presets(file_uri, cmd_name):
814 """Read file command line overrides from YAML config file.
816 Parameters
817 ----------
818 file_uri : `str`
819 URI of override YAML file containing the command line overrides.
820 They should be grouped by command name.
821 cmd_name : `str`
822 The subcommand name that is being modified.
824 Returns
825 -------
826 overrides : `dict` of [`str`, Any]
827 The relevant command line options read from the override file.
828 """
829 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
830 config = Config(file_uri)
831 return config[cmd_name]
834def sortAstropyTable(table, dimensions, sort_first=None):
835 """Sort an astropy table, with prioritization given to columns in this
836 order:
837 1. the provided named columns
838 2. spatial and temporal columns
839 3. the rest of the columns
841 The table is sorted in-place, and is also returned for convenience.
843 Parameters
844 ----------
845 table : `astropy.table.Table`
846 The table to sort
847 dimensions : `list` [``Dimension``]
848 The dimensions of the dataIds in the table (the dimensions should be
849 the same for all the dataIds). Used to determine if the column is
850 spatial, temporal, or neither.
851 sort_first : `list` [`str`]
852 The names of columns that should be sorted first, before spatial and
853 temporal columns.
855 Returns
856 -------
857 `astropy.table.Table`
858 For convenience, the table that has been sorted.
859 """
860 # For sorting we want to ignore the id
861 # We also want to move temporal or spatial dimensions earlier
862 sort_first = sort_first or []
863 sort_early = []
864 sort_late = []
865 for dim in dimensions:
866 if dim.spatial or dim.temporal:
867 sort_early.extend(dim.required.names)
868 else:
869 sort_late.append(str(dim))
870 sort_keys = sort_first + sort_early + sort_late
871 # The required names above means that we have the possibility of
872 # repeats of sort keys. Now have to remove them
873 # (order is retained by dict creation).
874 sort_keys = list(dict.fromkeys(sort_keys).keys())
876 table.sort(sort_keys)
877 return table
880def catch_and_exit(func):
881 """Decorator which catches all exceptions, prints an exception traceback
882 and signals click to exit.
883 """
885 @wraps(func)
886 def inner(*args, **kwargs):
887 try:
888 func(*args, **kwargs)
889 except (click.exceptions.ClickException, click.exceptions.Exit, click.exceptions.Abort):
890 # this is handled by click itself
891 raise
892 except Exception:
893 exc_type, exc_value, exc_tb = sys.exc_info()
894 if exc_tb.tb_next:
895 # do not show this decorator in traceback
896 exc_tb = exc_tb.tb_next
897 log.exception(
898 "Caught an exception, details are in traceback:", exc_info=(exc_type, exc_value, exc_tb)
899 )
900 # tell click to stop, this never returns.
901 click.get_current_context().exit(1)
903 return inner