Coverage for python/lsst/daf/butler/cli/utils.py: 41%
282 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 19:55 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 19:55 +0000
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 click
23import click.exceptions
24import click.testing
25from contextlib import contextmanager
26import copy
27from functools import partial, wraps
28import itertools
29import logging
30import os
31import sys
32import textwrap
33import traceback
34from unittest.mock import patch
35import uuid
36import yaml
38from .cliLog import CliLog
39from ..core.utils import iterable
40from ..core.config import Config
42log = logging.getLogger(__name__)
44# This is used as the metavar argument to Options that accept multiple string
45# inputs, which may be comma-separarated. For example:
46# --my-opt foo,bar --my-opt baz.
47# Other arguments to the Option should include multiple=true and
48# callback=split_kv.
49typeStrAcceptsMultiple = "TEXT ..."
50typeStrAcceptsSingle = "TEXT"
52# For parameters that support key-value inputs, this defines the separator
53# for those inputs.
54split_kv_separator = "="
57# The standard help string for the --where option when it takes a WHERE clause.
58where_help = "A string expression similar to a SQL WHERE clause. May involve any column of a " \
59 "dimension table or a dimension name as a shortcut for the primary key column of a " \
60 "dimension table."
63def astropyTablesToStr(tables):
64 """Render astropy tables to string as they are displayed in the CLI.
66 Output formatting matches ``printAstropyTables``.
67 """
68 ret = ""
69 for table in tables:
70 ret += "\n"
71 table.pformat_all()
72 ret += "\n"
73 return ret
76def printAstropyTables(tables):
77 """Print astropy tables to be displayed in the CLI.
79 Output formatting matches ``astropyTablesToStr``.
80 """
81 for table in tables:
82 print("")
83 table.pprint_all()
84 print("")
87def textTypeStr(multiple):
88 """Get the text type string for CLI help documentation.
90 Parameters
91 ----------
92 multiple : `bool`
93 True if multiple text values are allowed, False if only one value is
94 allowed.
96 Returns
97 -------
98 textTypeStr : `str`
99 The type string to use.
100 """
101 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
104class LogCliRunner(click.testing.CliRunner):
105 """A test runner to use when the logging system will be initialized by code
106 under test, calls CliLog.resetLog(), which undoes any logging setup that
107 was done with the CliLog interface.
109 lsst.log modules can not be set back to an uninitialized state (python
110 logging modules can be set back to NOTSET), instead they are set to
111 `CliLog.defaultLsstLogLevel`."""
113 def invoke(self, *args, **kwargs):
114 result = super().invoke(*args, **kwargs)
115 CliLog.resetLog()
116 return result
119def clickResultMsg(result):
120 """Get a standard assert message from a click result
122 Parameters
123 ----------
124 result : click.Result
125 The result object returned from click.testing.CliRunner.invoke
127 Returns
128 -------
129 msg : `str`
130 The message string.
131 """
132 msg = f"""\noutput: {result.output}\nexception: {result.exception}"""
133 if result.exception:
134 msg += f"""\ntraceback: {"".join(traceback.format_tb(result.exception.__traceback__))}"""
135 return msg
138@contextmanager
139def command_test_env(runner, commandModule, commandName):
140 """A context manager that creates (and then cleans up) an environment that
141 provides a CLI plugin command with the given name.
143 Parameters
144 ----------
145 runner : click.testing.CliRunner
146 The test runner to use to create the isolated filesystem.
147 commandModule : `str`
148 The importable module that the command can be imported from.
149 commandName : `str`
150 The name of the command being published to import.
151 """
152 with runner.isolated_filesystem():
153 with open("resources.yaml", "w") as f:
154 f.write(yaml.dump({"cmd": {"import": commandModule, "commands": [commandName]}}))
155 # Add a colon to the end of the path on the next line, this tests the
156 # case where the lookup in LoaderCLI._getPluginList generates an empty
157 # string in one of the list entries and verifies that the empty string
158 # is properly stripped out.
159 with patch.dict("os.environ", {"DAF_BUTLER_PLUGINS": f"{os.path.realpath(f.name)}:"}):
160 yield
163def addArgumentHelp(doc, helpText):
164 """Add a Click argument's help message to a function's documentation.
166 This is needed because click presents arguments in the order the argument
167 decorators are applied to a function, top down. But, the evaluation of the
168 decorators happens bottom up, so if arguments just append their help to the
169 function's docstring, the argument descriptions appear in reverse order
170 from the order they are applied in.
172 Parameters
173 ----------
174 doc : `str`
175 The function's docstring.
176 helpText : `str`
177 The argument's help string to be inserted into the function's
178 docstring.
180 Returns
181 -------
182 doc : `str`
183 Updated function documentation.
184 """
185 if doc is None: 185 ↛ 186line 185 didn't jump to line 186, because the condition on line 185 was never true
186 doc = helpText
187 else:
188 # See click documentation for details:
189 # https://click.palletsprojects.com/en/7.x/documentation/#truncating-help-texts
190 # In short, text for the click command help can be truncated by putting
191 # "\f" in the docstring, everything after it should be removed
192 if "\f" in doc: 192 ↛ 193line 192 didn't jump to line 193, because the condition on line 192 was never true
193 doc = doc.split("\f")[0]
195 doclines = doc.splitlines()
196 # The function's docstring may span multiple lines, so combine the
197 # docstring from all the first lines until a blank line is encountered.
198 # (Lines after the first blank line will be argument help.)
199 while len(doclines) > 1 and doclines[1]:
200 doclines[0] = " ".join((doclines[0], doclines.pop(1).strip()))
201 doclines.insert(1, helpText)
202 doclines.insert(1, "\n")
203 doc = "\n".join(doclines)
204 return doc
207def split_commas(context, param, values):
208 """Process a tuple of values, where each value may contain comma-separated
209 values, and return a single list of all the passed-in values.
211 This function can be passed to the 'callback' argument of a click.option to
212 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
214 Parameters
215 ----------
216 context : `click.Context` or `None`
217 The current execution context. Unused, but Click always passes it to
218 callbacks.
219 param : `click.core.Option` or `None`
220 The parameter being handled. Unused, but Click always passes it to
221 callbacks.
222 values : [`str`]
223 All the values passed for this option. Strings may contain commas,
224 which will be treated as delimiters for separate values.
226 Returns
227 -------
228 list of string
229 The passed in values separated by commas and combined into a single
230 list.
231 """
232 if values is None:
233 return values
234 valueList = []
235 for value in iterable(values):
236 valueList.extend(value.split(","))
237 return tuple(valueList)
240def split_kv(context, param, values, choice=None, multiple=True, normalize=False, separator="=",
241 unseparated_okay=False, return_type=dict, default_key="", reverse_kv=False,
242 add_to_default=False):
243 """Process a tuple of values that are key-value pairs separated by a given
244 separator. Multiple pairs may be comma separated. Return a dictionary of
245 all the passed-in values.
247 This function can be passed to the 'callback' argument of a click.option to
248 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
250 Parameters
251 ----------
252 context : `click.Context` or `None`
253 The current execution context. Unused, but Click always passes it to
254 callbacks.
255 param : `click.core.Option` or `None`
256 The parameter being handled. Unused, but Click always passes it to
257 callbacks.
258 values : [`str`]
259 All the values passed for this option. Strings may contain commas,
260 which will be treated as delimiters for separate values.
261 choice : `click.Choice`, optional
262 If provided, verify each value is a valid choice using the provided
263 `click.Choice` instance. If None, no verification will be done. By
264 default None
265 multiple : `bool`, optional
266 If true, the value may contain multiple comma-separated values. By
267 default True.
268 normalize : `bool`, optional
269 If True and `choice.case_sensitive == False`, normalize the string the
270 user provided to match the choice's case. By default False.
271 separator : str, optional
272 The character that separates key-value pairs. May not be a comma or an
273 empty space (for space separators use Click's default implementation
274 for tuples; `type=(str, str)`). By default "=".
275 unseparated_okay : `bool`, optional
276 If True, allow values that do not have a separator. They will be
277 returned in the values dict as a tuple of values in the key '', that
278 is: `values[''] = (unseparated_values, )`. By default False.
279 return_type : `type`, must be `dict` or `tuple`
280 The type of the value that should be returned.
281 If `dict` then the returned object will be a dict, for each item in
282 values, the value to the left of the separator will be the key and the
283 value to the right of the separator will be the value.
284 If `tuple` then the returned object will be a tuple. Each item in the
285 tuple will be 2-item tuple, the first item will be the value to the
286 left of the separator and the second item will be the value to the
287 right. By default `dict`.
288 default_key : `Any`
289 The key to use if a value is passed that is not a key-value pair.
290 (Passing values that are not key-value pairs requires
291 ``unseparated_okay`` to be `True`.)
292 reverse_kv : bool
293 If true then for each item in values, the value to the left of the
294 separator is treated as the value and the value to the right of the
295 separator is treated as the key. By default False.
296 add_to_default : `bool`, optional
297 If True, then passed-in values will not overwrite the default value
298 unless the ``return_type`` is `dict` and passed-in value(s) have the
299 same key(s) as the default value.
301 Returns
302 -------
303 values : `dict` [`str`, `str`]
304 The passed-in values in dict form.
306 Raises
307 ------
308 `click.ClickException`
309 Raised if the separator is not found in an entry, or if duplicate keys
310 are encountered.
311 """
313 def norm(val):
314 """If `normalize` is True and `choice` is not `None`, find the value
315 in the available choices and return the value as spelled in the
316 choices.
318 Assumes that val exists in choices; `split_kv` uses the `choice`
319 instance to verify val is a valid choice.
320 """
321 if normalize and choice is not None:
322 v = val.casefold()
323 for opt in choice.choices:
324 if opt.casefold() == v:
325 return opt
326 return val
328 class RetDict:
330 def __init__(self):
331 self.ret = {}
333 def add(self, key, val):
334 if reverse_kv:
335 key, val = val, key
336 self.ret[key] = val
338 def get(self):
339 return self.ret
341 class RetTuple:
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.append((key, val))
351 def get(self):
352 return tuple(self.ret)
354 if separator in (",", " "):
355 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
356 vals = values # preserve the original argument for error reporting below.
358 if add_to_default:
359 default = param.get_default(context)
360 if default:
361 vals = itertools.chain(default, vals)
363 if return_type is dict:
364 ret = RetDict()
365 elif return_type is tuple:
366 ret = RetTuple()
367 else:
368 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
369 if multiple:
370 vals = split_commas(context, param, vals)
371 for val in iterable(vals):
372 if unseparated_okay and separator not in val:
373 if choice is not None:
374 choice(val) # will raise if val is an invalid choice
375 ret.add(default_key, norm(val))
376 else:
377 try:
378 k, v = val.split(separator)
379 if choice is not None:
380 choice(v) # will raise if val is an invalid choice
381 except ValueError:
382 raise click.ClickException(
383 f"Could not parse key-value pair '{val}' using separator '{separator}', "
384 f"with multiple values {'allowed' if multiple else 'not allowed'}.")
385 ret.add(k, norm(v))
386 return ret.get()
389def to_upper(context, param, value):
390 """Convert a value to upper case.
392 Parameters
393 ----------
394 context : click.Context
396 values : string
397 The value to be converted.
399 Returns
400 -------
401 string
402 A copy of the passed-in value, converted to upper case.
403 """
404 return value.upper()
407def unwrap(val):
408 """Remove newlines and leading whitespace from a multi-line string with
409 a consistent indentation level.
411 The first line of the string may be only a newline or may contain text
412 followed by a newline, either is ok. After the first line, each line must
413 begin with a consistant amount of whitespace. So, content of a
414 triple-quoted string may begin immediately after the quotes, or the string
415 may start with a newline. Each line after that must be the same amount of
416 indentation/whitespace followed by text and a newline. The last line may
417 end with a new line but is not required to do so.
419 Parameters
420 ----------
421 val : `str`
422 The string to change.
424 Returns
425 -------
426 strippedString : `str`
427 The string with newlines, indentation, and leading and trailing
428 whitespace removed.
429 """
430 def splitSection(val):
431 if not val.startswith("\n"): 431 ↛ 435line 431 didn't jump to line 435, because the condition on line 431 was never false
432 firstLine, _, val = val.partition("\n")
433 firstLine += " "
434 else:
435 firstLine = ""
436 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
438 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
441class option_section: # noqa: N801
442 """Decorator to add a section label between options in the help text of a
443 command.
445 Parameters
446 ----------
447 sectionText : `str`
448 The text to print in the section identifier.
449 """
451 def __init__(self, sectionText):
452 self.sectionText = "\n" + sectionText
454 def __call__(self, f):
455 # Generate a parameter declaration that will be unique for this
456 # section.
457 return click.option(f"--option-section-{str(uuid.uuid4())}",
458 sectionText=self.sectionText,
459 cls=OptionSection)(f)
462class MWPath(click.Path):
463 """Overrides click.Path to implement file-does-not-exist checking.
465 Changes the definition of ``exists` so that `True` indicates the location
466 (file or directory) must exist, `False` indicates the location must *not*
467 exist, and `None` indicates that the file may exist or not. The standard
468 definition for the `click.Path` ``exists`` parameter is that for `True` a
469 location must exist, but `False` means it is not required to exist (not
470 that it is required to not exist).
472 Parameters
473 ----------
474 exists : `True`, `False`, or `None`
475 If `True`, the location (file or directory) indicated by the caller
476 must exist. If `False` the location must not exist. If `None`, the
477 location may exist or not.
479 For other parameters see `click.Path`.
480 """
482 def __init__(self, exists=None, file_okay=True, dir_okay=True,
483 writable=False, readable=True, resolve_path=False,
484 allow_dash=False, path_type=None):
485 self.mustNotExist = exists is False
486 if exists is None: 486 ↛ 488line 486 didn't jump to line 488, because the condition on line 486 was never false
487 exists = False
488 super().__init__(exists, file_okay, dir_okay, writable, readable,
489 resolve_path, allow_dash, path_type)
491 def convert(self, value, param, ctx):
492 """Called by click.ParamType to "convert values through types".
493 `click.Path` uses this step to verify Path conditions."""
494 if self.mustNotExist and os.path.exists(value):
495 self.fail(f'Path "{value}" should not exist.')
496 return super().convert(value, param, ctx)
499class MWOption(click.Option):
500 """Overrides click.Option with desired behaviors."""
502 def make_metavar(self):
503 """Overrides `click.Option.make_metavar`. Makes the metavar for the
504 help menu. Adds a space and an elipsis after the metavar name if
505 the option accepts multiple inputs, otherwise defers to the base
506 implementation.
508 By default click does not add an elipsis when multiple is True and
509 nargs is 1. And when nargs does not equal 1 click adds an elipsis
510 without a space between the metavar and the elipsis, but we prefer a
511 space between.
513 Does not get called for some option types (e.g. flag) so metavar
514 transformation that must apply to all types should be applied in
515 get_help_record.
516 """
517 metavar = super().make_metavar()
518 if self.multiple and self.nargs == 1:
519 metavar += " ..."
520 elif self.nargs != 1:
521 metavar = f"{metavar[:-3]} ..."
522 return metavar
525class MWArgument(click.Argument):
526 """Overrides click.Argument with desired behaviors."""
528 def make_metavar(self):
529 """Overrides `click.Option.make_metavar`. Makes the metavar for the
530 help menu. Always adds a space and an elipsis (' ...') after the
531 metavar name if the option accepts multiple inputs.
533 By default click adds an elipsis without a space between the metavar
534 and the elipsis, but we prefer a space between.
536 Returns
537 -------
538 metavar : `str`
539 The metavar value.
540 """
541 metavar = super().make_metavar()
542 if self.nargs != 1:
543 metavar = f"{metavar[:-3]} ..."
544 return metavar
547class OptionSection(MWOption):
548 """Implements an Option that prints a section label in the help text and
549 does not pass any value to the command function.
551 This class does a bit of hackery to add a section label to a click command
552 help output: first, `expose_value` is set to `False` so that no value is
553 passed to the command function. Second, this class overrides
554 `click.Option.get_help_record` to return the section label string without
555 any prefix so that it stands out as a section label.
557 This class overrides the hidden attribute because our documentation build
558 tool, sphinx-click, implements its own `get_help_record` function which
559 builds the record from other option values (e.g. `name`, `opts`), which
560 breaks the hack we use to make `get_help_record` only return the
561 `sectionText`. Fortunately, Click gets the value of `hidden` inside the
562 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before
563 entering its `_get_help_record` function. So, making the hidden property
564 return True hides this option from sphinx-click, while allowing the section
565 text to be returned by our `get_help_record` method when using Click.
567 The intention for this implementation is to do minimally invasive overrides
568 of the click classes so as to be robust and easy to fix if the click
569 internals change.
571 Parameters
572 ----------
573 sectionName : `str`
574 The parameter declaration for this option. It is not shown to the user,
575 it must be unique within the command. If using the `section` decorator
576 to add a section to a command's options, the section name is
577 auto-generated.
578 sectionText : `str`
579 The text to print in the section identifier.
580 """
582 @property
583 def hidden(self):
584 return True
586 @hidden.setter
587 def hidden(self, val):
588 pass
590 def __init__(self, sectionName, sectionText):
591 super().__init__(sectionName, expose_value=False)
592 self.sectionText = sectionText
594 def get_help_record(self, ctx):
595 return (self.sectionText, "")
598class MWOptionDecorator:
599 """Wraps the click.option decorator to enable shared options to be declared
600 and allows inspection of the shared option.
601 """
603 def __init__(self, *param_decls, **kwargs):
604 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption),
605 **kwargs)
606 opt = click.Option(param_decls, **kwargs)
607 self._name = opt.name
608 self._opts = opt.opts
610 def name(self):
611 """Get the name that will be passed to the command function for this
612 option."""
613 return self._name
615 def opts(self):
616 """Get the flags that will be used for this option on the command
617 line."""
618 return self._opts
620 @property
621 def help(self):
622 """Get the help text for this option. Returns an empty string if no
623 help was defined."""
624 return self.partialOpt.keywords.get("help", "")
626 def __call__(self, *args, **kwargs):
627 return self.partialOpt(*args, **kwargs)
630class MWArgumentDecorator:
631 """Wraps the click.argument decorator to enable shared arguments to be
632 declared. """
634 def __init__(self, *param_decls, **kwargs):
635 self._helpText = kwargs.pop("help", None)
636 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
638 def __call__(self, *args, help=None, **kwargs):
639 def decorator(f):
640 if help is not None:
641 self._helpText = help
642 if self._helpText:
643 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
644 return self.partialArg(*args, **kwargs)(f)
645 return decorator
648class MWCommand(click.Command):
649 """Command subclass that stores a copy of the args list for use by the
650 command."""
652 extra_epilog = None
654 def __init__(self, *args, **kwargs):
655 # wrap callback method with catch_and_exit decorator
656 callback = kwargs.get("callback")
657 if callback is not None: 657 ↛ 660line 657 didn't jump to line 660, because the condition on line 657 was never false
658 kwargs = kwargs.copy()
659 kwargs["callback"] = catch_and_exit(callback)
660 super().__init__(*args, **kwargs)
662 def parse_args(self, ctx, args):
663 MWCtxObj.getFrom(ctx).args = copy.copy(args)
664 super().parse_args(ctx, args)
666 @property
667 def epilog(self):
668 """Override the epilog attribute to add extra_epilog (if defined by a
669 subclass) to the end of any epilog provided by a subcommand.
670 """
671 ret = self._epilog if self._epilog else ""
672 if self.extra_epilog:
673 if ret:
674 ret += "\n\n"
675 ret += self.extra_epilog
676 return ret
678 @epilog.setter
679 def epilog(self, val):
680 self._epilog = val
683class ButlerCommand(MWCommand):
684 """Command subclass with butler-command specific overrides."""
686 extra_epilog = "See 'butler --help' for more options."
689class OptionGroup:
690 """Base class for an option group decorator. Requires the option group
691 subclass to have a property called `decorator`."""
693 def __call__(self, f):
694 for decorator in reversed(self.decorators):
695 f = decorator(f)
696 return f
699class MWCtxObj():
700 """Helper object for managing the `click.Context.obj` parameter, allows
701 obj data to be managed in a consistent way.
703 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
704 initialize the obj if needed and return a new or existing MWCtxObj.
706 Attributes
707 ----------
708 args : `list` [`str`]
709 The list of arguments (argument values, option flags, and option
710 values), split using whitespace, that were passed in on the command
711 line for the subcommand represented by the parent context object.
712 """
714 def __init__(self):
716 self.args = None
718 @staticmethod
719 def getFrom(ctx):
720 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
721 new or already existing MWCtxObj."""
722 if ctx.obj is not None:
723 return ctx.obj
724 ctx.obj = MWCtxObj()
725 return ctx.obj
728def yaml_presets(ctx, param, value):
729 """Click callback that reads additional values from the supplied
730 YAML file.
732 Parameters
733 ----------
734 ctx : `click.context`
735 The context for the click operation. Used to extract the subcommand
736 name.
737 param : `str`
738 The parameter name.
739 value : `object`
740 The value of the parameter.
741 """
742 ctx.default_map = ctx.default_map or {}
743 cmd_name = ctx.info_name
744 if value:
745 try:
746 overrides = _read_yaml_presets(value, cmd_name)
747 except Exception as e:
748 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
749 # Override the defaults for this subcommand
750 ctx.default_map.update(overrides)
751 return
754def _read_yaml_presets(file_uri, cmd_name):
755 """Read file command line overrides from YAML config file.
757 Parameters
758 ----------
759 file_uri : `str`
760 URI of override YAML file containing the command line overrides.
761 They should be grouped by command name.
762 cmd_name : `str`
763 The subcommand name that is being modified.
765 Returns
766 -------
767 overrides : `dict` of [`str`, Any]
768 The relevant command line options read from the override file.
769 """
770 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
771 config = Config(file_uri)
772 return config[cmd_name]
775def sortAstropyTable(table, dimensions, sort_first=None):
776 """Sort an astropy table, with prioritization given to columns in this
777 order:
778 1. the provided named columns
779 2. spatial and temporal columns
780 3. the rest of the columns
782 The table is sorted in-place, and is also returned for convenience.
784 Parameters
785 ----------
786 table : `astropy.table.Table`
787 The table to sort
788 dimensions : `list` [``Dimension``]
789 The dimensions of the dataIds in the table (the dimensions should be
790 the same for all the dataIds). Used to determine if the column is
791 spatial, temporal, or neither.
792 sort_first : `list` [`str`]
793 The names of columns that should be sorted first, before spatial and
794 temporal columns.
796 Returns
797 -------
798 `astropy.table.Table`
799 For convenience, the table that has been sorted.
800 """
801 # For sorting we want to ignore the id
802 # We also want to move temporal or spatial dimensions earlier
803 sort_first = sort_first or []
804 sort_early = []
805 sort_late = []
806 for dim in dimensions:
807 if dim.spatial or dim.temporal:
808 sort_early.extend(dim.required.names)
809 else:
810 sort_late.append(str(dim))
811 sort_keys = sort_first + sort_early + sort_late
812 # The required names above means that we have the possibility of
813 # repeats of sort keys. Now have to remove them
814 # (order is retained by dict creation).
815 sort_keys = list(dict.fromkeys(sort_keys).keys())
817 table.sort(sort_keys)
818 return table
821def catch_and_exit(func):
822 """Decorator which catches all exceptions, prints an exception traceback
823 and signals click to exit.
824 """
825 @wraps(func)
826 def inner(*args, **kwargs):
827 try:
828 func(*args, **kwargs)
829 except (click.exceptions.ClickException, click.exceptions.Exit, click.exceptions.Abort):
830 # this is handled by click itself
831 raise
832 except Exception:
833 exc_type, exc_value, exc_tb = sys.exc_info()
834 if exc_tb.tb_next:
835 # do not show this decorator in traceback
836 exc_tb = exc_tb.tb_next
837 log.exception("Caught an exception, details are in traceback:",
838 exc_info=(exc_type, exc_value, exc_tb))
839 # tell click to stop, this never returns.
840 click.get_current_context().exit(1)
842 return inner