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

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 logging
29import os
30import textwrap
31import traceback
32from unittest.mock import patch
33import uuid
34import yaml
36from .cliLog import CliLog
37from ..core.utils import iterable
38from ..core.config import Config
40log = logging.getLogger(__name__)
42# This is used as the metavar argument to Options that accept multiple string
43# inputs, which may be comma-separarated. For example:
44# --my-opt foo,bar --my-opt baz.
45# Other arguments to the Option should include multiple=true and
46# callback=split_kv.
47typeStrAcceptsMultiple = "TEXT ..."
48typeStrAcceptsSingle = "TEXT"
50# For parameters that support key-value inputs, this defines the separator
51# for those inputs.
52split_kv_separator = "="
55# The standard help string for the --where option when it takes a WHERE clause.
56where_help = "A string expression similar to a SQL WHERE clause. May involve any column of a " \
57 "dimension table or a dimension name as a shortcut for the primary key column of a " \
58 "dimension table."
61def astropyTablesToStr(tables):
62 """Render astropy tables to string as they are displayed in the CLI.
64 Output formatting matches ``printAstropyTables``.
65 """
66 ret = ""
67 for table in tables:
68 ret += "\n"
69 table.pformat_all()
70 ret += "\n"
71 return ret
74def printAstropyTables(tables):
75 """Print astropy tables to be displayed in the CLI.
77 Output formatting matches ``astropyTablesToStr``.
78 """
79 for table in tables:
80 print("")
81 table.pprint_all()
82 print("")
85def textTypeStr(multiple):
86 """Get the text type string for CLI help documentation.
88 Parameters
89 ----------
90 multiple : `bool`
91 True if multiple text values are allowed, False if only one value is
92 allowed.
94 Returns
95 -------
96 textTypeStr : `str`
97 The type string to use.
98 """
99 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
102class LogCliRunner(click.testing.CliRunner):
103 """A test runner to use when the logging system will be initialized by code
104 under test, calls CliLog.resetLog(), which undoes any logging setup that
105 was done with the CliLog interface.
107 lsst.log modules can not be set back to an uninitialized state (python
108 logging modules can be set back to NOTSET), instead they are set to
109 `CliLog.defaultLsstLogLevel`."""
111 def invoke(self, *args, **kwargs):
112 result = super().invoke(*args, **kwargs)
113 CliLog.resetLog()
114 return result
117def clickResultMsg(result):
118 """Get a standard assert message from a click result
120 Parameters
121 ----------
122 result : click.Result
123 The result object returned from click.testing.CliRunner.invoke
125 Returns
126 -------
127 msg : `str`
128 The message string.
129 """
130 msg = f"""\noutput: {result.output}\nexception: {result.exception}"""
131 if result.exception:
132 msg += f"""\ntraceback: {"".join(traceback.format_tb(result.exception.__traceback__))}"""
133 return msg
136@contextmanager
137def command_test_env(runner, commandModule, commandName):
138 """A context manager that creates (and then cleans up) an environment that
139 provides a CLI plugin command with the given name.
141 Parameters
142 ----------
143 runner : click.testing.CliRunner
144 The test runner to use to create the isolated filesystem.
145 commandModule : `str`
146 The importable module that the command can be imported from.
147 commandName : `str`
148 The name of the command being published to import.
149 """
150 with runner.isolated_filesystem():
151 with open("resources.yaml", "w") as f:
152 f.write(yaml.dump({"cmd": {"import": commandModule, "commands": [commandName]}}))
153 # Add a colon to the end of the path on the next line, this tests the
154 # case where the lookup in LoaderCLI._getPluginList generates an empty
155 # string in one of the list entries and verifies that the empty string
156 # is properly stripped out.
157 with patch.dict("os.environ", {"DAF_BUTLER_PLUGINS": f"{os.path.realpath(f.name)}:"}):
158 yield
161def addArgumentHelp(doc, helpText):
162 """Add a Click argument's help message to a function's documentation.
164 This is needed because click presents arguments in the order the argument
165 decorators are applied to a function, top down. But, the evaluation of the
166 decorators happens bottom up, so if arguments just append their help to the
167 function's docstring, the argument descriptions appear in reverse order
168 from the order they are applied in.
170 Parameters
171 ----------
172 doc : `str`
173 The function's docstring.
174 helpText : `str`
175 The argument's help string to be inserted into the function's
176 docstring.
178 Returns
179 -------
180 doc : `str`
181 Updated function documentation.
182 """
183 if doc is None: 183 ↛ 184line 183 didn't jump to line 184, because the condition on line 183 was never true
184 doc = helpText
185 else:
186 # See click documentation for details:
187 # https://click.palletsprojects.com/en/7.x/documentation/#truncating-help-texts
188 # In short, text for the click command help can be truncated by putting
189 # "\f" in the docstring, everything after it should be removed
190 if "\f" in doc: 190 ↛ 191line 190 didn't jump to line 191, because the condition on line 190 was never true
191 doc = doc.split("\f")[0]
193 doclines = doc.splitlines()
194 # The function's docstring may span multiple lines, so combine the
195 # docstring from all the first lines until a blank line is encountered.
196 # (Lines after the first blank line will be argument help.)
197 while len(doclines) > 1 and doclines[1]:
198 doclines[0] = " ".join((doclines[0], doclines.pop(1).strip()))
199 doclines.insert(1, helpText)
200 doclines.insert(1, "\n")
201 doc = "\n".join(doclines)
202 return doc
205def split_commas(context, param, values):
206 """Process a tuple of values, where each value may contain comma-separated
207 values, and return a single list of all the passed-in values.
209 This function can be passed to the 'callback' argument of a click.option to
210 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
212 Parameters
213 ----------
214 context : `click.Context` or `None`
215 The current execution context. Unused, but Click always passes it to
216 callbacks.
217 param : `click.core.Option` or `None`
218 The parameter being handled. Unused, but Click always passes it to
219 callbacks.
220 values : [`str`]
221 All the values passed for this option. Strings may contain commas,
222 which will be treated as delimiters for separate values.
224 Returns
225 -------
226 list of string
227 The passed in values separated by commas and combined into a single
228 list.
229 """
230 if values is None:
231 return values
232 valueList = []
233 for value in iterable(values):
234 valueList.extend(value.split(","))
235 return tuple(valueList)
238def split_kv(context, param, values, choice=None, multiple=True, normalize=False, separator="=",
239 unseparated_okay=False, return_type=dict, default_key="", reverse_kv=False,
240 add_to_default=False):
241 """Process a tuple of values that are key-value pairs separated by a given
242 separator. Multiple pairs may be comma separated. Return a dictionary of
243 all the passed-in values.
245 This function can be passed to the 'callback' argument of a click.option to
246 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
248 Parameters
249 ----------
250 context : `click.Context` or `None`
251 The current execution context. Unused, but Click always passes it to
252 callbacks.
253 param : `click.core.Option` or `None`
254 The parameter being handled. Unused, but Click always passes it to
255 callbacks.
256 values : [`str`]
257 All the values passed for this option. Strings may contain commas,
258 which will be treated as delimiters for separate values.
259 choice : `click.Choice`, optional
260 If provided, verify each value is a valid choice using the provided
261 `click.Choice` instance. If None, no verification will be done. By
262 default None
263 multiple : `bool`, optional
264 If true, the value may contain multiple comma-separated values. By
265 default True.
266 normalize : `bool`, optional
267 If True and `choice.case_sensitive == False`, normalize the string the
268 user provided to match the choice's case. By default False.
269 separator : str, optional
270 The character that separates key-value pairs. May not be a comma or an
271 empty space (for space separators use Click's default implementation
272 for tuples; `type=(str, str)`). By default "=".
273 unseparated_okay : `bool`, optional
274 If True, allow values that do not have a separator. They will be
275 returned in the values dict as a tuple of values in the key '', that
276 is: `values[''] = (unseparated_values, )`. By default False.
277 return_type : `type`, must be `dict` or `tuple`
278 The type of the value that should be returned.
279 If `dict` then the returned object will be a dict, for each item in
280 values, the value to the left of the separator will be the key and the
281 value to the right of the separator will be the value.
282 If `tuple` then the returned object will be a tuple. Each item in the
283 tuple will be 2-item tuple, the first item will be the value to the
284 left of the separator and the second item will be the value to the
285 right. By default `dict`.
286 default_key : `Any`
287 The key to use if a value is passed that is not a key-value pair.
288 (Passing values that are not key-value pairs requires
289 ``unseparated_okay`` to be `True`.)
290 reverse_kv : bool
291 If true then for each item in values, the value to the left of the
292 separator is treated as the value and the value to the right of the
293 separator is treated as the key. By default False.
294 add_to_default : `bool`, optional
295 If True, then passed-in values will not overwrite the default value
296 unless the ``return_type`` is `dict` and passed-in value(s) have the
297 same key(s) as the default value.
299 Returns
300 -------
301 values : `dict` [`str`, `str`]
302 The passed-in values in dict form.
304 Raises
305 ------
306 `click.ClickException`
307 Raised if the separator is not found in an entry, or if duplicate keys
308 are encountered.
309 """
311 def norm(val):
312 """If `normalize` is True and `choice` is not `None`, find the value
313 in the available choices and return the value as spelled in the
314 choices.
316 Assumes that val exists in choices; `split_kv` uses the `choice`
317 instance to verify val is a valid choice.
318 """
319 if normalize and choice is not None:
320 v = val.casefold()
321 for opt in choice.choices:
322 if opt.casefold() == v:
323 return opt
324 return val
326 class RetDict:
328 def __init__(self):
329 self.ret = {}
331 def add(self, key, val):
332 if reverse_kv:
333 key, val = val, key
334 self.ret[key] = val
336 def get(self):
337 return self.ret
339 class RetTuple:
341 def __init__(self):
342 self.ret = []
344 def add(self, key, val):
345 if reverse_kv:
346 key, val = val, key
347 self.ret.append((key, val))
349 def get(self):
350 return tuple(self.ret)
352 if separator in (",", " "):
353 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
354 vals = values # preserve the original argument for error reporting below.
356 if add_to_default:
357 default = param.get_default(context)
358 if default:
359 vals = itertools.chain(default, vals)
361 if return_type is dict:
362 ret = RetDict()
363 elif return_type is tuple:
364 ret = RetTuple()
365 else:
366 raise click.ClickException(f"Internal error: invalid return type '{return_type}' for split_kv.")
367 if multiple:
368 vals = split_commas(context, param, vals)
369 for val in iterable(vals):
370 if unseparated_okay and separator not in val:
371 if choice is not None:
372 choice(val) # will raise if val is an invalid choice
373 ret.add(default_key, norm(val))
374 else:
375 try:
376 k, v = val.split(separator)
377 if choice is not None:
378 choice(v) # will raise if val is an invalid choice
379 except ValueError:
380 raise click.ClickException(
381 f"Could not parse key-value pair '{val}' using separator '{separator}', "
382 f"with multiple values {'allowed' if multiple else 'not allowed'}.")
383 ret.add(k, norm(v))
384 return ret.get()
387def to_upper(context, param, value):
388 """Convert a value to upper case.
390 Parameters
391 ----------
392 context : click.Context
394 values : string
395 The value to be converted.
397 Returns
398 -------
399 string
400 A copy of the passed-in value, converted to upper case.
401 """
402 return value.upper()
405def unwrap(val):
406 """Remove newlines and leading whitespace from a multi-line string with
407 a consistent indentation level.
409 The first line of the string may be only a newline or may contain text
410 followed by a newline, either is ok. After the first line, each line must
411 begin with a consistant amount of whitespace. So, content of a
412 triple-quoted string may begin immediately after the quotes, or the string
413 may start with a newline. Each line after that must be the same amount of
414 indentation/whitespace followed by text and a newline. The last line may
415 end with a new line but is not required to do so.
417 Parameters
418 ----------
419 val : `str`
420 The string to change.
422 Returns
423 -------
424 strippedString : `str`
425 The string with newlines, indentation, and leading and trailing
426 whitespace removed.
427 """
428 def splitSection(val):
429 if not val.startswith("\n"): 429 ↛ 433line 429 didn't jump to line 433, because the condition on line 429 was never false
430 firstLine, _, val = val.partition("\n")
431 firstLine += " "
432 else:
433 firstLine = ""
434 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
436 return "\n\n".join([splitSection(s) for s in val.split("\n\n")])
439class option_section: # noqa: N801
440 """Decorator to add a section label between options in the help text of a
441 command.
443 Parameters
444 ----------
445 sectionText : `str`
446 The text to print in the section identifier.
447 """
449 def __init__(self, sectionText):
450 self.sectionText = "\n" + sectionText
452 def __call__(self, f):
453 # Generate a parameter declaration that will be unique for this
454 # section.
455 return click.option(f"--option-section-{str(uuid.uuid4())}",
456 sectionText=self.sectionText,
457 cls=OptionSection)(f)
460class MWPath(click.Path):
461 """Overrides click.Path to implement file-does-not-exist checking.
463 Changes the definition of ``exists` so that `True` indicates the location
464 (file or directory) must exist, `False` indicates the location must *not*
465 exist, and `None` indicates that the file may exist or not. The standard
466 definition for the `click.Path` ``exists`` parameter is that for `True` a
467 location must exist, but `False` means it is not required to exist (not
468 that it is required to not exist).
470 Parameters
471 ----------
472 exists : `True`, `False`, or `None`
473 If `True`, the location (file or directory) indicated by the caller
474 must exist. If `False` the location must not exist. If `None`, the
475 location may exist or not.
477 For other parameters see `click.Path`.
478 """
480 def __init__(self, exists=None, file_okay=True, dir_okay=True,
481 writable=False, readable=True, resolve_path=False,
482 allow_dash=False, path_type=None):
483 self.mustNotExist = exists is False
484 if exists is None:
485 exists = False
486 super().__init__(exists, file_okay, dir_okay, writable, readable,
487 resolve_path, allow_dash, path_type)
489 def convert(self, value, param, ctx):
490 """Called by click.ParamType to "convert values through types".
491 `click.Path` uses this step to verify Path conditions."""
492 if self.mustNotExist and os.path.exists(value):
493 self.fail(f'Path "{value}" should not exist.')
494 return super().convert(value, param, ctx)
497class MWOption(click.Option):
498 """Overrides click.Option with desired behaviors."""
500 def make_metavar(self):
501 """Overrides `click.Option.make_metavar`. Makes the metavar for the
502 help menu. Adds a space and an elipsis after the metavar name if
503 the option accepts multiple inputs, otherwise defers to the base
504 implementation.
506 By default click does not add an elipsis when multiple is True and
507 nargs is 1. And when nargs does not equal 1 click adds an elipsis
508 without a space between the metavar and the elipsis, but we prefer a
509 space between.
511 Does not get called for some option types (e.g. flag) so metavar
512 transformation that must apply to all types should be applied in
513 get_help_record.
514 """
515 metavar = super().make_metavar()
516 if self.multiple and self.nargs == 1:
517 metavar += " ..."
518 elif self.nargs != 1:
519 metavar = f"{metavar[:-3]} ..."
520 return metavar
523class MWArgument(click.Argument):
524 """Overrides click.Argument with desired behaviors."""
526 def make_metavar(self):
527 """Overrides `click.Option.make_metavar`. Makes the metavar for the
528 help menu. Always adds a space and an elipsis (' ...') after the
529 metavar name if the option accepts multiple inputs.
531 By default click adds an elipsis without a space between the metavar
532 and the elipsis, but we prefer a space between.
534 Returns
535 -------
536 metavar : `str`
537 The metavar value.
538 """
539 metavar = super().make_metavar()
540 if self.nargs != 1:
541 metavar = f"{metavar[:-3]} ..."
542 return metavar
545class OptionSection(MWOption):
546 """Implements an Option that prints a section label in the help text and
547 does not pass any value to the command function.
549 This class does a bit of hackery to add a section label to a click command
550 help output: first, `expose_value` is set to `False` so that no value is
551 passed to the command function. Second, this class overrides
552 `click.Option.get_help_record` to return the section label string without
553 any prefix so that it stands out as a section label.
555 This class overrides the hidden attribute because our documentation build
556 tool, sphinx-click, implements its own `get_help_record` function which
557 builds the record from other option values (e.g. `name`, `opts`), which
558 breaks the hack we use to make `get_help_record` only return the
559 `sectionText`. Fortunately, Click gets the value of `hidden` inside the
560 `Option`'s `get_help_record`, and `sphinx-click` calls `opt.hidden` before
561 entering its `_get_help_record` function. So, making the hidden property
562 return True hides this option from sphinx-click, while allowing the section
563 text to be returned by our `get_help_record` method when using Click.
565 The intention for this implementation is to do minimally invasive overrides
566 of the click classes so as to be robust and easy to fix if the click
567 internals change.
569 Parameters
570 ----------
571 sectionName : `str`
572 The parameter declaration for this option. It is not shown to the user,
573 it must be unique within the command. If using the `section` decorator
574 to add a section to a command's options, the section name is
575 auto-generated.
576 sectionText : `str`
577 The text to print in the section identifier.
578 """
580 @property
581 def hidden(self):
582 return True
584 @hidden.setter
585 def hidden(self, val):
586 pass
588 def __init__(self, sectionName, sectionText):
589 super().__init__(sectionName, expose_value=False)
590 self.sectionText = sectionText
592 def get_help_record(self, ctx):
593 return (self.sectionText, "")
596class MWOptionDecorator:
597 """Wraps the click.option decorator to enable shared options to be declared
598 and allows inspection of the shared option.
599 """
601 def __init__(self, *param_decls, **kwargs):
602 self.partialOpt = partial(click.option, *param_decls, cls=partial(MWOption),
603 **kwargs)
604 opt = click.Option(param_decls, **kwargs)
605 self._name = opt.name
606 self._opts = opt.opts
608 def name(self):
609 """Get the name that will be passed to the command function for this
610 option."""
611 return self._name
613 def opts(self):
614 """Get the flags that will be used for this option on the command
615 line."""
616 return self._opts
618 @property
619 def help(self):
620 """Get the help text for this option. Returns an empty string if no
621 help was defined."""
622 return self.partialOpt.keywords.get("help", "")
624 def __call__(self, *args, **kwargs):
625 return self.partialOpt(*args, **kwargs)
628class MWArgumentDecorator:
629 """Wraps the click.argument decorator to enable shared arguments to be
630 declared. """
632 def __init__(self, *param_decls, **kwargs):
633 self._helpText = kwargs.pop("help", None)
634 self.partialArg = partial(click.argument, *param_decls, cls=MWArgument, **kwargs)
636 def __call__(self, *args, help=None, **kwargs):
637 def decorator(f):
638 if help is not None:
639 self._helpText = help
640 if self._helpText:
641 f.__doc__ = addArgumentHelp(f.__doc__, self._helpText)
642 return self.partialArg(*args, **kwargs)(f)
643 return decorator
646class MWCommand(click.Command):
647 """Command subclass that stores a copy of the args list for use by the
648 command."""
650 extra_epilog = None
652 def parse_args(self, ctx, args):
653 MWCtxObj.getFrom(ctx).args = copy.copy(args)
654 super().parse_args(ctx, args)
656 @property
657 def epilog(self):
658 """Override the epilog attribute to add extra_epilog (if defined by a
659 subclass) to the end of any epilog provided by a subcommand.
660 """
661 ret = self._epilog if self._epilog else ""
662 if self.extra_epilog:
663 if ret:
664 ret += "\n\n"
665 ret += self.extra_epilog
666 return ret
668 @epilog.setter
669 def epilog(self, val):
670 self._epilog = val
673class ButlerCommand(MWCommand):
674 """Command subclass with butler-command specific overrides."""
676 extra_epilog = "See 'butler --help' for more options."
679class OptionGroup:
680 """Base class for an option group decorator. Requires the option group
681 subclass to have a property called `decorator`."""
683 def __call__(self, f):
684 for decorator in reversed(self.decorators):
685 f = decorator(f)
686 return f
689class MWCtxObj():
690 """Helper object for managing the `click.Context.obj` parameter, allows
691 obj data to be managed in a consistent way.
693 `Context.obj` defaults to None. `MWCtxObj.getFrom(ctx)` can be used to
694 initialize the obj if needed and return a new or existing MWCtxObj.
696 Attributes
697 ----------
698 args : `list` [`str`]
699 The list of arguments (argument values, option flags, and option
700 values), split using whitespace, that were passed in on the command
701 line for the subcommand represented by the parent context object.
702 """
704 def __init__(self):
706 self.args = None
708 @staticmethod
709 def getFrom(ctx):
710 """If needed, initialize `ctx.obj` with a new MWCtxObj, and return the
711 new or already existing MWCtxObj."""
712 if ctx.obj is not None:
713 return ctx.obj
714 ctx.obj = MWCtxObj()
715 return ctx.obj
718def yaml_presets(ctx, param, value):
719 """Click callback that reads additional values from the supplied
720 YAML file.
722 Parameters
723 ----------
724 ctx : `click.context`
725 The context for the click operation. Used to extract the subcommand
726 name.
727 param : `str`
728 The parameter name.
729 value : `object`
730 The value of the parameter.
731 """
732 ctx.default_map = ctx.default_map or {}
733 cmd_name = ctx.info_name
734 if value:
735 try:
736 overrides = _read_yaml_presets(value, cmd_name)
737 except Exception as e:
738 raise click.BadOptionUsage(param.name, f"Error reading overrides file: {e}", ctx)
739 # Override the defaults for this subcommand
740 ctx.default_map.update(overrides)
741 return
744def _read_yaml_presets(file_uri, cmd_name):
745 """Read file command line overrides from YAML config file.
747 Parameters
748 ----------
749 file_uri : `str`
750 URI of override YAML file containing the command line overrides.
751 They should be grouped by command name.
752 cmd_name : `str`
753 The subcommand name that is being modified.
755 Returns
756 -------
757 overrides : `dict` of [`str`, Any]
758 The relevant command line options read from the override file.
759 """
760 log.debug("Reading command line overrides for subcommand %s from URI %s", cmd_name, file_uri)
761 config = Config(file_uri)
762 return config[cmd_name]
765def sortAstropyTable(table, dimensions, sort_first=None):
766 """Sort an astropy table, with prioritization given to columns in this
767 order:
768 1. the provided named columns
769 2. spatial and temporal columns
770 3. the rest of the columns
772 The table is sorted in-place, and is also returned for convenience.
774 Parameters
775 ----------
776 table : `astropy.table.Table`
777 The table to sort
778 dimensions : `list` [``Dimension``]
779 The dimensions of the dataIds in the table (the dimensions should be
780 the same for all the dataIds). Used to determine if the column is
781 spatial, temporal, or neither.
782 sort_first : `list` [`str`]
783 The names of columns that should be sorted first, before spatial and
784 temporal columns.
786 Returns
787 -------
788 `astropy.table.Table`
789 For convenience, the table that has been sorted.
790 """
791 # For sorting we want to ignore the id
792 # We also want to move temporal or spatial dimensions earlier
793 sort_first = sort_first or []
794 sort_early = []
795 sort_late = []
796 for dim in dimensions:
797 if dim.spatial or dim.temporal:
798 sort_early.extend(dim.required.names)
799 else:
800 sort_late.append(str(dim))
801 sort_keys = sort_first + sort_early + sort_late
802 # The required names above means that we have the possibility of
803 # repeats of sort keys. Now have to remove them
804 # (order is retained by dict creation).
805 sort_keys = list(dict.fromkeys(sort_keys).keys())
807 table.sort(sort_keys)
808 return table