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 io
28import os
29import textwrap
30import traceback
31from unittest.mock import MagicMock, patch
32import uuid
33import yaml
34import logging
36from .cliLog import CliLog
37from ..core.utils import iterable
38from ..core.config import Config
40log = logging.getLogger(__name__)
42# CLI_MOCK_ENV is set by some tests as an environment variable, it
43# indicates to the cli_handle_exception function that instead of executing the
44# command implementation function it should use the Mocker class for unit test
45# verification.
46mockEnvVarKey = "CLI_MOCK_ENV"
47mockEnvVar = {mockEnvVarKey: "1"}
49# This is used as the metavar argument to Options that accept multiple string
50# inputs, which may be comma-separarated. For example:
51# --my-opt foo,bar --my-opt baz.
52# Other arguments to the Option should include multiple=true and
53# callback=split_kv.
54typeStrAcceptsMultiple = "TEXT ..."
55typeStrAcceptsSingle = "TEXT"
57# For parameters that support key-value inputs, this defines the separator
58# for those inputs.
59split_kv_separator = "="
62def textTypeStr(multiple):
63 """Get the text type string for CLI help documentation.
65 Parameters
66 ----------
67 multiple : `bool`
68 True if multiple text values are allowed, False if only one value is
69 allowed.
71 Returns
72 -------
73 textTypeStr : `str`
74 The type string to use.
75 """
76 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
79class Mocker:
81 mock = MagicMock()
83 def __init__(self, *args, **kwargs):
84 """Mocker is a helper class for unit tests. It can be imported and
85 called and later imported again and call can be verified.
87 For convenience, constructor arguments are forwarded to the call
88 function.
89 """
90 self.__call__(*args, **kwargs)
92 def __call__(self, *args, **kwargs):
93 """Creates a MagicMock and stores it in a static variable that can
94 later be verified.
95 """
96 Mocker.mock(*args, **kwargs)
98 @classmethod
99 def reset(cls):
100 cls.mock.reset_mock()
103class LogCliRunner(click.testing.CliRunner):
104 """A test runner to use when the logging system will be initialized by code
105 under test, calls CliLog.resetLog(), which undoes any logging setup that
106 was done with the CliLog interface.
108 lsst.log modules can not be set back to an uninitialized state (python
109 logging modules can be set back to NOTSET), instead they are set to
110 `CliLog.defaultLsstLogLevel`."""
112 def invoke(self, *args, **kwargs):
113 result = super().invoke(*args, **kwargs)
114 CliLog.resetLog()
115 return result
118def clickResultMsg(result):
119 """Get a standard assert message from a click result
121 Parameters
122 ----------
123 result : click.Result
124 The result object returned from click.testing.CliRunner.invoke
126 Returns
127 -------
128 msg : `str`
129 The message string.
130 """
131 msg = f"""\noutput: {result.output}\nexception: {result.exception}"""
132 if result.exception:
133 msg += f"""\ntraceback: {"".join(traceback.format_tb(result.exception.__traceback__))}"""
134 return msg
137@contextmanager
138def command_test_env(runner, commandModule, commandName):
139 """A context manager that creates (and then cleans up) an environment that
140 provides a CLI plugin command with the given name.
142 Parameters
143 ----------
144 runner : click.testing.CliRunner
145 The test runner to use to create the isolated filesystem.
146 commandModule : `str`
147 The importable module that the command can be imported from.
148 commandName : `str`
149 The name of the command being published to import.
150 """
151 with runner.isolated_filesystem():
152 with open("resources.yaml", "w") as f:
153 f.write(yaml.dump({"cmd": {"import": commandModule, "commands": [commandName]}}))
154 # Add a colon to the end of the path on the next line, this tests the
155 # case where the lookup in LoaderCLI._getPluginList generates an empty
156 # string in one of the list entries and verifies that the empty string
157 # is properly stripped out.
158 with patch.dict("os.environ", {"DAF_BUTLER_PLUGINS": f"{os.path.realpath(f.name)}:"}):
159 yield
162def addArgumentHelp(doc, helpText):
163 """Add a Click argument's help message to a function's documentation.
165 This is needed because click presents arguments in the order the argument
166 decorators are applied to a function, top down. But, the evaluation of the
167 decorators happens bottom up, so if arguments just append their help to the
168 function's docstring, the argument descriptions appear in reverse order
169 from the order they are applied in.
171 Parameters
172 ----------
173 doc : `str`
174 The function's docstring.
175 helpText : `str`
176 The argument's help string to be inserted into the function's
177 docstring.
179 Returns
180 -------
181 doc : `str`
182 Updated function documentation.
183 """
184 if doc is None: 184 ↛ 185line 184 didn't jump to line 185, because the condition on line 184 was never true
185 doc = helpText
186 else:
187 # See click documentation for details:
188 # https://click.palletsprojects.com/en/7.x/documentation/#truncating-help-texts
189 # In short, text for the click command help can be truncated by putting
190 # "\f" in the docstring, everything after it should be removed
191 if "\f" in doc: 191 ↛ 192line 191 didn't jump to line 192, because the condition on line 191 was never true
192 doc = doc.split("\f")[0]
194 doclines = doc.splitlines()
195 doclines.insert(1, helpText)
196 doclines.insert(1, "\n")
197 doc = "\n".join(doclines)
198 return doc
201def split_commas(context, param, values):
202 """Process a tuple of values, where each value may contain comma-separated
203 values, and return a single list of all the passed-in values.
205 This function can be passed to the 'callback' argument of a click.option to
206 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
208 Parameters
209 ----------
210 context : `click.Context` or `None`
211 The current execution context. Unused, but Click always passes it to
212 callbacks.
213 param : `click.core.Option` or `None`
214 The parameter being handled. Unused, but Click always passes it to
215 callbacks.
216 values : [`str`]
217 All the values passed for this option. Strings may contain commas,
218 which will be treated as delimiters for separate values.
220 Returns
221 -------
222 list of string
223 The passed in values separated by commas and combined into a single
224 list.
225 """
226 if values is None:
227 return values
228 valueList = []
229 for value in iterable(values):
230 valueList.extend(value.split(","))
231 return tuple(valueList)
234def split_kv(context, param, values, choice=None, multiple=True, normalize=False, separator="=",
235 unseparated_okay=False, return_type=dict, default_key="", reverse_kv=False):
236 """Process a tuple of values that are key-value pairs separated by a given
237 separator. Multiple pairs may be comma separated. Return a dictionary of
238 all the passed-in values.
240 This function can be passed to the 'callback' argument of a click.option to
241 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
243 Parameters
244 ----------
245 context : `click.Context` or `None`
246 The current execution context. Unused, but Click always passes it to
247 callbacks.
248 param : `click.core.Option` or `None`
249 The parameter being handled. Unused, but Click always passes it to
250 callbacks.
251 values : [`str`]
252 All the values passed for this option. Strings may contain commas,
253 which will be treated as delimiters for separate values.
254 choice : `click.Choice`, optional
255 If provided, verify each value is a valid choice using the provided
256 `click.Choice` instance. If None, no verification will be done. By
257 default None
258 multiple : `bool`, optional
259 If true, the value may contain multiple comma-separated values. By
260 default True.
261 normalize : `bool`, optional
262 If True and `choice.case_sensitive == False`, normalize the string the
263 user provided to match the choice's case. By default False.
264 separator : str, optional
265 The character that separates key-value pairs. May not be a comma or an
266 empty space (for space separators use Click's default implementation
267 for tuples; `type=(str, str)`). By default "=".
268 unseparated_okay : `bool`, optional
269 If True, allow values that do not have a separator. They will be
270 returned in the values dict as a tuple of values in the key '', that
271 is: `values[''] = (unseparated_values, )`. By default False.
272 return_type : `type`, must be `dict` or `tuple`
273 The type of the value that should be returned.
274 If `dict` then the returned object will be a dict, for each item in
275 values, the value to the left of the separator will be the key and the
276 value to the right of the separator will be the value.
277 If `tuple` then the returned object will be a tuple. Each item in the
278 tuple will be 2-item tuple, the first item will be the value to the
279 left of the separator and the second item will be the value to the
280 right. By default `dict`.
281 default_key : `Any`
282 The key to use if a value is passed that is not a key-value pair.
283 (Passing values that are not key-value pairs requires
284 ``unseparated_okay`` to be `True`.)
285 reverse_kv : bool
286 If true then for each item in values, the value to the left of the
287 separator is treated as the value and the value to the right of the
288 separator is treated as the key. By default False.
290 Returns
291 -------
292 values : `dict` [`str`, `str`]
293 The passed-in values in dict form.
295 Raises
296 ------
297 `click.ClickException`
298 Raised if the separator is not found in an entry, or if duplicate keys
299 are encountered.
300 """
302 def norm(val):
303 """If `normalize` is True and `choice` is not `None`, find the value
304 in the available choices and return the value as spelled in the
305 choices.
307 Assumes that val exists in choices; `split_kv` uses the `choice`
308 instance to verify val is a valid choice.
309 """
310 if normalize and choice is not None:
311 v = val.casefold()
312 for opt in choice.choices:
313 if opt.casefold() == v:
314 return opt
315 return val
317 class RetDict:
319 def __init__(self):
320 self.ret = {}
322 def add(self, key, val):
323 if reverse_kv:
324 key, val = val, key
325 if key in self.ret:
326 raise click.ClickException(f"Duplicate entries for '{k}' in '{values}'")
327 self.ret[key] = val
329 def get(self):
330 return self.ret
332 class RetTuple:
334 def __init__(self):
335 self.ret = []
337 def add(self, key, val):
338 if reverse_kv:
339 key, val = val, key
340 self.ret.append((key, val))
342 def get(self):
343 return tuple(self.ret)
345 if separator in (",", " "):
346 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
347 vals = values # preserve the original argument for error reporting below.
348 if return_type is dict:
349 ret = RetDict()
350 elif return_type is tuple:
351 ret = RetTuple()
352 else:
353 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
354 if multiple:
355 vals = split_commas(context, param, vals)
356 for val in iterable(vals):
357 if unseparated_okay and separator not in val:
358 if choice is not None:
359 choice(val) # will raise if val is an invalid choice
360 ret.add(default_key, norm(val))
361 else:
362 try:
363 k, v = val.split(separator)
364 if choice is not None:
365 choice(v) # will raise if val is an invalid choice
366 except ValueError:
367 raise click.ClickException(
368 f"Could not parse key-value pair '{val}' using separator '{separator}', "
369 f"with multiple values {'allowed' if multiple else 'not allowed'}.")
370 ret.add(k, norm(v))
371 return ret.get()
374def to_upper(context, param, value):
375 """Convert a value to upper case.
377 Parameters
378 ----------
379 context : click.Context
381 values : string
382 The value to be converted.
384 Returns
385 -------
386 string
387 A copy of the passed-in value, converted to upper case.
388 """
389 return value.upper()
392def unwrap(val):
393 """Remove newlines and leading whitespace from a multi-line string with
394 a consistent indentation level.
396 The first line of the string may be only a newline or may contain text
397 followed by a newline, either is ok. After the first line, each line must
398 begin with a consistant amount of whitespace. So, content of a
399 triple-quoted string may begin immediately after the quotes, or the string
400 may start with a newline. Each line after that must be the same amount of
401 indentation/whitespace followed by text and a newline. The last line may
402 end with a new line but is not required to do so.
404 Parameters
405 ----------
406 val : `str`
407 The string to change.
409 Returns
410 -------
411 strippedString : `str`
412 The string with newlines, indentation, and leading and trailing
413 whitespace removed.
414 """
415 def splitSection(val):
416 if not val.startswith("\n"): 416 ↛ 420line 416 didn't jump to line 420, because the condition on line 416 was never false
417 firstLine, _, val = val.partition("\n")
418 firstLine += " "
419 else:
420 firstLine = ""
421 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
423 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
426def cli_handle_exception(func, *args, **kwargs):
427 """Wrap a function call in an exception handler that raises a
428 ClickException if there is an Exception.
430 Also provides support for unit testing by testing for an environment
431 variable, and if it is present prints the function name, args, and kwargs
432 to stdout so they can be read and verified by the unit test code.
434 Parameters
435 ----------
436 func : function
437 A function to be called and exceptions handled. Will pass args & kwargs
438 to the function.
440 Returns
441 -------
442 The result of calling func.
444 Raises
445 ------
446 click.ClickException
447 An exception to be handled by the Click CLI tool.
448 """
449 if mockEnvVarKey in os.environ:
450 Mocker(*args, **kwargs)
451 return
453 try:
454 return func(*args, **kwargs)
455 except Exception as e:
456 msg = io.StringIO()
457 traceback.print_exc(file=msg)
458 log.debug(msg.getvalue())
459 raise click.ClickException(e) from e
462class option_section: # noqa: N801
463 """Decorator to add a section label between options in the help text of a
464 command.
466 Parameters
467 ----------
468 sectionText : `str`
469 The text to print in the section identifier.
470 """
472 def __init__(self, sectionText):
473 self.sectionText = "\n" + sectionText
475 def __call__(self, f):
476 # Generate a parameter declaration that will be unique for this
477 # section.
478 return click.option(f"--option-section-{str(uuid.uuid4())}",
479 sectionText=self.sectionText,
480 cls=OptionSection)(f)
483class MWPath(click.Path):
484 """Overrides click.Path to implement file-does-not-exist checking.
486 Changes the definition of ``exists` so that `True` indicates the location
487 (file or directory) must exist, `False` indicates the location must *not*
488 exist, and `None` indicates that the file may exist or not. The standard
489 definition for the `click.Path` ``exists`` parameter is that for `True` a
490 location must exist, but `False` means it is not required to exist (not
491 that it is required to not exist).
493 Parameters
494 ----------
495 exists : `True`, `False`, or `None`
496 If `True`, the location (file or directory) indicated by the caller
497 must exist. If `False` the location must not exist. If `None`, the
498 location may exist or not.
500 For other parameters see `click.Path`.
501 """
503 def __init__(self, exists=None, file_okay=True, dir_okay=True,
504 writable=False, readable=True, resolve_path=False,
505 allow_dash=False, path_type=None):
506 self.mustNotExist = exists is False
507 if exists is None:
508 exists = False
509 super().__init__(exists, file_okay, dir_okay, writable, readable,
510 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'{self.path_type} "{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),
626 **kwargs)
627 opt = click.Option(param_decls, **kwargs)
628 self._name = opt.name
629 self._opts = opt.opts
631 def name(self):
632 """Get the name that will be passed to the command function for this
633 option."""
634 return self._name
636 def opts(self):
637 """Get the flags that will be used for this option on the command
638 line."""
639 return self._opts
641 @property
642 def help(self):
643 """Get the help text for this option. Returns an empty string if no
644 help was defined."""
645 return self.partialOpt.keywords.get("help", "")
647 def __call__(self, *args, **kwargs):
648 return self.partialOpt(*args, **kwargs)
651class MWArgumentDecorator:
652 """Wraps the click.argument decorator to enable shared arguments to be
653 declared. """
655 def __init__(self, *param_decls, **kwargs):
656 self._helpText = kwargs.pop("help", None)
657 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
659 def __call__(self, *args, help=None, **kwargs):
660 def decorator(f):
661 if help is not None:
662 self._helpText = help
663 if self._helpText: 663 ↛ 665line 663 didn't jump to line 665, because the condition on line 663 was never false
664 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
665 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 parse_args(self, ctx, args):
676 MWCtxObj.getFrom(ctx).args = copy.copy(args)
677 super().parse_args(ctx, args)
679 @property
680 def epilog(self):
681 """Override the epilog attribute to add extra_epilog (if defined by a
682 subclass) to the end of any epilog provided by a subcommand.
683 """
684 ret = self._epilog if self._epilog else ""
685 if self.extra_epilog:
686 if ret:
687 ret += "\n\n"
688 ret += self.extra_epilog
689 return ret
691 @epilog.setter
692 def epilog(self, val):
693 self._epilog = val
696class ButlerCommand(MWCommand):
697 """Command subclass with butler-command specific overrides."""
699 extra_epilog = "See 'butler --help' for more options."
702class MWCtxObj():
703 """Helper object for managing the `click.Context.obj` parameter, allows
704 obj data to be managed in a consistent way.
706 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
707 initialize the obj if needed and return a new or existing MWCtxObj.
709 Attributes
710 ----------
711 args : `list` [`str`]
712 The list of arguments (argument values, option flags, and option
713 values), split using whitespace, that were passed in on the command
714 line for the subcommand represented by the parent context object.
715 """
717 def __init__(self):
719 self.args = None
721 @staticmethod
722 def getFrom(ctx):
723 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
724 new or already existing MWCtxObj."""
725 if ctx.obj is not None:
726 return ctx.obj
727 ctx.obj = MWCtxObj()
728 return ctx.obj
731def yaml_presets(ctx, param, value):
732 """Click callback that reads additional values from the supplied
733 YAML file.
735 Parameters
736 ----------
737 ctx : `click.context`
738 The context for the click operation. Used to extract the subcommand
739 name.
740 param : `str`
741 The parameter name.
742 value : `object`
743 The value of the parameter.
744 """
745 ctx.default_map = ctx.default_map or {}
746 cmd_name = ctx.info_name
747 if value:
748 try:
749 overrides = _read_yaml_presets(value, cmd_name)
750 except Exception as e:
751 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
752 # Override the defaults for this subcommand
753 ctx.default_map.update(overrides)
754 return
757def _read_yaml_presets(file_uri, cmd_name):
758 """Read file command line overrides from YAML config file.
760 Parameters
761 ----------
762 file_uri : `str`
763 URI of override YAML file containing the command line overrides.
764 They should be grouped by command name.
765 cmd_name : `str`
766 The subcommand name that is being modified.
768 Returns
769 -------
770 overrides : `dict` of [`str`, Any]
771 The relevant command line options read from the override file.
772 """
773 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
774 config = Config(file_uri)
775 return config[cmd_name]
778def sortAstropyTable(table, dimensions, sort_first=None):
779 """Sort an astropy table, with prioritization given to columns in this
780 order:
781 1. the provided named columns
782 2. spatial and temporal columns
783 3. the rest of the columns
785 The table is sorted in-place, and is also returned for convenience.
787 Parameters
788 ----------
789 table : `astropy.table.Table`
790 The table to sort
791 dimensions : `list` [``Dimension``]
792 The dimensions of the dataIds in the table (the dimensions should be
793 the same for all the dataIds). Used to determine if the column is
794 spatial, temporal, or neither.
795 sort_first : `list` [`str`]
796 The names of columns that should be sorted first, before spatial and
797 temporal columns.
799 Returns
800 -------
801 `astropy.table.Table`
802 For convenience, the table that has been sorted.
803 """
804 # For sorting we want to ignore the id
805 # We also want to move temporal or spatial dimensions earlier
806 sort_first = sort_first or []
807 sort_early = []
808 sort_late = []
809 for dim in dimensions:
810 if dim.spatial or dim.temporal:
811 sort_early.extend(dim.required.names)
812 else:
813 sort_late.append(str(dim))
814 sort_keys = sort_first + sort_early + sort_late
815 # The required names above means that we have the possibility of
816 # repeats of sort keys. Now have to remove them
817 # (order is retained by dict creation).
818 sort_keys = list(dict.fromkeys(sort_keys).keys())
820 table.sort(sort_keys)
821 return table