[med-svn] [Git][med-team/q2cli][upstream] New upstream version 2019.4.0
Liubov Chuprikova
gitlab at salsa.debian.org
Thu Jun 13 18:38:41 BST 2019
Liubov Chuprikova pushed to branch upstream at Debian Med / q2cli
Commits:
5e908400 by Liubov Chuprikova at 2019-06-13T17:16:54Z
New upstream version 2019.4.0
- - - - -
24 changed files:
- .github/ISSUE_TEMPLATE/6-where-to-go.md
- .github/SUPPORT.md
- q2cli/__init__.py
- q2cli/_version.py
- + q2cli/builtin/__init__.py
- q2cli/dev.py → q2cli/builtin/dev.py
- q2cli/info.py → q2cli/builtin/info.py
- q2cli/tools.py → q2cli/builtin/tools.py
- + q2cli/click/__init__.py
- + q2cli/click/command.py
- + q2cli/click/licenses/click.LICENSE.rst
- + q2cli/click/option.py
- + q2cli/click/parser.py
- + q2cli/click/type.py
- q2cli/commands.py
- − q2cli/core.py
- + q2cli/core/__init__.py
- q2cli/cache.py → q2cli/core/cache.py
- q2cli/completion.py → q2cli/core/completion.py
- − q2cli/handlers.py
- q2cli/tests/test_cli.py
- q2cli/tests/test_core.py
- q2cli/tests/test_tools.py
- q2cli/util.py
Changes:
=====================================
.github/ISSUE_TEMPLATE/6-where-to-go.md
=====================================
@@ -59,6 +59,9 @@ Sorted alphabetically by repo name.
- The q2-diversity plugin
https://github.com/qiime2/q2-diversity/issues
+- The q2-diversity-lib plugin
+ https://github.com/qiime2/q2-diversity-lib/issues
+
- The q2-emperor plugin
https://github.com/qiime2/q2-emperor/issues
=====================================
.github/SUPPORT.md
=====================================
@@ -52,6 +52,8 @@ Sorted alphabetically by repo name.
| The q2-demux plugin
- [q2-diversity](https://github.com/qiime2/q2-diversity/issues)
| The q2-diversity plugin
+- [q2-diversity-lib](https://github.com/qiime2/q2-diversity-lib/issues)
+ | The q2-diversity-lib plugin
- [q2-emperor](https://github.com/qiime2/q2-emperor/issues)
| The q2-emperor plugin
- [q2-feature-classifier](https://github.com/qiime2/q2-feature-classifier/issues)
=====================================
q2cli/__init__.py
=====================================
@@ -6,9 +6,7 @@
# The full license is in the file LICENSE, distributed with this software.
# ----------------------------------------------------------------------------
-from .core import Option, option
from ._version import get_versions
-__all__ = ['Option', 'option']
__version__ = get_versions()['version']
del get_versions
=====================================
q2cli/_version.py
=====================================
@@ -23,9 +23,9 @@ def get_keywords():
# setup.py/versioneer.py will grep for the variable names, so they must
# each be defined on a line of their own. _version.py will just call
# get_keywords().
- git_refnames = " (tag: 2019.1.0)"
- git_full = "bd4936307955a839c754dae505722e53328a124a"
- git_date = "2019-01-29 14:00:34 +0000"
+ git_refnames = " (tag: 2019.4.0)"
+ git_full = "dc80fad32777035091692ce1083088380a6ac509"
+ git_date = "2019-05-03 04:14:45 +0000"
keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
return keywords
=====================================
q2cli/builtin/__init__.py
=====================================
@@ -0,0 +1,7 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
=====================================
q2cli/dev.py → q2cli/builtin/dev.py
=====================================
@@ -7,9 +7,11 @@
# ----------------------------------------------------------------------------
import click
+from q2cli.click.command import ToolCommand, ToolGroupCommand
- at click.group(help='Utilities for developers and advanced users.')
+ at click.group(help='Utilities for developers and advanced users.',
+ cls=ToolGroupCommand)
def dev():
pass
@@ -22,7 +24,8 @@ def dev():
"is necessary because package versions do not typically "
"change each time an update is made to a package's code. "
"Setting the environment variable Q2CLIDEV to any value "
- "will always refresh the cache when a command is run.")
+ "will always refresh the cache when a command is run.",
+ cls=ToolCommand)
def refresh_cache():
- import q2cli.cache
- q2cli.cache.CACHE.refresh()
+ import q2cli.core.cache
+ q2cli.core.cache.CACHE.refresh()
=====================================
q2cli/info.py → q2cli/builtin/info.py
=====================================
@@ -8,7 +8,7 @@
import click
-import q2cli
+from q2cli.click.command import ToolCommand
def _echo_version():
@@ -25,9 +25,9 @@ def _echo_version():
def _echo_plugins():
- import q2cli.cache
+ import q2cli.core.cache
- plugins = q2cli.cache.CACHE.plugins
+ plugins = q2cli.core.cache.CACHE.plugins
if plugins:
for name, plugin in sorted(plugins.items()):
click.echo('%s: %s' % (name, plugin['version']))
@@ -36,32 +36,17 @@ def _echo_plugins():
'the official QIIME 2 plugins at https://qiime2.org')
-def _echo_installed_packages():
- import pip
-
- # This code was derived from an example provide here:
- # http://stackoverflow.com/a/23885252/3424666
- installed_packages = sorted(["%s==%s" % (i.key, i.version)
- for i in pip.get_installed_distributions()])
- for e in installed_packages:
- click.echo(e)
-
-
- at click.command(help='Display information about current deployment.')
- at q2cli.option('--py-packages', is_flag=True,
- help='Display names and versions of all installed Python '
- 'packages.')
-def info(py_packages):
+ at click.command(help='Display information about current deployment.',
+ cls=ToolCommand)
+def info():
import q2cli.util
- import q2cli.cache
+ # This import improves performance for repeated _echo_plugins
+ import q2cli.core.cache
click.secho('System versions', fg='green')
_echo_version()
click.secho('\nInstalled plugins', fg='green')
_echo_plugins()
- if py_packages:
- click.secho('\nInstalled Python packages', fg='green')
- _echo_installed_packages()
click.secho('\nApplication config directory', fg='green')
click.secho(q2cli.util.get_app_dir())
=====================================
q2cli/tools.py → q2cli/builtin/tools.py
=====================================
@@ -10,10 +10,15 @@ import os
import click
-import q2cli
+import q2cli.util
+from q2cli.click.command import ToolCommand, ToolGroupCommand
- at click.group(help='Tools for working with QIIME 2 files.')
+_COMBO_METAVAR = 'ARTIFACT/VISUALIZATION'
+
+
+ at click.group(help='Tools for working with QIIME 2 files.',
+ cls=ToolGroupCommand)
def tools():
pass
@@ -23,18 +28,18 @@ def tools():
'or a Visualization',
help='Exporting extracts (and optionally transforms) data '
'stored inside an Artifact or Visualization. Note that '
- 'Visualizations cannot be transformed with --output-format'
- )
- at q2cli.option('--input-path', required=True,
+ 'Visualizations cannot be transformed with --output-format',
+ cls=ToolCommand)
+ at click.option('--input-path', required=True, metavar=_COMBO_METAVAR,
type=click.Path(exists=True, file_okay=True,
dir_okay=False, readable=True),
help='Path to file that should be exported')
- at q2cli.option('--output-path', required=True,
+ at click.option('--output-path', required=True,
type=click.Path(exists=False, file_okay=True, dir_okay=True,
writable=True),
help='Path to file or directory where '
'data should be exported to')
- at q2cli.option('--output-format', required=False,
+ at click.option('--output-format', required=False,
help='Format which the data should be exported as. '
'This option cannot be used with Visualizations')
def export_data(input_path, output_path, output_format):
@@ -112,29 +117,30 @@ def show_importable_formats(ctx, param, value):
help="Import data to create a new QIIME 2 Artifact. See "
"https://docs.qiime2.org/ for usage examples and details "
"on the file types and associated semantic types that can "
- "be imported.")
- at q2cli.option('--type', required=True,
+ "be imported.",
+ cls=ToolCommand)
+ at click.option('--type', required=True,
help='The semantic type of the artifact that will be created '
'upon importing. Use --show-importable-types to see what '
'importable semantic types are available in the current '
'deployment.')
- at q2cli.option('--input-path', required=True,
+ at click.option('--input-path', required=True,
type=click.Path(exists=True, file_okay=True, dir_okay=True,
readable=True),
help='Path to file or directory that should be imported.')
- at q2cli.option('--output-path', required=True,
+ at click.option('--output-path', required=True, metavar='ARTIFACT',
type=click.Path(exists=False, file_okay=True, dir_okay=False,
writable=True),
help='Path where output artifact should be written.')
- at q2cli.option('--input-format', required=False,
+ at click.option('--input-format', required=False,
help='The format of the data to be imported. If not provided, '
'data must be in the format expected by the semantic type '
'provided via --type.')
- at q2cli.option('--show-importable-types', is_flag=True, is_eager=True,
+ at click.option('--show-importable-types', is_flag=True, is_eager=True,
callback=show_importable_types, expose_value=False,
help='Show the semantic types that can be supplied to --type '
'to import data into an artifact.')
- at q2cli.option('--show-importable-formats', is_flag=True, is_eager=True,
+ at click.option('--show-importable-formats', is_flag=True, is_eager=True,
callback=show_importable_formats, expose_value=False,
help='Show formats that can be supplied to --input-format to '
'import data into an artifact.')
@@ -163,9 +169,11 @@ def import_data(type, input_path, output_path, input_format):
@tools.command(short_help='Take a peek at a QIIME 2 Artifact or '
'Visualization.',
help="Display basic information about a QIIME 2 Artifact or "
- "Visualization, including its UUID and type.")
+ "Visualization, including its UUID and type.",
+ cls=ToolCommand)
@click.argument('path', type=click.Path(exists=True, file_okay=True,
- dir_okay=False, readable=True))
+ dir_okay=False, readable=True),
+ metavar=_COMBO_METAVAR)
def peek(path):
import qiime2.sdk
@@ -184,10 +192,11 @@ def peek(path):
short_help='Inspect columns available in metadata.',
help='Inspect metadata files or artifacts viewable as metadata.'
' Providing multiple file paths to this command will merge'
- ' the metadata.')
- at q2cli.option('--tsv/--no-tsv', default=False,
+ ' the metadata.',
+ cls=ToolCommand)
+ at click.option('--tsv/--no-tsv', default=False,
help='Print as machine-readable TSV instead of text.')
- at click.argument('paths', nargs=-1, required=True,
+ at click.argument('paths', nargs=-1, required=True, metavar='METADATA...',
type=click.Path(exists=True, file_okay=True, dir_okay=False,
readable=True))
@q2cli.util.pretty_failure(traceback=None)
@@ -265,11 +274,12 @@ def _load_metadata(path):
@tools.command(short_help='View a QIIME 2 Visualization.',
help="Displays a QIIME 2 Visualization until the command "
"exits. To open a QIIME 2 Visualization so it can be "
- "used after the command exits, use 'qiime extract'.")
- at click.argument('visualization-path',
+ "used after the command exits, use 'qiime extract'.",
+ cls=ToolCommand)
+ at click.argument('visualization-path', metavar='VISUALIZATION',
type=click.Path(exists=True, file_okay=True, dir_okay=False,
readable=True))
- at q2cli.option('--index-extension', required=False, default='html',
+ at click.option('--index-extension', required=False, default='html',
help='The extension of the index file that should be opened. '
'[default: html]')
def view(visualization_path, index_extension):
@@ -337,12 +347,13 @@ def view(visualization_path, index_extension):
"Visualization's archive, including provenance, metadata, "
"and actual data. Use 'qiime tools export' to export only "
"the data stored in an Artifact or Visualization, with "
- "the choice of exporting to different formats.")
- at q2cli.option('--input-path', required=True,
+ "the choice of exporting to different formats.",
+ cls=ToolCommand)
+ at click.option('--input-path', required=True, metavar=_COMBO_METAVAR,
type=click.Path(exists=True, file_okay=True, dir_okay=False,
readable=True),
help='Path to file that should be extracted')
- at q2cli.option('--output-path', required=False,
+ at click.option('--output-path', required=False,
type=click.Path(exists=False, file_okay=False, dir_okay=True,
writable=True),
help='Directory where archive should be extracted to '
@@ -370,10 +381,12 @@ def extract(input_path, output_path):
'and/or more thorough validation of your data (e.g. when '
'debugging issues with your data or analyses).\n\nNote: '
'validation can take some time to complete, depending on '
- 'the size and type of your data.')
+ 'the size and type of your data.',
+ cls=ToolCommand)
@click.argument('path', type=click.Path(exists=True, file_okay=True,
- dir_okay=False, readable=True))
- at q2cli.option('--level', required=False, type=click.Choice(['min', 'max']),
+ dir_okay=False, readable=True),
+ metavar=_COMBO_METAVAR)
+ at click.option('--level', required=False, type=click.Choice(['min', 'max']),
help='Desired level of validation. "min" will perform minimal '
'validation, and "max" will perform maximal validation (at '
'the potential cost of runtime).',
@@ -404,9 +417,11 @@ def validate(path, level):
@tools.command(short_help='Print citations for a QIIME 2 result.',
help='Print citations as a BibTex file (.bib) for a QIIME 2'
- ' result.')
+ ' result.',
+ cls=ToolCommand)
@click.argument('path', type=click.Path(exists=True, file_okay=True,
- dir_okay=False, readable=True))
+ dir_okay=False, readable=True),
+ metavar=_COMBO_METAVAR)
def citations(path):
import qiime2.sdk
import io
=====================================
q2cli/click/__init__.py
=====================================
@@ -0,0 +1,7 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
=====================================
q2cli/click/command.py
=====================================
@@ -0,0 +1,375 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Some of the source code in this file is derived from original work:
+#
+# Copyright (c) 2014 by the Pallets team.
+#
+# To see the license for the original work, see licenses/click.LICENSE.rst
+# Specific reproduction and derivation of original work is marked below.
+# ----------------------------------------------------------------------------
+
+import click
+import click.core
+
+
+class BaseCommandMixin:
+ # Modified from original:
+ # < https://github.com/pallets/click/blob/
+ # c6042bf2607c5be22b1efef2e42a94ffd281434c/click/core.py#L867 >
+ # Copyright (c) 2014 by the Pallets team.
+ def make_parser(self, ctx):
+ """Creates the underlying option parser for this command."""
+ from .parser import Q2Parser
+
+ parser = Q2Parser(ctx)
+ for param in self.get_params(ctx):
+ param.add_to_parser(parser, ctx)
+ return parser
+
+ # Modified from original:
+ # < https://github.com/pallets/click/blob/
+ # c6042bf2607c5be22b1efef2e42a94ffd281434c/click/core.py#L934 >
+ # Copyright (c) 2014 by the Pallets team.
+ def parse_args(self, ctx, args):
+ if isinstance(self, click.MultiCommand):
+ return super().parse_args(ctx, args)
+
+ errors = []
+ parser = self.make_parser(ctx)
+ skip_rest = False
+ for _ in range(10): # surely this is enough attempts
+ try:
+ opts, args, param_order = parser.parse_args(args=args)
+ break
+ except click.ClickException as e:
+ errors.append(e)
+ skip_rest = True
+
+ if not skip_rest:
+ for param in click.core.iter_params_for_processing(
+ param_order, self.get_params(ctx)):
+ try:
+ value, args = param.handle_parse_result(ctx, opts, args)
+ except click.ClickException as e:
+ errors.append(e)
+
+ if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
+ errors.append(click.UsageError(
+ 'Got unexpected extra argument%s (%s)'
+ % (len(args) != 1 and 's' or '',
+ ' '.join(map(click.core.make_str, args)))))
+ if errors:
+ click.echo(ctx.get_help()+"\n", err=True)
+ if len(errors) > 1:
+ problems = 'There were some problems with the command:'
+ else:
+ problems = 'There was a problem with the command:'
+ click.secho(problems.center(78, ' '), fg='yellow', err=True)
+ for idx, e in enumerate(errors, 1):
+ msg = click.formatting.wrap_text(
+ e.format_message(),
+ initial_indent=' (%d/%d%s) ' % (idx, len(errors),
+ '?' if skip_rest else ''),
+ subsequent_indent=' ')
+ click.secho(msg, err=True, fg='red', bold=True)
+ ctx.exit(1)
+
+ ctx.args = args
+ return args
+
+ def get_option_names(self, ctx):
+ if not hasattr(self, '__option_names'):
+ names = set()
+ for param in self.get_params(ctx):
+ if hasattr(param, 'q2_name'):
+ names.add(param.q2_name)
+ else:
+ names.add(param.name)
+ self.__option_names = names
+
+ return self.__option_names
+
+ def list_commands(self, ctx):
+ if not hasattr(super(), 'list_commands'):
+ return []
+ return super().list_commands(ctx)
+
+ def get_opt_groups(self, ctx):
+ return {'Options': list(self.get_params(ctx))}
+
+ def format_help_text(self, ctx, formatter):
+ super().format_help_text(ctx, formatter)
+ formatter.write_paragraph()
+
+ # Modified from original:
+ # < https://github.com/pallets/click/blob
+ # /c6042bf2607c5be22b1efef2e42a94ffd281434c/click/core.py#L830 >
+ # Copyright (c) 2014 by the Pallets team.
+ def format_usage(self, ctx, formatter):
+ """Writes the usage line into the formatter."""
+ pieces = self.collect_usage_pieces(ctx)
+ formatter.write_usage(_style_command(ctx.command_path),
+ ' '.join(pieces))
+
+ def format_options(self, ctx, formatter, COL_MAX=23, COL_MIN=10):
+ # write options
+ opt_groups = {}
+ records = []
+ for group, options in self.get_opt_groups(ctx).items():
+ opt_records = []
+ for o in options:
+ record = o.get_help_record(ctx)
+ if record is None:
+ continue
+ opt_records.append((o, record))
+ records.append(record)
+ opt_groups[group] = opt_records
+ first_columns = (r[0] for r in records)
+ border = min(COL_MAX, max(COL_MIN, *(len(col) for col in first_columns
+ if len(col) < COL_MAX)))
+
+ for opt_group, opt_records in opt_groups.items():
+ if not opt_records:
+ continue
+ formatter.write_heading(click.style(opt_group, bold=True))
+ formatter.indent()
+ padded_border = border + formatter.current_indent
+ for opt, record in opt_records:
+ self.write_option(ctx, formatter, opt, record, padded_border)
+ formatter.dedent()
+
+ # Modified from original:
+ # https://github.com/pallets/click/blob
+ # /c6042bf2607c5be22b1efef2e42a94ffd281434c/click/core.py#L1056
+ # Copyright (c) 2014 by the Pallets team.
+ commands = []
+ for subcommand in self.list_commands(ctx):
+ cmd = self.get_command(ctx, subcommand)
+ # What is this, the tool lied about a command. Ignore it
+ if cmd is None:
+ continue
+ if cmd.hidden:
+ continue
+
+ commands.append((subcommand, cmd))
+
+ # allow for 3 times the default spacing
+ if len(commands):
+ limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)
+
+ rows = []
+ for subcommand, cmd in commands:
+ help = cmd.get_short_help_str(limit)
+ rows.append((_style_command(subcommand), help))
+
+ if rows:
+ with formatter.section(click.style('Commands', bold=True)):
+ formatter.write_dl(rows)
+
+ def write_option(self, ctx, formatter, opt, record, border, COL_SPACING=2):
+ import itertools
+ full_width = formatter.width - formatter.current_indent
+ indent_text = ' ' * formatter.current_indent
+ opt_text, help_text = record
+ opt_text_secondary = None
+ if type(opt_text) is tuple:
+ opt_text, opt_text_secondary = opt_text
+ help_text, requirements = self._clean_help(help_text)
+ type_placement = None
+ type_repr = None
+ type_indent = 2 * indent_text
+
+ if hasattr(opt.type, 'get_type_repr'):
+ type_repr = opt.type.get_type_repr(opt)
+ if type_repr is not None:
+ if len(type_repr) <= border - len(type_indent):
+ type_placement = 'under'
+ else:
+ type_placement = 'beside'
+
+ if len(opt_text) > border:
+ lines = simple_wrap(opt_text, full_width)
+ else:
+ lines = [opt_text.split(' ')]
+ if opt_text_secondary is not None:
+ lines.append(opt_text_secondary.split(' '))
+
+ to_write = []
+ for tokens in lines:
+ dangling_edge = formatter.current_indent
+ styled = []
+ for token in tokens:
+ dangling_edge += len(token) + 1
+ if token.startswith('--'):
+ token = _style_option(token, required=opt.required)
+ styled.append(token)
+ line = indent_text + ' '.join(styled)
+ to_write.append(line)
+ formatter.write('\n'.join(to_write))
+ dangling_edge -= 1
+
+ if type_placement == 'beside':
+ lines = simple_wrap(type_repr, formatter.width - len(type_indent),
+ start_col=dangling_edge - 1)
+ to_write = []
+ first_iter = True
+ for tokens in lines:
+ line = ' '.join(tokens)
+ if first_iter:
+ dangling_edge += 1 + len(line)
+ line = " " + _style_type(line)
+ first_iter = False
+ else:
+ dangling_edge = len(type_indent) + len(line)
+ line = type_indent + _style_type(line)
+ to_write.append(line)
+ formatter.write('\n'.join(to_write))
+
+ if dangling_edge + 1 > border + COL_SPACING:
+ formatter.write('\n')
+ left_col = []
+ else:
+ padding = ' ' * (border + COL_SPACING - dangling_edge)
+ formatter.write(padding)
+ dangling_edge += len(padding)
+ left_col = [''] # jagged start
+
+ if type_placement == 'under':
+ padding = ' ' * (border + COL_SPACING
+ - len(type_repr) - len(type_indent))
+ line = ''.join([type_indent, _style_type(type_repr), padding])
+ left_col.append(line)
+
+ if hasattr(opt, 'meta_help') and opt.meta_help is not None:
+ meta_help = simple_wrap(opt.meta_help,
+ border - len(type_indent) - 1)
+ for idx, line in enumerate([' '.join(t) for t in meta_help]):
+ if idx == 0:
+ line = type_indent + '(' + line
+ else:
+ line = type_indent + ' ' + line
+ if idx == len(meta_help) - 1:
+ line += ')'
+ line += ' ' * (border - len(line) + COL_SPACING)
+ left_col.append(line)
+
+ right_col = simple_wrap(help_text,
+ formatter.width - border - COL_SPACING)
+ right_col = [' '.join(self._color_important(tokens, ctx))
+ for tokens in right_col]
+
+ to_write = []
+ for left, right in itertools.zip_longest(
+ left_col, right_col, fillvalue=' ' * (border + COL_SPACING)):
+ to_write.append(left)
+ if right.strip():
+ to_write[-1] += right
+
+ formatter.write('\n'.join(to_write))
+
+ if requirements is None:
+ formatter.write('\n')
+ else:
+ if to_write:
+ if len(to_write) > 1 or ((not left_col) or left_col[0] != ''):
+ dangling_edge = 0
+ dangling_edge += click.formatting.term_len(to_write[-1])
+ else:
+ pass # dangling_edge is still correct
+
+ if dangling_edge + 1 + len(requirements) > formatter.width:
+ formatter.write('\n')
+ pad = formatter.width - len(requirements)
+ else:
+ pad = formatter.width - len(requirements) - dangling_edge
+
+ formatter.write((' ' * pad) + _style_reqs(requirements) + '\n')
+
+ def _color_important(self, tokens, ctx):
+ import re
+
+ for t in tokens:
+ if '_' in t:
+ names = self.get_option_names(ctx)
+ if re.sub(r'[^\w]', '', t) in names:
+ m = re.search(r'(\w+)', t)
+ word = t[m.start():m.end()]
+ word = _style_emphasis(word.replace('_', '-'))
+ token = t[:m.start()] + word + t[m.end():]
+ yield token
+ continue
+ yield t
+
+ def _clean_help(self, text):
+ reqs = ['[required]', '[optional]', '[default: ']
+ requirement = None
+ for req in reqs:
+ if req in text:
+ requirement = req
+ break
+ else:
+ return text, None
+
+ req_idx = text.index(requirement)
+
+ return text[:req_idx].strip(), text[req_idx:].strip()
+
+
+class ToolCommand(BaseCommandMixin, click.Command):
+ pass
+
+
+class ToolGroupCommand(BaseCommandMixin, click.Group):
+ pass
+
+
+def simple_wrap(text, target, start_col=0):
+ result = [[]]
+ current_line = result[0]
+ current_width = start_col
+ tokens = []
+ for token in text.split(' '):
+ if len(token) <= target:
+ tokens.append(token)
+ else:
+ for i in range(0, len(token), target):
+ tokens.append(token[i:i+target])
+
+ for token in tokens:
+ token_len = len(token)
+ if current_width + 1 + token_len > target:
+ current_line = [token]
+ result.append(current_line)
+ current_width = token_len
+ else:
+ result[-1].append(token)
+ current_width += 1 + token_len
+
+ return result
+
+
+def _style_option(text, required=False):
+ return click.style(text, fg='blue', underline=required)
+
+
+def _style_type(text):
+ return click.style(text, fg='green')
+
+
+def _style_reqs(text):
+ return click.style(text, fg='magenta')
+
+
+def _style_command(text):
+ return _style_option(text)
+
+
+def _style_emphasis(text):
+ return click.style(text, underline=True)
=====================================
q2cli/click/licenses/click.LICENSE.rst
=====================================
@@ -0,0 +1,40 @@
+Copyright © 2014 by the Pallets team.
+
+Some rights reserved.
+
+Redistribution and use in source and binary forms of the software as
+well as documentation, with or without modification, are permitted
+provided that the following conditions are met:
+
+- Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+- Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
+
+----
+
+Click uses parts of optparse written by Gregory P. Ward and maintained
+by the Python Software Foundation. This is limited to code in parser.py.
+
+Copyright © 2001-2006 Gregory P. Ward. All rights reserved.
+Copyright © 2002-2006 Python Software Foundation. All rights reserved.
+
=====================================
q2cli/click/option.py
=====================================
@@ -0,0 +1,214 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
+
+import click
+
+from .type import QIIME2Type
+
+# Sentinel to avoid the situation where `None` *is* the default value.
+NoDefault = {}
+
+
+class GeneratedOption(click.Option):
+ def __init__(self, *, prefix, name, repr, ast, multiple, is_bool_flag,
+ metadata, metavar, default=NoDefault, description=None,
+ **attrs):
+ import q2cli.util
+
+ if metadata is not None:
+ prefix = 'm'
+ if multiple is not None:
+ multiple = list if multiple == 'list' else set
+
+ if is_bool_flag:
+ yes = q2cli.util.to_cli_name(name)
+ no = q2cli.util.to_cli_name('no_' + name)
+ opt = f'--{prefix}-{yes}/--{prefix}-{no}'
+ elif metadata is not None:
+ cli_name = q2cli.util.to_cli_name(name)
+ opt = f'--{prefix}-{cli_name}-file'
+ if metadata == 'column':
+ self.q2_extra_dest, self.q2_extra_opts, _ = \
+ self._parse_decls([f'--{prefix}-{cli_name}-column'], True)
+ else:
+ cli_name = q2cli.util.to_cli_name(name)
+ opt = f'--{prefix}-{cli_name}'
+
+ click_type = QIIME2Type(ast, repr, is_output=prefix == 'o')
+ attrs['metavar'] = metavar
+ attrs['multiple'] = multiple is not None
+ attrs['param_decls'] = [opt]
+ attrs['required'] = default is NoDefault
+ attrs['help'] = self._add_default(description, default)
+ if default is not NoDefault:
+ attrs['default'] = default
+
+ # This is to evade clicks __DEBUG__ check
+ if not is_bool_flag:
+ attrs['type'] = click_type
+ else:
+ attrs['type'] = None
+
+ super().__init__(**attrs)
+ # put things back the way they _should_ be after evading __DEBUG__
+ self.is_bool_flag = is_bool_flag
+ self.type = click_type
+
+ # attrs we will use elsewhere
+ self.q2_multiple = multiple
+ self.q2_prefix = prefix
+ self.q2_name = name
+ self.q2_ast = ast
+ self.q2_metadata = metadata
+
+ @property
+ def meta_help(self):
+ if self.q2_metadata == 'file':
+ return 'multiple arguments will be merged'
+
+ def _add_default(self, desc, default):
+ if desc is not None:
+ desc += ' '
+ else:
+ desc = ''
+ if default is not NoDefault:
+ if default is None:
+ desc += '[optional]'
+ else:
+ desc += '[default: %r]' % (default,)
+ return desc
+
+ def consume_value(self, ctx, opts):
+ if self.q2_metadata == 'column':
+ return self._consume_metadata(ctx, opts)
+ else:
+ return super().consume_value(ctx, opts)
+
+ def _consume_metadata(self, ctx, opts):
+ # double consume
+ md_file = super().consume_value(ctx, opts)
+ # consume uses self.name, so mutate but backup for after
+ backup, self.name = self.name, self.q2_extra_dest
+ md_col = super().consume_value(ctx, opts)
+ self.name = backup
+
+ if (md_col is None) != (md_file is None):
+ # missing one or the other
+ if md_file is None:
+ raise click.MissingParameter(ctx=ctx, param=self)
+ else:
+ raise click.MissingParameter(param_hint=self.q2_extra_opts,
+ ctx=ctx, param=self)
+
+ if md_col is None and md_file is None:
+ return None
+ else:
+ return (md_file, md_col)
+
+ def get_help_record(self, ctx):
+ record = super().get_help_record(ctx)
+ if self.is_bool_flag:
+ metavar = self.make_metavar()
+ if metavar:
+ record = (record[0] + ' ' + self.make_metavar(), record[1])
+ elif self.q2_metadata == 'column':
+ opts = (record[0], self.q2_extra_opts[0] + ' COLUMN ')
+ record = (opts, record[1])
+ return record
+
+ # Override
+ def add_to_parser(self, parser, ctx):
+ shared = dict(dest=self.name, nargs=0, obj=self)
+ if self.q2_metadata == 'column':
+ parser.add_option(self.opts, action='store', dest=self.name,
+ nargs=1, obj=self)
+ parser.add_option(self.q2_extra_opts, action='store',
+ dest=self.q2_extra_dest, nargs=1, obj=self)
+ elif self.is_bool_flag:
+ if self.multiple:
+ action = 'append_maybe'
+ else:
+ action = 'store_maybe'
+ parser.add_option(self.opts, action=action, const=True,
+ **shared)
+ parser.add_option(self.secondary_opts, action=action,
+ const=False, **shared)
+ elif self.multiple:
+ action = 'append_greedy'
+ parser.add_option(self.opts, action='append_greedy', **shared)
+ else:
+ super().add_to_parser(parser, ctx)
+
+ def full_process_value(self, ctx, value):
+ try:
+ return super().full_process_value(ctx, value)
+ except click.MissingParameter:
+ if not (self.q2_prefix == 'o'
+ and ctx.params.get('output_dir', False)):
+ raise
+
+ def type_cast_value(self, ctx, value):
+ import sys
+ import q2cli.util
+ import qiime2.sdk.util
+
+ if self.multiple:
+ if value == () or value is None:
+ return None
+ elif self.q2_prefix == 'i':
+ value = super().type_cast_value(ctx, value)
+ if self.q2_multiple is set:
+ self._check_length(value, ctx)
+ value = self.q2_multiple(value)
+ type_expr = qiime2.sdk.util.type_from_ast(self.q2_ast)
+ args = ', '.join(map(repr, (x.type for x in value)))
+ if value not in type_expr:
+ raise click.BadParameter(
+ 'recieved <%s> as an argument, which is incompatible'
+ ' with parameter type: %r' % (args, type_expr),
+ ctx=ctx, param=self)
+ return value
+ elif self.q2_metadata == 'file':
+ value = super().type_cast_value(ctx, value)
+ if len(value) == 1:
+ return value[0]
+ else:
+ try:
+ return value[0].merge(*value[1:])
+ except Exception as e:
+ header = ("There was an issue with merging "
+ "QIIME 2 Metadata:")
+ tb = 'stderr' if '--verbose' in sys.argv else None
+ q2cli.util.exit_with_error(
+ e, header=header, traceback=tb)
+ elif self.q2_prefix == 'p':
+ try:
+ if self.q2_multiple is set:
+ self._check_length(value, ctx)
+ value = qiime2.sdk.util.parse_primitive(self.q2_ast, value)
+ except ValueError:
+ args = ', '.join(map(repr, value))
+ expr = qiime2.sdk.util.type_from_ast(self.q2_ast)
+ raise click.BadParameter(
+ 'recieved <%s> as an argument, which is incompatible'
+ ' with parameter type: %r' % (args, expr),
+ ctx=ctx, param=self)
+ return value
+
+ return super().type_cast_value(ctx, value)
+
+ def _check_length(self, value, ctx):
+ import collections
+
+ counter = collections.Counter(value)
+ dups = ', '.join(map(repr, (v for v, n in counter.items() if n > 1)))
+ args = ', '.join(map(repr, value))
+ if dups:
+ raise click.BadParameter(
+ 'recieved <%s> as an argument, which contains duplicates'
+ ' of the following: <%s>' % (args, dups), ctx=ctx, param=self)
=====================================
q2cli/click/parser.py
=====================================
@@ -0,0 +1,156 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Some of the source code in this file is derived from original work:
+#
+# Copyright (c) 2014 by the Pallets team.
+#
+# To see the license for the original work, see licenses/click.LICENSE.rst
+# Specific reproduction and derivation of original work is marked below.
+# ----------------------------------------------------------------------------
+
+import click.parser as parser
+import click.exceptions as exceptions
+
+
+class Q2Option(parser.Option):
+ @property
+ def takes_value(self):
+ # store_maybe should take a value so that we hit the right branch
+ # in OptionParser._match_long_opt
+ return (super().takes_value or self.action == 'store_maybe'
+ or self.action == 'append_greedy')
+
+ def _maybe_take(self, state):
+ if not state.rargs:
+ return None
+ # In a more perfect world, we would have access to all long opts
+ # and could verify against those instead of just the prefix '--'
+ if state.rargs[0].startswith('--'):
+ return None
+ return state.rargs.pop(0)
+
+ # Specific technique derived from original:
+ # < https://github.com/pallets/click/blob/
+ # c6042bf2607c5be22b1efef2e42a94ffd281434c/click/core.py#L867 >
+ # Copyright (c) 2014 by the Pallets team.
+ def process(self, value, state):
+ # actions should update state.opts and state.order
+
+ if (self.dest in state.opts
+ and self.action not in ('append', 'append_const',
+ 'append_maybe', 'append_greedy',
+ 'count')):
+ raise exceptions.UsageError(
+ 'Option %r was specified multiple times in the command.'
+ % self._get_opt_name())
+ elif self.action == 'store_maybe':
+ assert value == ()
+ value = self._maybe_take(state)
+ if value is None:
+ state.opts[self.dest] = self.const
+ else:
+ state.opts[self.dest] = value
+ state.order.append(self.obj) # can't forget this
+ elif self.action == 'append_maybe':
+ assert value == ()
+ value = self._maybe_take(state)
+ if value is None:
+ state.opts.setdefault(self.dest, []).append(self.const)
+ else:
+ while value is not None:
+ state.opts.setdefault(self.dest, []).append(value)
+ value = self._maybe_take(state)
+ state.order.append(self.obj) # can't forget this
+ elif self.action == 'append_greedy':
+ assert value == ()
+ value = self._maybe_take(state)
+ while value is not None:
+ state.opts.setdefault(self.dest, []).append(value)
+ value = self._maybe_take(state)
+ state.order.append(self.obj) # can't forget this
+ elif self.takes_value and value.startswith('--'):
+ # Error early instead of cascading the parse error to a "missing"
+ # parameter, which they ironically did provide
+ raise parser.BadOptionUsage(
+ self, '%s option requires an argument' % self._get_opt_name())
+ else:
+ super().process(value, state)
+
+ def _get_opt_name(self):
+ if hasattr(self.obj, 'secondary_opts'):
+ return ' / '.join(self.obj.opts + self.obj.secondary_opts)
+ if hasattr(self.obj, 'get_error_hint'):
+ return self.obj.get_error_hint(None)
+ return ' / '.join(self._long_opts)
+
+
+class Q2Parser(parser.OptionParser):
+ # Modified from original:
+ # < https://github.com/pallets/click/blob/
+ # ic6042bf2607c5be22b1efef2e42a94ffd281434c/click/parser.py#L228 >
+ # Copyright (c) 2014 by the Pallets team.
+ def add_option(self, opts, dest, action=None, nargs=1, const=None,
+ obj=None):
+ """Adds a new option named `dest` to the parser. The destination
+ is not inferred (unlike with optparse) and needs to be explicitly
+ provided. Action can be any of ``store``, ``store_const``,
+ ``append``, ``appnd_const`` or ``count``.
+ The `obj` can be used to identify the option in the order list
+ that is returned from the parser.
+ """
+ if obj is None:
+ obj = dest
+ opts = [parser.normalize_opt(opt, self.ctx) for opt in opts]
+
+ # BEGIN MODIFICATIONS
+ if action == 'store_maybe' or action == 'append_maybe':
+ # Specifically target this branch:
+ # < https://github.com/pallets/click/blob/
+ # c6042bf2607c5be22b1efef2e42a94ffd281434c/click/parser.py#L341 >
+ # this happens to prevents click from reading any arguments itself
+ # because it will only "pop" off rargs[:0], which is nothing
+ nargs = 0
+ if const is None:
+ raise ValueError("A 'const' must be provided when action is "
+ "'store_maybe' or 'append_maybe'")
+ elif action == 'append_greedy':
+ nargs = 0
+
+ option = Q2Option(opts, dest, action=action, nargs=nargs,
+ const=const, obj=obj)
+ # END MODIFICATIONS
+ self._opt_prefixes.update(option.prefixes)
+ for opt in option._short_opts:
+ self._short_opt[opt] = option
+ for opt in option._long_opts:
+ self._long_opt[opt] = option
+
+ def parse_args(self, args):
+ backup = args.copy() # args will be mutated by super()
+ try:
+ return super().parse_args(args)
+ except exceptions.UsageError:
+ if '--help' in backup:
+ # all is forgiven
+ return {'help': True}, [], ['help']
+ raise
+
+ # Override of private member:
+ # < https://github.com/pallets/click/blob/
+ # ic6042bf2607c5be22b1efef2e42a94ffd281434c/click/parser.py#L321 >
+ def _match_long_opt(self, opt, explicit_value, state):
+ if opt not in self._long_opt:
+ from q2cli.util import get_close_matches
+ # This is way better than substring matching
+ possibilities = get_close_matches(opt, self._long_opt)
+ raise exceptions.NoSuchOption(opt, possibilities=possibilities,
+ ctx=self.ctx)
+
+ return super()._match_long_opt(opt, explicit_value, state)
=====================================
q2cli/click/type.py
=====================================
@@ -0,0 +1,183 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
+
+import click
+
+
+def is_writable_dir(path):
+ import os
+
+ head = 'do-while'
+ path = os.path.normpath(os.path.abspath(path))
+ while head:
+ if os.path.exists(path):
+ if os.path.isfile(path):
+ return False
+ else:
+ return os.access(path, os.W_OK | os.X_OK)
+ path, head = os.path.split(path)
+
+ return False
+
+
+class OutDirType(click.Path):
+ def convert(self, value, param, ctx):
+ import os
+ # Click path fails to validate writability on new paths
+
+ if os.path.exists(value):
+ if os.path.isfile(value):
+ self.fail('%r is already a file.' % (value,), param, ctx)
+ else:
+ self.fail('%r already exists, will not overwrite.' % (value,),
+ param, ctx)
+
+ if value[-1] != os.path.sep:
+ value += os.path.sep
+
+ if not is_writable_dir(value):
+ self.fail('%r is not a writable directory, cannot write output'
+ ' to it.' % (value,), param, ctx)
+ return value
+
+
+class QIIME2Type(click.ParamType):
+ def __init__(self, type_ast, type_repr, is_output=False):
+ self.type_repr = type_repr
+ self.type_ast = type_ast
+ self.is_output = is_output
+ self._type_expr = None
+
+ @property
+ def type_expr(self):
+ import qiime2.sdk.util
+
+ if self._type_expr is None:
+ self._type_expr = qiime2.sdk.util.type_from_ast(self.type_ast)
+ return self._type_expr
+
+ def convert(self, value, param, ctx):
+ import qiime2.sdk.util
+
+ if value is None:
+ return None # Them's the rules
+
+ if self.is_output:
+ return self._convert_output(value, param, ctx)
+
+ if qiime2.sdk.util.is_semantic_type(self.type_expr):
+ return self._convert_input(value, param, ctx)
+
+ if qiime2.sdk.util.is_metadata_type(self.type_expr):
+ return self._convert_metadata(value, param, ctx)
+
+ return self._convert_primitive(value, param, ctx)
+
+ def _convert_output(self, value, param, ctx):
+ import os
+ # Click path fails to validate writability on new paths
+
+ if os.path.exists(value):
+ if os.path.isdir(value):
+ self.fail('%r is already a directory.' % (value,), param, ctx)
+
+ directory = os.path.dirname(value)
+ if not is_writable_dir(directory):
+ self.fail('%r is not a writable directory, cannot write output'
+ ' to it.' % (directory,), param, ctx)
+ return value
+
+ def _convert_input(self, value, param, ctx):
+ import os
+ import qiime2.sdk
+ import qiime2.sdk.util
+
+ try:
+ result = qiime2.sdk.Result.load(value)
+ except Exception:
+ self.fail('%r is not a QIIME 2 Artifact (.qza)' % value,
+ param, ctx)
+
+ if isinstance(result, qiime2.sdk.Visualization):
+ maybe = value[:-1] + 'a'
+ hint = ''
+ if os.path.exists(maybe):
+ hint = (' (There is an artifact with the same name:'
+ ' %r, did you mean that?)'
+ % os.path.basename(maybe))
+
+ self.fail('%r is a QIIME 2 visualization (.qzv), not an '
+ ' Artifact (.qza)%s' % (value, hint), param, ctx)
+
+ style = qiime2.sdk.util.interrogate_collection_type(self.type_expr)
+ if style.style is None and result not in self.type_expr:
+ # collections need to be handled above this
+ self.fail("Expected an artifact of at least type %r."
+ " An artifact of type %r was provided."
+ % (self.type_expr, result.type), param, ctx)
+
+ return result
+
+ def _convert_metadata(self, value, param, ctx):
+ import sys
+ import qiime2
+ import q2cli.util
+
+ if self.type_expr.name == 'MetadataColumn':
+ value, column = value
+ fp = value
+
+ try:
+ artifact = qiime2.Artifact.load(fp)
+ except Exception:
+ try:
+ metadata = qiime2.Metadata.load(fp)
+ except Exception as e:
+ header = ("There was an issue with loading the file %s as "
+ "metadata:" % fp)
+ tb = 'stderr' if '--verbose' in sys.argv else None
+ q2cli.util.exit_with_error(e, header=header, traceback=tb)
+ else:
+ try:
+ metadata = artifact.view(qiime2.Metadata)
+ except Exception as e:
+ header = ("There was an issue with viewing the artifact "
+ "%s as QIIME 2 Metadata:" % fp)
+ tb = 'stderr' if '--verbose' in sys.argv else None
+ q2cli.util.exit_with_error(e, header=header, traceback=tb)
+
+ if self.type_expr.name != 'MetadataColumn':
+ return metadata
+ else:
+ try:
+ metadata_column = metadata.get_column(column)
+ except Exception:
+ self.fail("There was an issue with retrieving column %r from "
+ "the metadata:" % column)
+
+ if metadata_column not in self.type_expr:
+ self.fail("Metadata column is of type %r, but expected %r."
+ % (metadata_column.type, self.type_expr.fields[0]))
+
+ return metadata_column
+
+ def _convert_primitive(self, value, param, ctx):
+ import qiime2.sdk.util
+
+ return qiime2.sdk.util.parse_primitive(self.type_expr, value)
+
+ @property
+ def name(self):
+ return self.get_metavar('')
+
+ def get_type_repr(self, param):
+ return self.type_repr
+
+ def get_missing_message(self, param):
+ if self.is_output:
+ return '("--output-dir" may also be used)'
=====================================
q2cli/commands.py
=====================================
@@ -6,22 +6,22 @@
# The full license is in the file LICENSE, distributed with this software.
# ----------------------------------------------------------------------------
-import collections
-
import click
-import q2cli.dev
-import q2cli.info
-import q2cli.tools
+import q2cli.builtin.dev
+import q2cli.builtin.info
+import q2cli.builtin.tools
+
+from q2cli.click.command import BaseCommandMixin
-class RootCommand(click.MultiCommand):
+class RootCommand(BaseCommandMixin, click.MultiCommand):
"""This class defers to either the PluginCommand or the builtin cmds"""
- _builtin_commands = collections.OrderedDict([
- ('info', q2cli.info.info),
- ('tools', q2cli.tools.tools),
- ('dev', q2cli.dev.dev)
- ])
+ _builtin_commands = {
+ 'info': q2cli.builtin.info.info,
+ 'tools': q2cli.builtin.tools.tools,
+ 'dev': q2cli.builtin.dev.dev
+ }
def __init__(self, *args, **kwargs):
import re
@@ -72,8 +72,8 @@ class RootCommand(click.MultiCommand):
# `self._plugins` will not always be obtained from
# `q2cli.cache.CACHE.plugins`.
if self._plugins is None:
- import q2cli.cache
- self._plugins = q2cli.cache.CACHE.plugins
+ import q2cli.core.cache
+ self._plugins = q2cli.core.cache.CACHE.plugins
name_map = {}
for name, plugin in self._plugins.items():
@@ -98,12 +98,25 @@ class RootCommand(click.MultiCommand):
try:
plugin = self._plugin_lookup[name]
except KeyError:
- return None
+ from q2cli.util import get_close_matches
+
+ possibilities = get_close_matches(name, self._plugin_lookup)
+ if len(possibilities) == 1:
+ hint = ' Did you mean %r?' % possibilities[0]
+ elif possibilities:
+ hint = ' (Possible commands: %s)' % ', '.join(possibilities)
+ else:
+ hint = ''
+
+ click.secho("Error: QIIME 2 has no plugin/command named %r."
+ % name + hint,
+ err=True, fg='red')
+ ctx.exit(2) # Match exit code of `return None`
return PluginCommand(plugin, name)
-class PluginCommand(click.MultiCommand):
+class PluginCommand(BaseCommandMixin, click.MultiCommand):
"""Provides ActionCommands based on available Actions"""
def __init__(self, plugin, name, *args, **kwargs):
import q2cli.util
@@ -133,8 +146,21 @@ class PluginCommand(click.MultiCommand):
if not value or ctx.resilient_parsing:
return
- click.echo('%s version %s' % (self._plugin['name'],
- self._plugin['version']))
+ import qiime2.sdk
+ for entrypoint in qiime2.sdk.PluginManager.iter_entry_points():
+ plugin = entrypoint.load()
+ if (self._plugin['name'] == plugin.name):
+ pkg_name = entrypoint.dist.project_name
+ pkg_version = entrypoint.dist.version
+ break
+ else:
+ pkg_name = pkg_version = "[UNKNOWN]"
+
+ click.echo(
+ "QIIME 2 Plugin '%s' version %s (from package '%s' version %s)"
+ % (self._plugin['name'], self._plugin['version'],
+ pkg_name, pkg_version)
+ )
ctx.exit()
def _get_citation_records(self):
@@ -149,82 +175,84 @@ class PluginCommand(click.MultiCommand):
try:
action = self._action_lookup[name]
except KeyError:
- click.echo("Error: QIIME 2 plugin %r has no action %r."
- % (self._plugin['name'], name), err=True)
+ from q2cli.util import get_close_matches
+
+ possibilities = get_close_matches(name, self._action_lookup)
+ if len(possibilities) == 1:
+ hint = ' Did you mean %r?' % possibilities[0]
+ elif possibilities:
+ hint = ' (Possible commands: %s)' % ', '.join(possibilities)
+ else:
+ hint = ''
+
+ click.secho("Error: QIIME 2 plugin %r has no action %r."
+ % (self._plugin['name'], name) + hint,
+ err=True, fg='red')
ctx.exit(2) # Match exit code of `return None`
return ActionCommand(name, self._plugin, action)
-class ActionCommand(click.Command):
+class ActionCommand(BaseCommandMixin, click.Command):
"""A click manifestation of a QIIME 2 API Action (Method/Visualizer)
- The ActionCommand generates Handlers which map from 1 Action API parameter
- to one or more Click.Options.
-
- MetaHandlers are handlers which are not mapped to an API parameter, they
- are handled explicitly and generally return a `fallback` function which
- can be used to supplement value lookup in the regular handlers.
"""
def __init__(self, name, plugin, action):
- import q2cli.handlers
import q2cli.util
+ import q2cli.click.type
self.plugin = plugin
self.action = action
- self.generated_handlers = self.build_generated_handlers()
- self.verbose_handler = q2cli.handlers.VerboseHandler()
- self.quiet_handler = q2cli.handlers.QuietHandler()
- # Meta-Handlers:
- self.output_dir_handler = q2cli.handlers.OutputDirHandler()
- self.cmd_config_handler = q2cli.handlers.CommandConfigHandler(
- q2cli.util.to_cli_name(plugin['name']),
- q2cli.util.to_cli_name(self.action['id'])
- )
- super().__init__(name, params=list(self.get_click_parameters()),
- callback=self, short_help=action['name'],
- help=action['description'])
- def build_generated_handlers(self):
- import q2cli.handlers
+ self._inputs, self._params, self._outputs = \
+ self._build_generated_options()
+
+ self._misc = [
+ click.Option(['--output-dir'],
+ type=q2cli.click.type.OutDirType(),
+ help='Output unspecified results to a directory'),
+ click.Option(['--verbose / --quiet'], default=None, required=False,
+ help='Display verbose output to stdout and/or stderr '
+ 'during execution of this action. Or silence '
+ 'output if execution is successful (silence is '
+ 'golden).'),
+ q2cli.util.citations_option(self._get_citation_records)
+ ]
- handler_map = {
- 'input': q2cli.handlers.ArtifactHandler,
- 'parameter': q2cli.handlers.parameter_handler_factory,
- 'output': q2cli.handlers.ResultHandler
- }
+ options = [*self._inputs, *self._params, *self._outputs, *self._misc]
+ super().__init__(name, params=options, callback=self,
+ short_help=action['name'], help=action['description'])
+
+ def _build_generated_options(self):
+ import q2cli.click.option
+
+ inputs = []
+ params = []
+ outputs = []
- handlers = collections.OrderedDict()
for item in self.action['signature']:
item = item.copy()
type = item.pop('type')
- if item['ast']['type'] == 'collection':
- inner_handler = handler_map[type](**item)
- handler = q2cli.handlers.CollectionHandler(inner_handler,
- **item)
+ if type == 'input':
+ storage = inputs
+ elif type == 'parameter':
+ storage = params
else:
- handler = handler_map[type](**item)
-
- handlers[item['name']] = handler
-
- return handlers
-
- def get_click_parameters(self):
- import q2cli.util
-
- # Handlers may provide more than one click.Option
- for handler in self.generated_handlers.values():
- yield from handler.get_click_options()
+ storage = outputs
- # Meta-Handlers' Options:
- yield from self.output_dir_handler.get_click_options()
- yield from self.cmd_config_handler.get_click_options()
+ opt = q2cli.click.option.GeneratedOption(prefix=type[0], **item)
+ storage.append(opt)
- yield from self.verbose_handler.get_click_options()
- yield from self.quiet_handler.get_click_options()
+ return inputs, params, outputs
- yield q2cli.util.citations_option(self._get_citation_records)
+ def get_opt_groups(self, ctx):
+ return {
+ 'Inputs': self._inputs,
+ 'Parameters': self._params,
+ 'Outputs': self._outputs,
+ 'Miscellaneous': self._misc + [self.get_help_option(ctx)]
+ }
def _get_citation_records(self):
return self._get_action().citations
@@ -237,26 +265,35 @@ class ActionCommand(click.Command):
def __call__(self, **kwargs):
"""Called when user hits return, **kwargs are Dict[click_names, Obj]"""
- import itertools
import os
import qiime2.util
- arguments, missing_in, verbose, quiet = self.handle_in_params(kwargs)
- outputs, missing_out = self.handle_out_params(kwargs)
-
- if missing_in or missing_out:
- # A new context is generated for a callback, which will result in
- # the ctx.command_path duplicating the action, so just use the
- # parent so we can print the help *within* a callback.
- ctx = click.get_current_context().parent
- click.echo(ctx.get_help()+"\n", err=True)
- for option in itertools.chain(missing_in, missing_out):
- click.secho("Error: Missing option: --%s" % option, err=True,
- fg='red', bold=True)
- if missing_out:
- click.echo(_OUTPUT_OPTION_ERR_MSG, err=True)
- ctx.exit(1)
+ output_dir = kwargs.pop('output_dir')
+ verbose = kwargs.pop('verbose')
+ if verbose is None:
+ verbose = False
+ quiet = False
+ elif verbose:
+ quiet = False
+ else:
+ quiet = True
+
+ arguments = {}
+ init_outputs = {}
+ for key, value in kwargs.items():
+ prefix, *parts = key.split('_')
+ key = '_'.join(parts)
+
+ if prefix == 'o':
+ if value is None:
+ value = os.path.join(output_dir, key)
+ init_outputs[key] = value
+ elif prefix == 'm':
+ arguments[key[:-len('_file')]] = value
+ else:
+ arguments[key] = value
+ outputs = self._order_outputs(init_outputs)
action = self._get_action()
# `qiime2.util.redirected_stdio` defaults to stdout/stderr when
# supplied `None`.
@@ -290,77 +327,18 @@ class ActionCommand(click.Command):
log.close()
os.remove(log.name)
+ if output_dir is not None:
+ os.makedirs(output_dir)
+
for result, output in zip(results, outputs):
path = result.save(output)
if not quiet:
click.secho('Saved %s to: %s' % (result.type, path),
fg='green')
- def handle_in_params(self, kwargs):
- import q2cli.handlers
-
- arguments = {}
- missing = []
- cmd_fallback = self.cmd_config_handler.get_value(kwargs)
-
- verbose = self.verbose_handler.get_value(kwargs, fallback=cmd_fallback)
- quiet = self.quiet_handler.get_value(kwargs, fallback=cmd_fallback)
-
- if verbose and quiet:
- click.secho('Unsure of how to be quiet and verbose at the '
- 'same time.', fg='red', bold=True, err=True)
- click.get_current_context().exit(1)
-
- for item in self.action['signature']:
- if item['type'] == 'input' or item['type'] == 'parameter':
- name = item['name']
- handler = self.generated_handlers[name]
- try:
- if isinstance(handler,
- (q2cli.handlers.MetadataHandler,
- q2cli.handlers.MetadataColumnHandler)):
- arguments[name] = handler.get_value(
- verbose, kwargs, fallback=cmd_fallback)
- else:
- arguments[name] = handler.get_value(
- kwargs, fallback=cmd_fallback)
- except q2cli.handlers.ValueNotFoundException:
- missing += handler.missing
-
- return arguments, missing, verbose, quiet
-
- def handle_out_params(self, kwargs):
- import q2cli.handlers
-
- outputs = []
- missing = []
- cmd_fallback = self.cmd_config_handler.get_value(kwargs)
- out_fallback = self.output_dir_handler.get_value(
- kwargs, fallback=cmd_fallback
- )
-
- def fallback(*args):
- try:
- return cmd_fallback(*args)
- except q2cli.handlers.ValueNotFoundException:
- return out_fallback(*args)
-
+ def _order_outputs(self, outputs):
+ ordered = []
for item in self.action['signature']:
if item['type'] == 'output':
- name = item['name']
- handler = self.generated_handlers[name]
-
- try:
- outputs.append(handler.get_value(kwargs,
- fallback=fallback))
- except q2cli.handlers.ValueNotFoundException:
- missing += handler.missing
-
- return outputs, missing
-
-
-_OUTPUT_OPTION_ERR_MSG = """\
-Note: When only providing names for a subset of the output Artifacts or
-Visualizations, you must specify an output directory through use of the
---output-dir DIRECTORY flag.\
-"""
+ ordered.append(outputs[item['name']])
+ return ordered
=====================================
q2cli/core.py deleted
=====================================
@@ -1,145 +0,0 @@
-# ----------------------------------------------------------------------------
-# Copyright (c) 2016-2019, QIIME 2 development team.
-#
-# Distributed under the terms of the Modified BSD License.
-#
-# The full license is in the file LICENSE, distributed with this software.
-# ----------------------------------------------------------------------------
-
-import click
-
-import q2cli.util
-
-
-class Option(click.Option):
- """``click.Option`` with customized behavior for q2cli.
-
- Note to q2cli developers: you'll generally want to use this class and its
- corresponding decorator (``@q2cli.option``) over ``click.Option`` and
- ``@click.option`` to keep a consistent CLI behavior across commands. This
- class and decorator are designed to be drop-in replacements for their Click
- counterparts.
-
- """
- def __init__(self, param_decls=None, **attrs):
- if 'multiple' not in attrs and 'count' not in attrs:
- self._disallow_repeated_options(attrs)
- super().__init__(param_decls=param_decls, **attrs)
-
- def _disallow_repeated_options(self, attrs):
- """Prevent option from being repeated on the command line.
-
- Click allows options to be repeated on the command line and stores the
- value of the last specified option (this is to support overriding
- options set in shell aliases). While this is common behavior in CLI
- tools, it is prevented in q2cli to avoid confusion with options that
- are intended to be supplied multiple times (``multiple=True``; Click
- calls these "multiple options"). QIIME 2 metadata is an example of a
- "multiple option" in q2cli.
-
- References
- ----------
- .. [1] http://click.pocoo.org/6/options/#multiple-options
-
- """
- # General strategy:
- #
- # Make this option a "multiple option" (``multiple=True``) and use a
- # callback to unpack the stored values and assert that only a single
- # value was supplied.
-
- # Use the user-supplied callback or define a passthrough callback if
- # one wasn't supplied.
- if 'callback' in attrs:
- callback = attrs['callback']
- else:
- def callback(ctx, param, value):
- return value
-
- # Wrap the callback to intercept stored values so that they can be
- # unpacked and validated.
- def callback_wrapper(ctx, param, value):
- # When `multiple=True` Click will use an empty tuple to represent
- # the absence of the option instead of `None`.
- if value == ():
- value = None
- if not value or ctx.resilient_parsing:
- return callback(ctx, param, value)
-
- # Empty/null case is handled above, so attempt to unpack the value.
- try:
- value, = value
- except ValueError:
- click.echo(ctx.get_usage() + '\n', err=True)
- click.secho(
- "Error: Option --%s was specified multiple times in the "
- "command." % q2cli.util.to_cli_name(param.name),
- err=True, fg='red', bold=True)
- ctx.exit(1)
-
- return callback(ctx, param, value)
-
- # Promote this option to a "multiple option" and use the callback
- # wrapper to make it behave like a regular "single" option.
- attrs['callback'] = callback_wrapper
- attrs['multiple'] = True
-
- # If the user set a default, promote it to a "multiple option" default
- # by putting it in a list. A default of `None` is a special case that
- # can't be promoted.
- if 'default' in attrs and attrs['default'] is not None:
- attrs['default'] = [attrs['default']]
-
-
-# Modeled after `click.option` decorator.
-def option(*param_decls, **attrs):
- """``@click.option`` decorator with customized behavior for q2cli.
-
- See docstring on ``q2cli.Option`` (above) for details.
-
- """
- if 'cls' in attrs:
- raise ValueError("Cannot override `cls=q2cli.Option` in `attrs`.")
- attrs['cls'] = Option
-
- def decorator(f):
- return click.option(*param_decls, **attrs)(f)
-
- return decorator
-
-
-class MultipleType(click.ParamType):
- """This is just a wrapper, it doesn't do anything on its own"""
- def __init__(self, param_type):
- self.param_type = param_type
-
- @property
- def name(self):
- return "MULTIPLE " + self.param_type.name
-
- def convert(self, value, param, ctx):
- # Don't convert anything yet.
- return value
-
- def fail(self, *args, **kwargs):
- return self.param_type.fail(*args, **kwargs)
-
- def get_missing_message(self, *args, **kwargs):
- return self.param_type.get_missing_message(*args, **kwargs)
-
- def get_metavar(self, *args, **kwargs):
- metavar = self.param_type.get_metavar(*args, **kwargs)
- if metavar is None:
- return None
- return "MULTIPLE " + metavar
-
-
-class ResultPath(click.Path):
- def __init__(self, repr, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.repr = repr
-
- def get_metavar(self, param):
- if self.repr != 'Visualization':
- return "ARTIFACT PATH " + self.repr
- return "VISUALIZATION PATH"
=====================================
q2cli/core/__init__.py
=====================================
@@ -0,0 +1,7 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2016-2019, QIIME 2 development team.
+#
+# Distributed under the terms of the Modified BSD License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# ----------------------------------------------------------------------------
=====================================
q2cli/cache.py → q2cli/core/cache.py
=====================================
@@ -188,7 +188,7 @@ class DeploymentCache:
import json
import os.path
import click
- import q2cli.completion
+ import q2cli.core.completion
import q2cli.util
click.secho(
@@ -203,7 +203,7 @@ class DeploymentCache:
with open(path, 'w') as fh:
json.dump(state, fh)
- q2cli.completion.write_bash_completion_script(
+ q2cli.core.completion.write_bash_completion_script(
state['plugins'], q2cli.util.get_completion_path())
# Write requirements file last because the above steps may raise errors
@@ -276,7 +276,7 @@ class DeploymentCache:
sig = action.signature
for name, spec in itertools.chain(sig.signature_order.items(),
sig.outputs.items()):
- data = {'name': name, 'repr': repr(spec.qiime_type),
+ data = {'name': name, 'repr': self._get_type_repr(spec.qiime_type),
'ast': spec.qiime_type.to_ast()}
if name in sig.inputs:
@@ -292,10 +292,109 @@ class DeploymentCache:
if spec.has_default():
data['default'] = spec.default
+ data['metavar'] = self._get_metavar(spec.qiime_type)
+ data['multiple'], data['is_bool_flag'], data['metadata'] = \
+ self._special_option_flags(spec.qiime_type)
+
state['signature'].append(data)
return state
+ def _special_option_flags(self, type):
+ import qiime2.sdk.util
+ import itertools
+
+ multiple = None
+ is_bool_flag = False
+ metadata = None
+
+ style = qiime2.sdk.util.interrogate_collection_type(type)
+
+ if style.style is not None:
+ multiple = style.view.__name__
+ if style.style == 'simple':
+ names = {style.members.name, }
+ elif style.style == 'complex':
+ names = {m.name for m in
+ itertools.chain.from_iterable(style.members)}
+ else: # composite or monomorphic
+ names = {v.name for v in style.members}
+
+ if 'Bool' in names:
+ is_bool_flag = True
+ else: # not collection
+ expr = style.expr
+
+ if expr.name == 'Metadata':
+ multiple = 'list'
+ metadata = 'file'
+ elif expr.name == 'MetadataColumn':
+ metadata = 'column'
+ elif expr.name == 'Bool':
+ is_bool_flag = True
+
+ return multiple, is_bool_flag, metadata
+
+ def _get_type_repr(self, type):
+ import qiime2.sdk.util
+
+ type_repr = repr(type)
+ style = qiime2.sdk.util.interrogate_collection_type(type)
+
+ if not qiime2.sdk.util.is_semantic_type(type):
+ if style.style is None:
+ if style.expr.predicate is not None:
+ type_repr = repr(style.expr.predicate)
+ elif not type.fields:
+ type_repr = None
+ elif style.style == 'simple':
+ if style.members.predicate is not None:
+ type_repr = repr(style.members.predicate)
+
+ return type_repr
+
+ def _get_metavar(self, type):
+ import qiime2.sdk.util
+
+ name_to_var = {
+ 'Visualization': 'VISUALIZATION',
+ 'Int': 'INTEGER',
+ 'Str': 'TEXT',
+ 'Float': 'NUMBER',
+ 'Bool': '',
+ }
+
+ style = qiime2.sdk.util.interrogate_collection_type(type)
+
+ multiple = style.style is not None
+ if style.style == 'simple':
+ inner_type = style.members
+ elif not multiple:
+ inner_type = type
+ else:
+ inner_type = None
+
+ if qiime2.sdk.util.is_semantic_type(type):
+ metavar = 'ARTIFACT'
+ elif qiime2.sdk.util.is_metadata_type(type):
+ metavar = 'METADATA'
+ elif style.style is not None and style.style != 'simple':
+ metavar = 'VALUE'
+ else:
+ metavar = name_to_var[inner_type.name]
+ if (metavar == 'NUMBER' and inner_type is not None
+ and inner_type.predicate is not None
+ and inner_type.predicate.template.start == 0
+ and inner_type.predicate.template.end == 1):
+ metavar = 'PROPORTION'
+
+ if multiple or type.name == 'Metadata':
+ if metavar != 'TEXT' and metavar != '' and metavar != 'METADATA':
+ metavar += 'S'
+ metavar += '...'
+
+ return metavar
+
# Singleton. Import and use this instance as necessary.
CACHE = DeploymentCache()
=====================================
q2cli/completion.py → q2cli/core/completion.py
=====================================
@@ -73,6 +73,8 @@ def _generate_command_reply(cmd):
if isinstance(param, click.Option):
options.extend(param.opts)
options.extend(param.secondary_opts)
+ if hasattr(param, 'q2_extra_opts'):
+ options.extend(param.q2_extra_opts)
subcmd_names = []
if isinstance(cmd, click.MultiCommand):
=====================================
q2cli/handlers.py deleted
=====================================
@@ -1,656 +0,0 @@
-# ----------------------------------------------------------------------------
-# Copyright (c) 2016-2019, QIIME 2 development team.
-#
-# Distributed under the terms of the Modified BSD License.
-#
-# The full license is in the file LICENSE, distributed with this software.
-# ----------------------------------------------------------------------------
-
-import collections
-
-# Sentinel to avoid the situation where `None` *is* the default value.
-NoDefault = collections.namedtuple('NoDefault', [])()
-
-
-class ValueNotFoundException(Exception):
- """Raised when a value cannot be found. Used for control-flow only."""
-
-
-class Handler:
- def __init__(self, name, prefix='', default=NoDefault, description=None):
- # e.g. my_option_name
- self.name = name
- # e.g. p_my_option_name
- self.click_name = prefix + name
- self.default = default
- self.description = description
- self.missing = []
-
- @property
- def cli_name(self):
- import q2cli.util
-
- # e.g. p-my-option-name
- return q2cli.util.to_cli_name(self.click_name)
-
- def get_click_options(self):
- """Should yield 1 or more click.Options"""
- raise NotImplementedError()
-
- def get_value(self, arguments, fallback=None):
- """Should find 1 or more arguments and convert to a single API value"""
- raise NotImplementedError()
-
- def _locate_value(self, arguments, fallback, multiple=False):
- """Default lookup procedure to find a click.Option provided by user"""
- # TODO revisit this interaction between _locate_value, single vs.
- # multiple options, and fallbacks. Perhaps handlers should always
- # use tuples to store values, even for single options, in order to
- # normalize single-vs-multiple option handling. Probably not worth
- # revisiting until there are more unit + integration tests of q2cli
- # since there's the potential to break things.
-
- # Is it in args?
- v = arguments[self.click_name]
- missing_value = () if multiple else None
- if v != missing_value:
- return v
-
- # Does our fallback know about it?
- if fallback is not None:
- try:
- fallback_value = fallback(self.name, self.cli_name)
- except ValueNotFoundException:
- pass
- else:
- # TODO fallbacks don't know whether they're handling a single
- # vs. multiple option, so the current expectation is that
- # fallbacks will always return a single value. Revisit this
- # expectation in the future; perhaps fallbacks should be aware
- # of single-vs-multiple options, or perhaps they could always
- # return a tuple.
- if multiple:
- fallback_value = (fallback_value,)
- return fallback_value
-
- # Do we have a default?
- if self.default is not NoDefault:
- return self.default
-
- # Give up
- self.missing.append(self.cli_name)
- raise ValueNotFoundException()
-
- def _parse_boolean(self, string):
- """Parse string representing a boolean into Python bool type.
-
- Supported values match `configparser.ConfigParser.getboolean`.
-
- """
- trues = ['1', 'yes', 'true', 'on']
- falses = ['0', 'no', 'false', 'off']
-
- string_lower = string.lower()
- if string_lower in trues:
- return True
- elif string_lower in falses:
- return False
- else:
- import itertools
- import click
-
- msg = (
- "Error: unrecognized value for --%s flag: %s\n"
- "Supported values (case-insensitive): %s" %
- (self.cli_name, string,
- ', '.join(itertools.chain(trues, falses)))
- )
- click.secho(msg, err=True, fg='red', bold=True)
- ctx = click.get_current_context()
- ctx.exit(1)
-
- def _add_description(self, option, requirement):
- def pretty_cat(a, b, space=1):
- if a:
- return a + (' ' * space) + b
- return b
-
- if self.description:
- option.help = pretty_cat(option.help, self.description)
- option.help = pretty_cat(option.help, requirement, space=2)
-
- return option
-
-
-class VerboseHandler(Handler):
- """Handler for verbose output (--verbose flag)."""
-
- def __init__(self):
- super().__init__('verbose', default=False)
-
- def get_click_options(self):
- import q2cli
-
- # `is_flag` will set the default to `False`, but `self._locate_value`
- # needs to distinguish between the presence or absence of the flag
- # provided by the user.
- yield q2cli.Option(
- ['--' + self.cli_name], is_flag=True, default=None,
- help='Display verbose output to stdout and/or stderr during '
- 'execution of this action. [default: %s]' % self.default)
-
- def get_value(self, arguments, fallback=None):
- value = self._locate_value(arguments, fallback)
- # Value may have been specified in --cmd-config (or another source in
- # the future). If we don't have a bool type yet, attempt to interpret a
- # string representing a boolean.
- if type(value) is not bool:
- value = self._parse_boolean(value)
- return value
-
-
-class QuietHandler(Handler):
- """Handler for quiet output (--quiet flag)."""
-
- def __init__(self):
- super().__init__('quiet', default=False)
-
- def get_click_options(self):
- import q2cli
-
- # `is_flag` will set the default to `False`, but `self._locate_value`
- # needs to distinguish between the presence or absence of the flag
- # provided by the user.
- yield q2cli.Option(
- ['--' + self.cli_name], is_flag=True, default=None,
- help='Silence output if execution is successful '
- '(silence is golden). [default: %s]' % self.default)
-
- def get_value(self, arguments, fallback=None):
- value = self._locate_value(arguments, fallback)
- # Value may have been specified in --cmd-config (or another source in
- # the future). If we don't have a bool type yet, attempt to interpret a
- # string representing a boolean.
- if type(value) is not bool:
- value = self._parse_boolean(value)
- return value
-
-
-class OutputDirHandler(Handler):
- """Meta handler which returns a fallback function as its value."""
-
- def __init__(self):
- super().__init__('output_dir')
-
- def get_click_options(self):
- import click
- import q2cli
-
- yield q2cli.Option(
- ['--' + self.cli_name],
- type=click.Path(exists=False, dir_okay=True, file_okay=False,
- writable=True),
- help='Output unspecified results to a directory')
-
- def get_value(self, arguments, fallback=None):
- import os
- import os.path
- import click
-
- try:
- path = self._locate_value(arguments, fallback=fallback)
-
- # TODO: do we want a --force like flag?
- if os.path.exists(path):
- click.secho("Error: --%s directory already exists, won't "
- "overwrite." % self.cli_name, err=True, fg='red',
- bold=True)
- ctx = click.get_current_context()
- ctx.exit(1)
-
- os.makedirs(path)
-
- def fallback_(name, cli_name):
- return os.path.join(path, name)
- return fallback_
-
- except ValueNotFoundException:
- # Always fail to find a value as this handler doesn't exist.
- def fail(*_):
- raise ValueNotFoundException()
-
- return fail
-
-
-class CommandConfigHandler(Handler):
- """Meta handler which returns a fallback function as its value."""
-
- def __init__(self, cli_plugin, cli_action):
- self.cli_plugin = cli_plugin
- self.cli_action = cli_action
- super().__init__('cmd_config')
-
- def get_click_options(self):
- import click
- import q2cli
-
- yield q2cli.Option(
- ['--' + self.cli_name],
- type=click.Path(exists=True, dir_okay=False, file_okay=True,
- readable=True),
- help='Use config file for command options')
-
- def get_value(self, arguments, fallback=None):
- import configparser
- import warnings
-
- try:
- path = self._locate_value(arguments, fallback=fallback)
- config = configparser.ConfigParser()
- config.read(path)
- try:
- config_section = config['.'.join([
- self.cli_plugin, self.cli_action
- ])]
- except KeyError:
- warnings.warn("Config file does not contain a section"
- " for %s"
- % '.'.join([self.cli_plugin, self.cli_action]),
- UserWarning)
- raise ValueNotFoundException()
-
- def fallback_(name, cli_name):
- try:
- return config_section[cli_name]
- except KeyError:
- raise ValueNotFoundException()
- return fallback_
-
- except ValueNotFoundException:
- # Always fail to find a value as this handler doesn't exist.
- def fail(*_):
- raise ValueNotFoundException()
-
- return fail
-
-
-class GeneratedHandler(Handler):
- def __init__(self, name, repr, ast, default=NoDefault, description=None):
- super().__init__(name, prefix=self.prefix, default=default,
- description=description)
- self.repr = repr
- self.ast = ast
-
-
-class CollectionHandler(GeneratedHandler):
- view_map = {
- 'List': list,
- 'Set': set
- }
-
- def __init__(self, inner_handler, **kwargs):
- self.inner_handler = inner_handler
- # inner_handler needs to be set first so the prefix lookup works
- super().__init__(**kwargs)
- self.view_type = self.view_map[self.ast['name']]
-
- @property
- def prefix(self):
- return self.inner_handler.prefix
-
- def get_click_options(self):
- import q2cli.core
- for option in self.inner_handler.get_click_options():
- option.multiple = True
- # validation happens on a callback for q2cli.core.Option, so unset
- # it because we need standard click behavior for multi-options
- # without this, the result of not-passing a value is `None` instead
- # of `()` which confuses ._locate_value
- option.callback = None
- option.type = q2cli.core.MultipleType(option.type)
- yield option
-
- def get_value(self, arguments, fallback=None):
- args = self._locate_value(arguments, fallback, multiple=True)
- if args is None:
- return None
-
- decoded_values = []
- for arg in args:
- # Use an empty dict because we don't need the inner handler to
- # look for anything; that's our job. We just need it to decode
- # whatever it was we found.
- empty = collections.defaultdict(lambda: None)
- decoded = self.inner_handler.get_value(empty,
- fallback=lambda *_: arg)
- decoded_values.append(decoded)
-
- value = self.view_type(decoded_values)
-
- if len(value) != len(decoded_values):
- self._error_with_duplicate_in_set(decoded_values)
-
- return value
-
- def _error_with_duplicate_in_set(self, elements):
- import click
- import collections
-
- counter = collections.Counter(elements)
- dups = {name for name, count in counter.items() if count > 1}
-
- ctx = click.get_current_context()
- click.echo(ctx.get_usage() + '\n', err=True)
- click.secho("Error: Option --%s was given these values: %r more than "
- "one time, values passed should be unique."
- % (self.cli_name, dups), err=True, fg='red', bold=True)
- ctx.exit(1)
-
-
-class ArtifactHandler(GeneratedHandler):
- prefix = 'i_'
-
- def get_click_options(self):
- import q2cli
- import q2cli.core
-
- type = q2cli.core.ResultPath(repr=self.repr, exists=True,
- file_okay=True, dir_okay=False,
- readable=True)
- if self.default is None:
- requirement = '[optional]'
- else:
- requirement = '[required]'
-
- option = q2cli.Option(['--' + self.cli_name], type=type, help="")
- yield self._add_description(option, requirement)
-
- def get_value(self, arguments, fallback=None):
- import qiime2.sdk
-
- path = self._locate_value(arguments, fallback)
- if path is None:
- return None
- else:
- artifact = qiime2.sdk.Result.load(path)
- if isinstance(artifact, qiime2.sdk.Visualization):
- import click
- ctx = click.get_current_context()
- click.echo(ctx.get_usage() + '\n', err=True)
- click.secho("Error: Option --%s was given a visualization "
- "(.qzv), expected an artifact (.qza)."
- % self.cli_name, err=True, fg='red', bold=True)
- ctx.exit(1)
- else:
- return artifact
-
-
-class ResultHandler(GeneratedHandler):
- prefix = 'o_'
-
- def get_click_options(self):
- import q2cli
-
- type = q2cli.core.ResultPath(self.repr, exists=False, file_okay=True,
- dir_okay=False, writable=True)
- option = q2cli.Option(['--' + self.cli_name], type=type, help="")
- yield self._add_description(
- option, '[required if not passing --output-dir]')
-
- def get_value(self, arguments, fallback=None):
- return self._locate_value(arguments, fallback)
-
-
-def parameter_handler_factory(name, repr, ast, default=NoDefault,
- description=None):
- if ast['name'] == 'Metadata':
- return MetadataHandler(name, default=default, description=description)
- elif ast['name'] == 'MetadataColumn':
- if repr == 'MetadataColumn[Categorical]':
- column_types = ('categorical',)
- elif repr == 'MetadataColumn[Numeric]':
- column_types = ('numeric',)
- elif (repr == 'MetadataColumn[Categorical | Numeric]' or
- repr == 'MetadataColumn[Numeric | Categorical]'):
- column_types = ('categorical', 'numeric')
- else:
- raise NotImplementedError(
- "Parameter %r is type %s, which is not currently supported by "
- "this interface." % (name, repr))
- return MetadataColumnHandler(name, repr, column_types, default=default,
- description=description)
- else:
- return RegularParameterHandler(name, repr, ast, default=default,
- description=description)
-
-
-class MetadataHandler(Handler):
- def __init__(self, name, default=NoDefault, description=None):
- if default is not NoDefault and default is not None:
- raise TypeError(
- "The only supported default value for Metadata is `None`. "
- "Found this default value: %r" % (default,))
-
- super().__init__(name, prefix='m_', default=default,
- description=description)
- self.click_name += '_file'
-
- def get_click_options(self):
- import click
- import q2cli
- import q2cli.core
-
- name = '--' + self.cli_name
- type = click.Path(exists=True, file_okay=True, dir_okay=False,
- readable=True)
- type = q2cli.core.MultipleType(type)
- help = ('Metadata file or artifact viewable as metadata. This '
- 'option may be supplied multiple times to merge metadata.')
-
- if self.default is None:
- requirement = '[optional]'
- else:
- requirement = '[required]'
-
- option = q2cli.Option([name], type=type, help=help, multiple=True)
- yield self._add_description(option, requirement)
-
- def get_value(self, verbose, arguments, fallback=None):
- import qiime2
- import q2cli.util
-
- paths = self._locate_value(arguments, fallback, multiple=True)
- if paths is None:
- return paths
-
- metadata = []
- for path in paths:
- try:
- # check to see if path is an artifact
- artifact = qiime2.Artifact.load(path)
- except Exception:
- try:
- metadata.append(qiime2.Metadata.load(path))
- except Exception as e:
- header = ("There was an issue with loading the file %s as "
- "metadata:" % path)
- tb = 'stderr' if verbose else None
- q2cli.util.exit_with_error(e, header=header,
- traceback=tb)
- else:
- try:
- metadata.append(artifact.view(qiime2.Metadata))
- except Exception as e:
- header = ("There was an issue with viewing the artifact "
- "%s as QIIME 2 Metadata:" % path)
- tb = 'stderr' if verbose else None
- q2cli.util.exit_with_error(e, header=header,
- traceback=tb)
- if len(metadata) == 1:
- return metadata[0]
- else:
- return metadata[0].merge(*metadata[1:])
-
-
-class MetadataColumnHandler(Handler):
- def __init__(self, name, repr, column_types, default=NoDefault,
- description=None):
- if default is not NoDefault and default is not None:
- raise TypeError(
- "The only supported default value for MetadataColumn "
- "subclasses is `None`. Found this default value: %r"
- % (default,))
-
- super().__init__(name, prefix='m_', default=default,
- description=description)
- self.click_name += '_column'
-
- self.repr = repr
- self.column_types = column_types
-
- # Not passing `description` to metadata handler because `description`
- # applies to the metadata column (`self`).
- self.metadata_handler = MetadataHandler(name, default=default)
-
- def get_click_options(self):
- import q2cli
-
- name = '--' + self.cli_name
- type = str
- help = 'Column from metadata file or artifact viewable as metadata.'
-
- if self.default is None:
- requirement = '[optional]'
- else:
- requirement = '[required]'
-
- option = q2cli.Option([name], type=type, help=help, metavar=self.repr)
-
- yield from self.metadata_handler.get_click_options()
- yield self._add_description(option, requirement)
-
- def get_value(self, verbose, arguments, fallback=None):
- import q2cli.util
-
- # Attempt to find all options before erroring so that all handlers'
- # missing options can be displayed to the user.
- try:
- metadata_value = self.metadata_handler.get_value(
- verbose, arguments, fallback=fallback)
- except ValueNotFoundException:
- pass
-
- try:
- column_value = self._locate_value(arguments, fallback)
- except ValueNotFoundException:
- pass
-
- missing = self.metadata_handler.missing + self.missing
- if missing:
- self.missing = missing
- raise ValueNotFoundException()
-
- # If metadata column is optional, there is a chance for metadata to be
- # provided without a metadata column, or vice versa.
- if metadata_value is None and column_value is not None:
- self.missing.append(self.metadata_handler.cli_name)
- raise ValueNotFoundException()
- elif metadata_value is not None and column_value is None:
- self.missing.append(self.cli_name)
- raise ValueNotFoundException()
-
- if metadata_value is None and column_value is None:
- return None
- else:
- try:
- metadata_column = metadata_value.get_column(column_value)
- if metadata_column.type not in self.column_types:
- # This exception, and any exceptions raised by
- # `.get_column()` above, will be handled below in the
- # `except` block.
- if len(self.column_types) == 1:
- suffix = '%s.' % self.column_types[0]
- else:
- suffix = ('one of the following types: %s' %
- ', '.join(self.column_types))
- raise TypeError(
- "Metadata column %r is %s. Option --%s expects the "
- "column to be %s" %
- (column_value, metadata_column.type, self.cli_name,
- suffix))
- except Exception as e:
- header = ("There was an issue with retrieving column %r from "
- "the metadata:" % column_value)
- q2cli.util.exit_with_error(e, header=header, traceback=None)
- return metadata_column
-
-
-class RegularParameterHandler(GeneratedHandler):
- prefix = 'p_'
-
- def __init__(self, name, repr, ast, default=NoDefault, description=None):
- import q2cli.util
-
- super().__init__(name, repr, ast, default=default,
- description=description)
- # TODO: just create custom click.ParamType to avoid this silliness
- if ast['type'] == 'collection':
- ast, = ast['fields']
- self.type = q2cli.util.convert_primitive(ast)
-
- def get_click_options(self):
- import q2cli
- import q2cli.util
-
- if self.type is bool:
- no_name = self.prefix + 'no_' + self.name
- cli_no_name = q2cli.util.to_cli_name(no_name)
- name = '--' + self.cli_name + '/--' + cli_no_name
- # click.Option type is determined implicitly for flags with
- # secondary options, and explicitly passing type=bool results in a
- # TypeError, so we pass type=None (the default).
- option_type = None
- else:
- name = '--' + self.cli_name
- option_type = self.type
-
- if self.default is NoDefault:
- requirement = '[required]'
- elif self.default is None:
- requirement = '[optional]'
- else:
- requirement = '[default: %s]' % self.default
-
- # Pass `default=None` and `show_default=False` to `click.Option`
- # because the handlers are responsible for resolving missing values and
- # supplying defaults. Telling Click about the default value here makes
- # it impossible to determine whether the user supplied or omitted a
- # value once the handlers are invoked.
- option = q2cli.Option([name], type=option_type, default=None,
- show_default=False, help='')
-
- yield self._add_description(option, requirement)
-
- def get_value(self, arguments, fallback=None):
- value = self._locate_value(arguments, fallback)
- if value is None:
- return None
-
- elif self.type is bool:
- # TODO: should we defer to the Bool primitive? It only allows
- # 'true' and 'false'.
- if type(value) is not bool:
- value = self._parse_boolean(value)
- return value
- else:
- import qiime2.sdk
- primitive = qiime2.sdk.parse_type(self.repr, expect='primitive')
- # TODO/HACK: the repr is the primitive used, but since there's a
- # collection handler managing the set/list this get_value should
- # handle only the pieces. This is super gross, but would be
- # unecessary if click.ParamTypes were implemented for each
- # kind of QIIME 2 input.
- if self.ast['type'] == 'collection':
- primitive, = primitive.fields
-
- return primitive.decode(value)
=====================================
q2cli/tests/test_cli.py
=====================================
@@ -16,8 +16,8 @@ from qiime2 import Artifact, Visualization
from qiime2.core.testing.type import IntSequence1, IntSequence2
from qiime2.core.testing.util import get_dummy_plugin
-from q2cli.info import info
-from q2cli.tools import tools
+from q2cli.builtin.info import info
+from q2cli.builtin.tools import tools
from q2cli.commands import RootCommand
@@ -312,7 +312,7 @@ class TestOptionalArtifactSupport(unittest.TestCase):
def test_no_optional_artifacts_provided(self):
result = self._run_command(
'optional-artifacts-method', '--i-ints', self.ints1,
- '--p-num1', 42, '--o-output', self.output, '--verbose')
+ '--p-num1', '42', '--o-output', self.output, '--verbose')
self.assertEqual(result.exit_code, 0)
self.assertEqual(Artifact.load(self.output).view(list),
@@ -321,7 +321,7 @@ class TestOptionalArtifactSupport(unittest.TestCase):
def test_one_optional_artifact_provided(self):
result = self._run_command(
'optional-artifacts-method', '--i-ints', self.ints1,
- '--p-num1', 42, '--i-optional1', self.ints2,
+ '--p-num1', '42', '--i-optional1', self.ints2,
'--o-output', self.output, '--verbose')
self.assertEqual(result.exit_code, 0)
@@ -331,8 +331,8 @@ class TestOptionalArtifactSupport(unittest.TestCase):
def test_all_optional_artifacts_provided(self):
result = self._run_command(
'optional-artifacts-method', '--i-ints', self.ints1,
- '--p-num1', 42, '--i-optional1', self.ints2,
- '--i-optional2', self.ints3, '--p-num2', 111,
+ '--p-num1', '42', '--i-optional1', self.ints2,
+ '--i-optional2', self.ints3, '--p-num2', '111',
'--o-output', self.output, '--verbose')
self.assertEqual(result.exit_code, 0)
@@ -342,12 +342,12 @@ class TestOptionalArtifactSupport(unittest.TestCase):
def test_optional_artifact_type_mismatch(self):
result = self._run_command(
'optional-artifacts-method', '--i-ints', self.ints1,
- '--p-num1', 42, '--i-optional1', self.ints3,
+ '--p-num1', '42', '--i-optional1', self.ints3,
'--o-output', self.output, '--verbose')
self.assertEqual(result.exit_code, 1)
self.assertRegex(str(result.output),
- 'type IntSequence2.*subtype IntSequence1')
+ 'type IntSequence1.*type IntSequence2.*')
class MetadataTestsBase(unittest.TestCase):
@@ -385,19 +385,6 @@ class MetadataTestsBase(unittest.TestCase):
Artifact.import_data(
'Mapping', {'a': 'dog', 'b': 'cat'}).save(self.metadata_artifact)
- self.cmd_config = os.path.join(self.tempdir, 'conf.ini')
- with open(self.cmd_config, 'w') as f:
- f.write('[dummy-plugin.identity-with-metadata]\n'
- 'm-metadata-file=%s\n' % self.metadata_file1)
- f.write('[dummy-plugin.identity-with-optional-metadata]\n'
- 'm-metadata-file=%s\n' % self.metadata_file1)
- f.write('[dummy-plugin.identity-with-metadata-column]\n'
- 'm-metadata-file=%s\n'
- 'm-metadata-column=col1\n' % self.metadata_file1)
- f.write('[dummy-plugin.identity-with-optional-metadata-column]\n'
- 'm-metadata-file=%s\n'
- 'm-metadata-column=col1\n' % self.metadata_file1)
-
def tearDown(self):
shutil.rmtree(self.tempdir)
@@ -428,7 +415,7 @@ class TestMetadataSupport(MetadataTestsBase):
self.assertEqual(result.exit_code, 1)
self.assertTrue(result.output.startswith('Usage:'))
- self.assertIn("Missing option: --m-metadata-file", result.output)
+ self.assertIn("Missing option \"--m-metadata-file\"", result.output)
def test_optional_metadata_missing(self):
result = self._run_command(
@@ -501,20 +488,7 @@ class TestMetadataSupport(MetadataTestsBase):
'--m-metadata-file', self.metadata_file1)
self.assertNotEqual(result.exit_code, 0)
- self.assertIn('overlapping columns', str(result.exception))
-
- def test_cmd_config_metadata(self):
- for command in ('identity-with-metadata',
- 'identity-with-optional-metadata'):
- result = self._run_command(
- command, '--i-ints', self.input_artifact, '--o-out',
- self.output_artifact, '--cmd-config', self.cmd_config,
- '--verbose')
-
- exp_tsv = 'id\tcol1\n#q2:types\tcategorical\n0\tfoo\nid1\tbar\n'
- self._assertMetadataOutput(
- result, exp_tsv=exp_tsv,
- exp_yaml="metadata: !metadata 'metadata.tsv'")
+ self.assertIn('overlapping columns', result.output)
class TestMetadataColumnSupport(MetadataTestsBase):
@@ -525,8 +499,7 @@ class TestMetadataColumnSupport(MetadataTestsBase):
self.assertEqual(result.exit_code, 1)
self.assertTrue(result.output.startswith('Usage:'))
- self.assertIn("Missing option: --m-metadata-file", result.output)
- self.assertIn("Missing option: --m-metadata-column", result.output)
+ self.assertIn("Missing option \"--m-metadata-file\"", result.output)
def test_optional_metadata_missing(self):
result = self._run_command(
@@ -544,7 +517,7 @@ class TestMetadataColumnSupport(MetadataTestsBase):
self.assertEqual(result.exit_code, 1)
self.assertTrue(result.output.startswith('Usage:'))
- self.assertIn("Missing option: --m-metadata-column", result.output)
+ self.assertIn("Missing option \"--m-metadata-column\"", result.output)
def test_optional_column_without_metadata(self):
result = self._run_command(
@@ -554,7 +527,7 @@ class TestMetadataColumnSupport(MetadataTestsBase):
self.assertEqual(result.exit_code, 1)
self.assertTrue(result.output.startswith('Usage:'))
- self.assertIn("Missing option: --m-metadata-file", result.output)
+ self.assertIn("Missing option \"--m-metadata-file\"", result.output)
def test_single_metadata(self):
for command in ('identity-with-metadata-column',
@@ -579,11 +552,9 @@ class TestMetadataColumnSupport(MetadataTestsBase):
self.metadata_artifact, '--m-metadata-column', 'col2',
'--verbose')
- exp_tsv = 'id\tcol2\n#q2:types\tcategorical\n0\tbaz\n'
- exp_yaml = "metadata: !metadata '%s:metadata.tsv'" % (
- Artifact.load(self.metadata_artifact).uuid)
- self._assertMetadataOutput(result, exp_tsv=exp_tsv,
- exp_yaml=exp_yaml)
+ self.assertEqual(result.exit_code, 1)
+ self.assertIn('\'--m-metadata-file\' was specified multiple times',
+ result.output)
def test_multiple_metadata_column(self):
result = self._run_command(
@@ -595,41 +566,17 @@ class TestMetadataColumnSupport(MetadataTestsBase):
self.assertEqual(result.exit_code, 1)
self.assertTrue(result.output.startswith('Usage:'))
- self.assertIn('--m-metadata-column was specified multiple times',
+ self.assertIn('\'--m-metadata-file\' was specified multiple times',
result.output)
- def test_invalid_metadata_merge(self):
- for command in ('identity-with-metadata-column',
- 'identity-with-optional-metadata-column'):
- result = self._run_command(
- command, '--i-ints', self.input_artifact, '--o-out',
- self.output_artifact, '--m-metadata-file', self.metadata_file1,
- '--m-metadata-file', self.metadata_file1,
- '--m-metadata-column', 'col1')
-
- self.assertNotEqual(result.exit_code, 0)
- self.assertIn('overlapping columns', str(result.exception))
-
- def test_cmd_config(self):
- for command in ('identity-with-metadata-column',
- 'identity-with-optional-metadata-column'):
- result = self._run_command(
- command, '--i-ints', self.input_artifact, '--o-out',
- self.output_artifact, '--cmd-config', self.cmd_config,
- '--verbose')
-
- exp_tsv = 'id\tcol1\n#q2:types\tcategorical\n0\tfoo\nid1\tbar\n'
- self._assertMetadataOutput(
- result, exp_tsv=exp_tsv,
- exp_yaml="metadata: !metadata 'metadata.tsv'")
-
def test_categorical_metadata_column(self):
result = self._run_command(
'identity-with-categorical-metadata-column', '--help')
help_text = result.output
- self.assertIn('--m-metadata-column MetadataColumn[Categorical]',
- help_text)
+ self.assertIn(
+ '--m-metadata-column COLUMN MetadataColumn[Categorical]',
+ help_text)
result = self._run_command(
'identity-with-categorical-metadata-column', '--i-ints',
@@ -650,16 +597,17 @@ class TestMetadataColumnSupport(MetadataTestsBase):
'--m-metadata-column', 'numbers')
self.assertEqual(result.exit_code, 1)
- err_msg = ("Metadata column 'numbers' is numeric. Option "
- "--m-metadata-column expects the column to be categorical.")
- self.assertIn(err_msg, result.output)
+ self.assertIn("Metadata column", result.output)
+ self.assertIn("numeric", result.output)
+ self.assertIn("expected Categorical", result.output)
def test_numeric_metadata_column(self):
result = self._run_command(
'identity-with-numeric-metadata-column', '--help')
help_text = result.output
- self.assertIn('--m-metadata-column MetadataColumn[Numeric]', help_text)
+ self.assertIn('--m-metadata-column COLUMN MetadataColumn[Numeric]',
+ help_text)
result = self._run_command(
'identity-with-numeric-metadata-column', '--i-ints',
@@ -680,9 +628,9 @@ class TestMetadataColumnSupport(MetadataTestsBase):
'--m-metadata-column', 'strings')
self.assertEqual(result.exit_code, 1)
- err_msg = ("Metadata column 'strings' is categorical. Option "
- "--m-metadata-column expects the column to be numeric.")
- self.assertIn(err_msg, result.output)
+ self.assertIn("Metadata column", result.output)
+ self.assertIn("categorical", result.output)
+ self.assertIn("expected Numeric", result.output)
if __name__ == "__main__":
=====================================
q2cli/tests/test_core.py
=====================================
@@ -11,15 +11,14 @@ import shutil
import tempfile
import unittest
-import click
from click.testing import CliRunner
from qiime2 import Artifact
from qiime2.core.testing.type import IntSequence1
from qiime2.core.testing.util import get_dummy_plugin
import q2cli
-import q2cli.info
-import q2cli.tools
+import q2cli.builtin.info
+import q2cli.builtin.tools
from q2cli.commands import RootCommand
@@ -35,22 +34,22 @@ class TestOption(unittest.TestCase):
def _assertRepeatedOptionError(self, result, option):
self.assertEqual(result.exit_code, 1)
self.assertTrue(result.output.startswith('Usage:'))
- self.assertIn('%s was specified multiple times' % option,
- result.output)
+ self.assertRegex(result.output, '.*%s.* was specified multiple times'
+ % option)
def test_repeated_eager_option_with_callback(self):
result = self.runner.invoke(
- q2cli.tools.tools,
+ q2cli.builtin.tools.tools,
['import', '--show-importable-types', '--show-importable-types'])
self._assertRepeatedOptionError(result, '--show-importable-types')
def test_repeated_builtin_flag(self):
result = self.runner.invoke(
- q2cli.info.info,
- ['info', '--py-packages', '--py-packages'])
+ q2cli.builtin.tools.tools,
+ ['import', '--input-path', 'a', '--input-path', 'b'])
- self._assertRepeatedOptionError(result, '--py-packages')
+ self._assertRepeatedOptionError(result, '--input-path')
def test_repeated_action_flag(self):
qiime_cli = RootCommand()
@@ -70,7 +69,7 @@ class TestOption(unittest.TestCase):
output_path = os.path.join(self.tempdir, 'out.qza')
result = self.runner.invoke(
- q2cli.tools.tools,
+ q2cli.builtin.tools.tools,
['import', '--input-path', input_path,
'--output-path', output_path, '--type', 'IntSequence1',
'--type', 'IntSequence1'])
@@ -116,11 +115,5 @@ class TestOption(unittest.TestCase):
self.assertEqual(Artifact.load(output_path).view(list), [0, 42, 43])
-class TestOptionDecorator(unittest.TestCase):
- def test_cls_override(self):
- with self.assertRaisesRegex(ValueError, 'override `cls=q2cli.Option`'):
- q2cli.option('--bar', cls=click.Option)
-
-
if __name__ == "__main__":
unittest.main()
=====================================
q2cli/tests/test_tools.py
=====================================
@@ -15,7 +15,7 @@ from click.testing import CliRunner
from qiime2 import Artifact
from qiime2.core.testing.util import get_dummy_plugin
-from q2cli.tools import tools
+from q2cli.builtin.tools import tools
from q2cli.commands import RootCommand
=====================================
q2cli/util.py
=====================================
@@ -8,8 +8,13 @@
def get_app_dir():
- import click
- return click.get_app_dir('q2cli', roaming=False)
+ import os
+ conda_prefix = os.environ.get('CONDA_PREFIX')
+ if conda_prefix is not None and os.access(conda_prefix, os.W_OK | os.X_OK):
+ return os.path.join(conda_prefix, 'var', 'q2cli')
+ else:
+ import click
+ return click.get_app_dir('q2cli', roaming=False)
# NOTE: `get_cache_dir` and `get_completion_path` live here instead of
@@ -52,6 +57,7 @@ def exit_with_error(e, header='An error has been encountered:',
if traceback is not None:
tb.print_exception(type(e), e, e.__traceback__, file=tb_file)
+
tb_file.write('\n')
click.secho('\n\n'.join(segments), fg='red', bold=True, err=True)
@@ -62,6 +68,25 @@ def exit_with_error(e, header='An error has been encountered:',
click.get_current_context().exit(status)
+def get_close_matches(name, possibilities):
+ import difflib
+
+ name = name.lower()
+ # bash completion makes an incomplete arg most likely
+ matches = [m for m in possibilities if m.startswith(name)]
+ if not matches:
+ # otherwise, it may be misspelled
+ matches = difflib.get_close_matches(name, possibilities, cutoff=0.8)
+
+ matches.sort()
+
+ if len(matches) > 5:
+ # this is probably a good time to look at --help
+ matches = matches[:4] + ['...']
+
+ return matches
+
+
class pretty_failure:
def __init__(self, header='An error has been encountered:',
traceback='stderr', status=1):
@@ -105,12 +130,12 @@ def convert_primitive(ast):
if predicate['name'] == 'Choices' and ast['name'] == 'Str':
return click.Choice(predicate['choices'])
elif predicate['name'] == 'Range' and ast['name'] == 'Int':
- start = predicate['start']
- end = predicate['end']
+ start = predicate['range'][0]
+ end = predicate['range'][1]
# click.IntRange is always inclusive
- if start is not None and not predicate['inclusive-start']:
+ if start is not None and not predicate['inclusive'][0]:
start += 1
- if end is not None and not predicate['inclusive-end']:
+ if end is not None and not predicate['inclusive'][1]:
end -= 1
return click.IntRange(start, end)
elif predicate['name'] == 'Range' and ast['name'] == 'Float':
@@ -147,6 +172,6 @@ def citations_option(get_citation_records):
click.secho('No citations found.', fg='yellow', err=True)
ctx.exit(1)
- return click.Option(('--citations',), is_flag=True, expose_value=False,
+ return click.Option(['--citations'], is_flag=True, expose_value=False,
is_eager=True, callback=callback,
help='Show citations and exit.')
View it on GitLab: https://salsa.debian.org/med-team/q2cli/commit/5e9084009852bf779a99a228c21b0b848c6566bb
--
View it on GitLab: https://salsa.debian.org/med-team/q2cli/commit/5e9084009852bf779a99a228c21b0b848c6566bb
You're receiving this email because of your account on salsa.debian.org.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20190613/d51f0853/attachment-0001.html>
More information about the debian-med-commit
mailing list