Coverage for python/lsst/daf/butler/cli/utils.py : 36%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22import click
23import click.testing
24from contextlib import contextmanager
25import copy
26from functools import partial
27import itertools
28import io
29import logging
30import os
31import textwrap
32import traceback
33from unittest.mock import MagicMock, patch
34import uuid
35import yaml
37from .cliLog import CliLog
38from ..core.utils import iterable
39from ..core.config import Config
41log = logging.getLogger(__name__)
43# CLI_MOCK_ENV is set by some tests as an environment variable, it
44# indicates to the cli_handle_exception function that instead of executing the
45# command implementation function it should use the Mocker class for unit test
46# verification.
47mockEnvVarKey = "CLI_MOCK_ENV"
48mockEnvVar = {mockEnvVarKey: "1"}
50# This is used as the metavar argument to Options that accept multiple string
51# inputs, which may be comma-separarated. For example:
52# --my-opt foo,bar --my-opt baz.
53# Other arguments to the Option should include multiple=true and
54# callback=split_kv.
55typeStrAcceptsMultiple = "TEXT ..."
56typeStrAcceptsSingle = "TEXT"
58# For parameters that support key-value inputs, this defines the separator
59# for those inputs.
60split_kv_separator = "="
63def textTypeStr(multiple):
64 """Get the text type string for CLI help documentation.
66 Parameters
67 ----------
68 multiple : `bool`
69 True if multiple text values are allowed, False if only one value is
70 allowed.
72 Returns
73 -------
74 textTypeStr : `str`
75 The type string to use.
76 """
77 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
80class Mocker:
82 mock = MagicMock()
84 def __init__(self, *args, **kwargs):
85 """Mocker is a helper class for unit tests. It can be imported and
86 called and later imported again and call can be verified.
88 For convenience, constructor arguments are forwarded to the call
89 function.
90 """
91 self.__call__(*args, **kwargs)
93 def __call__(self, *args, **kwargs):
94 """Creates a MagicMock and stores it in a static variable that can
95 later be verified.
96 """
97 Mocker.mock(*args, **kwargs)
99 @classmethod
100 def reset(cls):
101 cls.mock.reset_mock()
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 doclines.insert(1, helpText)
197 doclines.insert(1, "\n")
198 doc = "\n".join(doclines)
199 return doc
202def split_commas(context, param, values):
203 """Process a tuple of values, where each value may contain comma-separated
204 values, and return a single list of all the passed-in values.
206 This function can be passed to the 'callback' argument of a click.option to
207 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
209 Parameters
210 ----------
211 context : `click.Context` or `None`
212 The current execution context. Unused, but Click always passes it to
213 callbacks.
214 param : `click.core.Option` or `None`
215 The parameter being handled. Unused, but Click always passes it to
216 callbacks.
217 values : [`str`]
218 All the values passed for this option. Strings may contain commas,
219 which will be treated as delimiters for separate values.
221 Returns
222 -------
223 list of string
224 The passed in values separated by commas and combined into a single
225 list.
226 """
227 if values is None:
228 return values
229 valueList = []
230 for value in iterable(values):
231 valueList.extend(value.split(","))
232 return tuple(valueList)
235def split_kv(context, param, values, choice=None, multiple=True, normalize=False, separator="=",
236 unseparated_okay=False, return_type=dict, default_key="", reverse_kv=False,
237 add_to_default=False):
238 """Process a tuple of values that are key-value pairs separated by a given
239 separator. Multiple pairs may be comma separated. Return a dictionary of
240 all the passed-in values.
242 This function can be passed to the 'callback' argument of a click.option to
243 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
245 Parameters
246 ----------
247 context : `click.Context` or `None`
248 The current execution context. Unused, but Click always passes it to
249 callbacks.
250 param : `click.core.Option` or `None`
251 The parameter being handled. Unused, but Click always passes it to
252 callbacks.
253 values : [`str`]
254 All the values passed for this option. Strings may contain commas,
255 which will be treated as delimiters for separate values.
256 choice : `click.Choice`, optional
257 If provided, verify each value is a valid choice using the provided
258 `click.Choice` instance. If None, no verification will be done. By
259 default None
260 multiple : `bool`, optional
261 If true, the value may contain multiple comma-separated values. By
262 default True.
263 normalize : `bool`, optional
264 If True and `choice.case_sensitive == False`, normalize the string the
265 user provided to match the choice's case. By default False.
266 separator : str, optional
267 The character that separates key-value pairs. May not be a comma or an
268 empty space (for space separators use Click's default implementation
269 for tuples; `type=(str, str)`). By default "=".
270 unseparated_okay : `bool`, optional
271 If True, allow values that do not have a separator. They will be
272 returned in the values dict as a tuple of values in the key '', that
273 is: `values[''] = (unseparated_values, )`. By default False.
274 return_type : `type`, must be `dict` or `tuple`
275 The type of the value that should be returned.
276 If `dict` then the returned object will be a dict, for each item in
277 values, the value to the left of the separator will be the key and the
278 value to the right of the separator will be the value.
279 If `tuple` then the returned object will be a tuple. Each item in the
280 tuple will be 2-item tuple, the first item will be the value to the
281 left of the separator and the second item will be the value to the
282 right. By default `dict`.
283 default_key : `Any`
284 The key to use if a value is passed that is not a key-value pair.
285 (Passing values that are not key-value pairs requires
286 ``unseparated_okay`` to be `True`.)
287 reverse_kv : bool
288 If true then for each item in values, the value to the left of the
289 separator is treated as the value and the value to the right of the
290 separator is treated as the key. By default False.
291 add_to_default : `bool`, optional
292 If True, then passed-in values will not overwrite the default value
293 unless the ``return_type`` is `dict` and passed-in value(s) have the
294 same key(s) as the default value.
296 Returns
297 -------
298 values : `dict` [`str`, `str`]
299 The passed-in values in dict form.
301 Raises
302 ------
303 `click.ClickException`
304 Raised if the separator is not found in an entry, or if duplicate keys
305 are encountered.
306 """
308 def norm(val):
309 """If `normalize` is True and `choice` is not `None`, find the value
310 in the available choices and return the value as spelled in the
311 choices.
313 Assumes that val exists in choices; `split_kv` uses the `choice`
314 instance to verify val is a valid choice.
315 """
316 if normalize and choice is not None:
317 v = val.casefold()
318 for opt in choice.choices:
319 if opt.casefold() == v:
320 return opt
321 return val
323 class RetDict:
325 def __init__(self):
326 self.ret = {}
328 def add(self, key, val):
329 if reverse_kv:
330 key, val = val, key
331 self.ret[key] = val
333 def get(self):
334 return self.ret
336 class RetTuple:
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.append((key, val))
346 def get(self):
347 return tuple(self.ret)
349 if separator in (",", " "):
350 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
351 vals = values # preserve the original argument for error reporting below.
353 if add_to_default:
354 default = param.get_default(context)
355 if default:
356 vals = itertools.chain(default, vals)
358 if return_type is dict:
359 ret = RetDict()
360 elif return_type is tuple:
361 ret = RetTuple()
362 else:
363 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
364 if multiple:
365 vals = split_commas(context, param, vals)
366 for val in iterable(vals):
367 if unseparated_okay and separator not in val:
368 if choice is not None:
369 choice(val) # will raise if val is an invalid choice
370 ret.add(default_key, norm(val))
371 else:
372 try:
373 k, v = val.split(separator)
374 if choice is not None:
375 choice(v) # will raise if val is an invalid choice
376 except ValueError:
377 raise click.ClickException(
378 f"Could not parse key-value pair '{val}' using separator '{separator}', "
379 f"with multiple values {'allowed' if multiple else 'not allowed'}.")
380 ret.add(k, norm(v))
381 return ret.get()
384def to_upper(context, param, value):
385 """Convert a value to upper case.
387 Parameters
388 ----------
389 context : click.Context
391 values : string
392 The value to be converted.
394 Returns
395 -------
396 string
397 A copy of the passed-in value, converted to upper case.
398 """
399 return value.upper()
402def unwrap(val):
403 """Remove newlines and leading whitespace from a multi-line string with
404 a consistent indentation level.
406 The first line of the string may be only a newline or may contain text
407 followed by a newline, either is ok. After the first line, each line must
408 begin with a consistant amount of whitespace. So, content of a
409 triple-quoted string may begin immediately after the quotes, or the string
410 may start with a newline. Each line after that must be the same amount of
411 indentation/whitespace followed by text and a newline. The last line may
412 end with a new line but is not required to do so.
414 Parameters
415 ----------
416 val : `str`
417 The string to change.
419 Returns
420 -------
421 strippedString : `str`
422 The string with newlines, indentation, and leading and trailing
423 whitespace removed.
424 """
425 def splitSection(val):
426 if not val.startswith("\n"): 426 ↛ 430line 426 didn't jump to line 430, because the condition on line 426 was never false
427 firstLine, _, val = val.partition("\n")
428 firstLine += " "
429 else:
430 firstLine = ""
431 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
433 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
436def cli_handle_exception(func, *args, **kwargs):
437 """Wrap a function call in an exception handler that raises a
438 ClickException if there is an Exception.
440 Also provides support for unit testing by testing for an environment
441 variable, and if it is present prints the function name, args, and kwargs
442 to stdout so they can be read and verified by the unit test code.
444 Parameters
445 ----------
446 func : function
447 A function to be called and exceptions handled. Will pass args & kwargs
448 to the function.
450 Returns
451 -------
452 The result of calling func.
454 Raises
455 ------
456 click.ClickException
457 An exception to be handled by the Click CLI tool.
458 """
459 if mockEnvVarKey in os.environ:
460 Mocker(*args, **kwargs)
461 return
463 try:
464 return func(*args, **kwargs)
465 except Exception as e:
466 msg = io.StringIO()
467 traceback.print_exc(file=msg)
468 log.debug(msg.getvalue())
469 raise click.ClickException(e) from e
472class option_section: # noqa: N801
473 """Decorator to add a section label between options in the help text of a
474 command.
476 Parameters
477 ----------
478 sectionText : `str`
479 The text to print in the section identifier.
480 """
482 def __init__(self, sectionText):
483 self.sectionText = "\n" + sectionText
485 def __call__(self, f):
486 # Generate a parameter declaration that will be unique for this
487 # section.
488 return click.option(f"--option-section-{str(uuid.uuid4())}",
489 sectionText=self.sectionText,
490 cls=OptionSection)(f)
493class MWPath(click.Path):
494 """Overrides click.Path to implement file-does-not-exist checking.
496 Changes the definition of ``exists` so that `True` indicates the location
497 (file or directory) must exist, `False` indicates the location must *not*
498 exist, and `None` indicates that the file may exist or not. The standard
499 definition for the `click.Path` ``exists`` parameter is that for `True` a
500 location must exist, but `False` means it is not required to exist (not
501 that it is required to not exist).
503 Parameters
504 ----------
505 exists : `True`, `False`, or `None`
506 If `True`, the location (file or directory) indicated by the caller
507 must exist. If `False` the location must not exist. If `None`, the
508 location may exist or not.
510 For other parameters see `click.Path`.
511 """
513 def __init__(self, exists=None, file_okay=True, dir_okay=True,
514 writable=False, readable=True, resolve_path=False,
515 allow_dash=False, path_type=None):
516 self.mustNotExist = exists is False
517 if exists is None:
518 exists = False
519 super().__init__(exists, file_okay, dir_okay, writable, readable,
520 resolve_path, allow_dash, path_type)
522 def convert(self, value, param, ctx):
523 """Called by click.ParamType to "convert values through types".
524 `click.Path` uses this step to verify Path conditions."""
525 if self.mustNotExist and os.path.exists(value):
526 self.fail(f'{self.path_type} "{value}" should not exist.')
527 return super().convert(value, param, ctx)
530class MWOption(click.Option):
531 """Overrides click.Option with desired behaviors."""
533 def make_metavar(self):
534 """Overrides `click.Option.make_metavar`. Makes the metavar for the
535 help menu. Adds a space and an elipsis after the metavar name if
536 the option accepts multiple inputs, otherwise defers to the base
537 implementation.
539 By default click does not add an elipsis when multiple is True and
540 nargs is 1. And when nargs does not equal 1 click adds an elipsis
541 without a space between the metavar and the elipsis, but we prefer a
542 space between.
544 Does not get called for some option types (e.g. flag) so metavar
545 transformation that must apply to all types should be applied in
546 get_help_record.
547 """
548 metavar = super().make_metavar()
549 if self.multiple and self.nargs == 1:
550 metavar += " ..."
551 elif self.nargs != 1:
552 metavar = f"{metavar[:-3]} ..."
553 return metavar
556class MWArgument(click.Argument):
557 """Overrides click.Argument with desired behaviors."""
559 def make_metavar(self):
560 """Overrides `click.Option.make_metavar`. Makes the metavar for the
561 help menu. Always adds a space and an elipsis (' ...') after the
562 metavar name if the option accepts multiple inputs.
564 By default click adds an elipsis without a space between the metavar
565 and the elipsis, but we prefer a space between.
567 Returns
568 -------
569 metavar : `str`
570 The metavar value.
571 """
572 metavar = super().make_metavar()
573 if self.nargs != 1:
574 metavar = f"{metavar[:-3]} ..."
575 return metavar
578class OptionSection(MWOption):
579 """Implements an Option that prints a section label in the help text and
580 does not pass any value to the command function.
582 This class does a bit of hackery to add a section label to a click command
583 help output: first, `expose_value` is set to `False` so that no value is
584 passed to the command function. Second, this class overrides
585 `click.Option.get_help_record` to return the section label string without
586 any prefix so that it stands out as a section label.
588 This class overrides the hidden attribute because our documentation build
589 tool, sphinx-click, implements its own `get_help_record` function which
590 builds the record from other option values (e.g. `name`, `opts`), which
591 breaks the hack we use to make `get_help_record` only return the
592 `sectionText`. Fortunately, Click gets the value of `hidden` inside the
593 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before
594 entering its `_get_help_record` function. So, making the hidden property
595 return True hides this option from sphinx-click, while allowing the section
596 text to be returned by our `get_help_record` method when using Click.
598 The intention for this implementation is to do minimally invasive overrides
599 of the click classes so as to be robust and easy to fix if the click
600 internals change.
602 Parameters
603 ----------
604 sectionName : `str`
605 The parameter declaration for this option. It is not shown to the user,
606 it must be unique within the command. If using the `section` decorator
607 to add a section to a command's options, the section name is
608 auto-generated.
609 sectionText : `str`
610 The text to print in the section identifier.
611 """
613 @property
614 def hidden(self):
615 return True
617 @hidden.setter
618 def hidden(self, val):
619 pass
621 def __init__(self, sectionName, sectionText):
622 super().__init__(sectionName, expose_value=False)
623 self.sectionText = sectionText
625 def get_help_record(self, ctx):
626 return (self.sectionText, "")
629class MWOptionDecorator:
630 """Wraps the click.option decorator to enable shared options to be declared
631 and allows inspection of the shared option.
632 """
634 def __init__(self, *param_decls, **kwargs):
635 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption),
636 **kwargs)
637 opt = click.Option(param_decls, **kwargs)
638 self._name = opt.name
639 self._opts = opt.opts
641 def name(self):
642 """Get the name that will be passed to the command function for this
643 option."""
644 return self._name
646 def opts(self):
647 """Get the flags that will be used for this option on the command
648 line."""
649 return self._opts
651 @property
652 def help(self):
653 """Get the help text for this option. Returns an empty string if no
654 help was defined."""
655 return self.partialOpt.keywords.get("help", "")
657 def __call__(self, *args, **kwargs):
658 return self.partialOpt(*args, **kwargs)
661class MWArgumentDecorator:
662 """Wraps the click.argument decorator to enable shared arguments to be
663 declared. """
665 def __init__(self, *param_decls, **kwargs):
666 self._helpText = kwargs.pop("help", None)
667 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
669 def __call__(self, *args, help=None, **kwargs):
670 def decorator(f):
671 if help is not None:
672 self._helpText = help
673 if self._helpText: 673 ↛ 675line 673 didn't jump to line 675, because the condition on line 673 was never false
674 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
675 return self.partialArg(*args, **kwargs)(f)
676 return decorator
679class MWCommand(click.Command):
680 """Command subclass that stores a copy of the args list for use by the
681 command."""
683 extra_epilog = None
685 def parse_args(self, ctx, args):
686 MWCtxObj.getFrom(ctx).args = copy.copy(args)
687 super().parse_args(ctx, args)
689 @property
690 def epilog(self):
691 """Override the epilog attribute to add extra_epilog (if defined by a
692 subclass) to the end of any epilog provided by a subcommand.
693 """
694 ret = self._epilog if self._epilog else ""
695 if self.extra_epilog:
696 if ret:
697 ret += "\n\n"
698 ret += self.extra_epilog
699 return ret
701 @epilog.setter
702 def epilog(self, val):
703 self._epilog = val
706class ButlerCommand(MWCommand):
707 """Command subclass with butler-command specific overrides."""
709 extra_epilog = "See 'butler --help' for more options."
712class MWCtxObj():
713 """Helper object for managing the `click.Context.obj` parameter, allows
714 obj data to be managed in a consistent way.
716 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
717 initialize the obj if needed and return a new or existing MWCtxObj.
719 Attributes
720 ----------
721 args : `list` [`str`]
722 The list of arguments (argument values, option flags, and option
723 values), split using whitespace, that were passed in on the command
724 line for the subcommand represented by the parent context object.
725 """
727 def __init__(self):
729 self.args = None
731 @staticmethod
732 def getFrom(ctx):
733 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
734 new or already existing MWCtxObj."""
735 if ctx.obj is not None:
736 return ctx.obj
737 ctx.obj = MWCtxObj()
738 return ctx.obj
741def yaml_presets(ctx, param, value):
742 """Click callback that reads additional values from the supplied
743 YAML file.
745 Parameters
746 ----------
747 ctx : `click.context`
748 The context for the click operation. Used to extract the subcommand
749 name.
750 param : `str`
751 The parameter name.
752 value : `object`
753 The value of the parameter.
754 """
755 ctx.default_map = ctx.default_map or {}
756 cmd_name = ctx.info_name
757 if value:
758 try:
759 overrides = _read_yaml_presets(value, cmd_name)
760 except Exception as e:
761 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
762 # Override the defaults for this subcommand
763 ctx.default_map.update(overrides)
764 return
767def _read_yaml_presets(file_uri, cmd_name):
768 """Read file command line overrides from YAML config file.
770 Parameters
771 ----------
772 file_uri : `str`
773 URI of override YAML file containing the command line overrides.
774 They should be grouped by command name.
775 cmd_name : `str`
776 The subcommand name that is being modified.
778 Returns
779 -------
780 overrides : `dict` of [`str`, Any]
781 The relevant command line options read from the override file.
782 """
783 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
784 config = Config(file_uri)
785 return config[cmd_name]
788def sortAstropyTable(table, dimensions, sort_first=None):
789 """Sort an astropy table, with prioritization given to columns in this
790 order:
791 1. the provided named columns
792 2. spatial and temporal columns
793 3. the rest of the columns
795 The table is sorted in-place, and is also returned for convenience.
797 Parameters
798 ----------
799 table : `astropy.table.Table`
800 The table to sort
801 dimensions : `list` [``Dimension``]
802 The dimensions of the dataIds in the table (the dimensions should be
803 the same for all the dataIds). Used to determine if the column is
804 spatial, temporal, or neither.
805 sort_first : `list` [`str`]
806 The names of columns that should be sorted first, before spatial and
807 temporal columns.
809 Returns
810 -------
811 `astropy.table.Table`
812 For convenience, the table that has been sorted.
813 """
814 # For sorting we want to ignore the id
815 # We also want to move temporal or spatial dimensions earlier
816 sort_first = sort_first or []
817 sort_early = []
818 sort_late = []
819 for dim in dimensions:
820 if dim.spatial or dim.temporal:
821 sort_early.extend(dim.required.names)
822 else:
823 sort_late.append(str(dim))
824 sort_keys = sort_first + sort_early + sort_late
825 # The required names above means that we have the possibility of
826 # repeats of sort keys. Now have to remove them
827 # (order is retained by dict creation).
828 sort_keys = list(dict.fromkeys(sort_keys).keys())
830 table.sort(sort_keys)
831 return table