From d1212e78553c55d2d836bf1f251e1bf116b5c364 Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Mon, 31 Aug 2026 13:32:15 +0200 Subject: [PATCH 01/10] MAINT: qiime2/q2cli -> rachis/rachis-cli name change --- Makefile | 4 +- README.md | 11 ++- bin/{tab-qiime => tab-rachis} | 18 ++-- conda-recipe/meta.yaml | 7 +- ... 50_activate_rachis_cli_tab_completion.sh} | 4 +- pyproject.toml | 5 +- q2cli/__main__.py | 14 +-- q2cli/builtin/dev.py | 9 +- q2cli/builtin/info.py | 2 +- q2cli/builtin/tools.py | 88 +++++++++---------- q2cli/click/option.py | 2 +- q2cli/click/type.py | 2 +- q2cli/commands.py | 17 ++-- q2cli/core/artifact_cache_global.py | 2 +- q2cli/core/assets/cli_howto.txt | 4 +- q2cli/core/assets/copyright_note.txt | 2 +- q2cli/core/cache.py | 25 +++--- q2cli/core/completion.py | 4 +- q2cli/core/usage.py | 25 +++--- q2cli/tests/test_cache_cli.py | 2 +- q2cli/tests/test_cli.py | 79 +++++++++-------- q2cli/tests/test_core.py | 18 ++-- q2cli/tests/test_dev.py | 6 +- q2cli/tests/test_tools.py | 54 ++++++------ q2cli/tests/test_usage.py | 50 +++++------ q2cli/util.py | 12 +-- 26 files changed, 237 insertions(+), 229 deletions(-) rename bin/{tab-qiime => tab-rachis} (71%) rename hooks/{50_activate_q2cli_tab_completion.sh => 50_activate_rachis_cli_tab_completion.sh} (71%) diff --git a/Makefile b/Makefile index 863de011..b3f1a34b 100644 --- a/Makefile +++ b/Makefile @@ -26,14 +26,14 @@ mystery-stew: all install: vendor-view all $(PYTHON) -m pip install -v . && \ mkdir -p $(PREFIX)/etc/conda/activate.d && \ - cp hooks/50_activate_q2cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/ + cp hooks/50_activate_rachis_cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/ dev: dev-no-view vendor-view all dev-no-view: all pip install -e . && \ mkdir -p $(PREFIX)/etc/conda/activate.d && \ - cp hooks/50_activate_q2cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/ + cp hooks/50_activate_rachis_cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/ clean: distclean rm -rf ./q2cli/assets diff --git a/README.md b/README.md index ea2eba6a..354bc13d 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ -# q2cli +# rachis-cli ![](https://github.com/qiime2/q2cli/workflows/ci-dev/badge.svg) -A [click-based](http://click.pocoo.org/) command line interface for [QIIME -2](https://github.com/qiime2/qiime2). +A [click-based](http://click.pocoo.org/) command line interface for [rachis](https://github.com/rachis-org/rachis). ## Installation and getting help -Visit https://qiime2.org to learn more about q2cli and the QIIME 2 project. +Visit https://qiime2.org to learn more about rachis-cli and the rachis project. ## Enabling tab completion @@ -17,7 +16,7 @@ To enable tab completion in Bash, run the following command or add it to your `.bashrc`/`.bash_profile`: ```bash -source tab-qiime +source tab-rachis ``` ### ZSH @@ -26,5 +25,5 @@ To enable tab completion in ZSH, run the following commands or add them to your `.zshrc`: ```bash -autoload -Uz compinit && compinit && autoload bashcompinit && bashcompinit && source tab-qiime +autoload -Uz compinit && compinit && autoload bashcompinit && bashcompinit && source tab-rachis ``` diff --git a/bin/tab-qiime b/bin/tab-rachis similarity index 71% rename from bin/tab-qiime rename to bin/tab-rachis index 516d2bd6..57acc6e3 100644 --- a/bin/tab-qiime +++ b/bin/tab-rachis @@ -1,17 +1,17 @@ #!/usr/bin/env bash # Bash completion script that defers to a cached completion script representing -# the state of the current QIIME 2 deployment. +# the state of the current rachis deployment. # # This script is intended to be executed on the command-line or in # .bashrc/.bash_profile: # -# source tab-qiime +# source tab-rachis # -_qiime_completion() +_rachis_completion() { - # Attempt to find the cached completion script. If q2cli isn't installed, or + # Attempt to find the cached completion script. If rachis-cli isn't installed, or # is an incompatible version, don't attempt completion. local completion_path="$(python -c "import q2cli.util; print(q2cli.util.get_completion_path())" 2> /dev/null)" @@ -39,16 +39,16 @@ _qiime_completion() return 0 } -# Enable default readline and bash completion behavior when `_qiime_completion` +# Enable default readline and bash completion behavior when `_rachis_completion` # doesn't have a reply. -complete -F _qiime_completion -o default -o bashdefault qiime +complete -F _rachis_completion -o default -o bashdefault ${usr_cmd} -# Execute a `qiime` command (any command will do) so that tab-completion will -# work out-of-the-box (e.g. with a fresh installation of q2cli). Running a +# Execute a `rachis` command (any command will do) so that tab-completion will +# work out-of-the-box (e.g. with a fresh installation of rachis-cli). Running a # command will create or refresh the cache if necessary, which contains the # actual completion script. # # Ignore stdout to avoid displaying help text to users enabling tab-completion. # stderr displays the note about cache refreshing, as that can take a few # moments to complete. -qiime &> /dev/null +${usr_cmd} &> /dev/null diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index 666468e3..49d9d5af 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -7,6 +7,7 @@ build: script: make install entry_points: - qiime=q2cli.__main__:qiime + - rachis=q2cli.__main__:qiime requirements: host: - python {{ python }} @@ -18,7 +19,7 @@ requirements: - python {{ python }} - setuptools - click {{ click }} - - qiime2 >={{ qiime2 }} + - rachis >={{ rachis }} - q2templates >={{ q2templates }} build: - python {{ python }} @@ -26,7 +27,7 @@ requirements: - versioningit test: requires: - - qiime2 >={{ qiime2 }} + - rachis >={{ rachis }} - q2templates >={{ q2templates }} - pytest - pytest-xdist @@ -34,7 +35,7 @@ test: imports: - q2cli commands: - - Q2CLIDEV=1 QIIMETEST= qiime --help + - Q2CLIDEV=1 QIIMETEST= rachis --help - Q2CLIDEV=1 QIIMETEST= py.test --pyargs q2cli about: home: https://qiime2.org diff --git a/hooks/50_activate_q2cli_tab_completion.sh b/hooks/50_activate_rachis_cli_tab_completion.sh similarity index 71% rename from hooks/50_activate_q2cli_tab_completion.sh rename to hooks/50_activate_rachis_cli_tab_completion.sh index 2f583850..6f2f89a3 100644 --- a/hooks/50_activate_q2cli_tab_completion.sh +++ b/hooks/50_activate_rachis_cli_tab_completion.sh @@ -1,5 +1,5 @@ if [ -n "${ZSH_VERSION-}" ]; then - autoload -Uz compinit && compinit && autoload bashcompinit && bashcompinit && source tab-qiime + autoload -Uz compinit && compinit && autoload bashcompinit && bashcompinit && source tab-rachis elif [ -n "${BASH_VERSION-}" ]; then - source tab-qiime + source tab-rachis fi diff --git a/pyproject.toml b/pyproject.toml index f1ce86b5..71710045 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,11 @@ dynamic = ["version"] [project.urls] Homepage = "https://qiime2.org" -Repository = "https://github.com/qiime2/q2cli" +Repository = "https://github.com/rachis-org/rachis-cli" [project.scripts] "qiime" = "q2cli.__main__:qiime" +"rachis" = "q2cli.__main__:qiime" [project.entry-points.'qiime2.usage_drivers'] "cli" = "q2cli.core.usage:ReplayCLIUsage" @@ -45,7 +46,7 @@ file = "q2cli/_version.py" [tool.setuptools] include-package-data = true script-files = [ - "bin/tab-qiime" + "bin/tab-rachis" ] [tool.setuptools.packages.find] diff --git a/q2cli/__main__.py b/q2cli/__main__.py index 9d342fe8..2d4e6585 100644 --- a/q2cli/__main__.py +++ b/q2cli/__main__.py @@ -12,15 +12,15 @@ ROOT_COMMAND_HELP = """\ -QIIME 2 command-line interface (q2cli) --------------------------------------- +rachis command-line interface (rachis-cli) +------------------------------------------ -To get help with QIIME 2, visit https://qiime2.org. +To get help with rachis, visit https://qiime2.org. To enable tab completion in Bash, run the following command or add it to your \ .bashrc/.bash_profile: - source tab-qiime + source tab-rachis To enable tab completion in ZSH, run the following commands or add them to \ your .zshrc: @@ -28,7 +28,7 @@ \b autoload -Uz compinit && compinit autoload bashcompinit && bashcompinit - source tab-qiime + source tab-rachis """ @@ -37,8 +37,8 @@ @click.command(cls=q2cli.commands.RootCommand, invoke_without_command=True, no_args_is_help=True, help=ROOT_COMMAND_HELP) @click.version_option(prog_name='q2cli', - message='%(prog)s version %(version)s\nRun `qiime info` ' - 'for more version details.') + message='%(prog)s version %(version)s\nRun `rachis info`' + ' for more version details.') def qiime(): pass diff --git a/q2cli/builtin/dev.py b/q2cli/builtin/dev.py index 9c09ddb9..9f3cf3fa 100644 --- a/q2cli/builtin/dev.py +++ b/q2cli/builtin/dev.py @@ -48,7 +48,7 @@ def refresh_cache(): "\n" "`command` refers to the name of the command you issued. `option` refers " "to the arguments you give to the command when running it. `type` refers " - "to the QIIME 2 semantic typing of these arguments (where applicable). " + "to the rachis semantic typing of these arguments (where applicable). " "`default_arg` refers to the label next to the argument indicating its " "default value (where applicable), and if it is required (where " "applicable). `required` refers to any arguments that must be passed to " @@ -151,8 +151,9 @@ def reset_theme(): @click.argument('input-path', type=click.Path(exists=True, file_okay=True, dir_okay=True, readable=True), metavar=_COMBO_METAVAR) +# TODO: we should probably change this to --rachis-type @click.option('--qiime-type', required=True, - help='QIIME 2 data type.') + help='rachis data type.') def assert_result_type(input_path, qiime_type): import q2cli.util import qiime2.sdk @@ -166,7 +167,7 @@ def assert_result_type(input_path, qiime_type): else: result = qiime2.sdk.Result.load(input_path) except Exception as e: - header = 'There was a problem loading %s as a QIIME 2 Result:' % \ + header = 'There was a problem loading %s as a rachis Result:' % \ input_path q2cli.util.exit_with_error(e, header=header) @@ -207,7 +208,7 @@ def assert_result_data(input_path, zip_data_path, expression): try: result = qiime2.sdk.Result.load(input_path) except Exception as e: - header = 'There was a problem loading %s as a QIIME 2 result:' % \ + header = 'There was a problem loading %s as a rachis result:' % \ input_path q2cli.util.exit_with_error(e, header=header) diff --git a/q2cli/builtin/info.py b/q2cli/builtin/info.py index 1c2a5291..a7dfe711 100644 --- a/q2cli/builtin/info.py +++ b/q2cli/builtin/info.py @@ -116,7 +116,7 @@ def info(config_level): if config_level: click.secho('To get help with configuring and/or understanding ' - 'QIIME 2 parallelization, visit ' + 'rachis parallelization, visit ' 'https://use.qiime2.org/en/stable/references/' 'parallel-configuration.html') diff --git a/q2cli/builtin/tools.py b/q2cli/builtin/tools.py index 65b51b25..176ae9fe 100644 --- a/q2cli/builtin/tools.py +++ b/q2cli/builtin/tools.py @@ -74,14 +74,14 @@ def _export(result, output_format, output_path): return output_format -@click.group(help='Tools for working with QIIME 2 files.', +@click.group(help='Tools for working with rachis files.', cls=ToolGroupCommand) def tools(): pass @tools.command(name='export', - short_help='Export data from a QIIME 2 Artifact ' + short_help='Export data from a rachis Artifact ' 'or a Visualization', help='Exporting extracts (and optionally transforms) data ' 'stored inside an Artifact or Visualization. Note that ' @@ -240,15 +240,15 @@ def show_formats(queries, importable, exportable, strict, tsv): @tools.command(name='import', - short_help='Import data into a new QIIME 2 Artifact.', - help="Import data to create a new QIIME 2 Artifact. See " + short_help='Import data into a new rachis Artifact.', + help="Import data to create a new rachis Artifact. See " "https://docs.qiime2.org/ for usage examples and details " "on the file types and associated semantic types that can " "be imported.", cls=ToolCommand) @click.option('--type', required=True, help='The semantic type of the artifact that will be created ' - 'upon importing. Use `qiime tools list-types` to see what ' + 'upon importing. Use `rachis tools list-types` to see what ' 'importable semantic types are available in the current ' 'deployment.') @click.option('--input-path', required=True, @@ -262,7 +262,7 @@ def show_formats(queries, importable, exportable, strict, tsv): @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. Use `qiime tools list-formats ' + 'provided via --type. Use `rachis tools list-formats ' '--importable` to see which formats of input data are ' 'importable.') @click.option('--validate-level', default='max', @@ -287,9 +287,9 @@ def import_data(type, input_path, output_path, input_format, validate_level): click.echo(CONFIG.cfg_style('success', success)) -@tools.command(short_help='Take a peek at a QIIME 2 Artifact or ' +@tools.command(short_help='Take a peek at a rachis Artifact or ' 'Visualization.', - help="Display basic information about a QIIME 2 Artifact or " + help="Display basic information about a rachis Artifact or " "Visualization, including its UUID and type.", cls=ToolCommand) @click.argument('paths', nargs=-1, required=True, @@ -543,8 +543,8 @@ def _merge_metadata(paths): return metadata -@tools.command(short_help='Make a QIIME 2 Report.', - help='Make a QIIME 2 report consisting of multiple ' +@tools.command(short_help='Make a rachis Report.', + help='Make a rachis report consisting of multiple ' 'visualizations. Visualizations must have unique ' 'filenames.', cls=ToolCommand) @@ -575,10 +575,10 @@ def make_report(visualization_paths, report_path): click.echo(CONFIG.cfg_style('success', f'Report saved to {result}')) -@tools.command(short_help='View a QIIME 2 Result.', - help="Displays a QIIME 2 Result until the command exits. To " - "open a QIIME 2 Visualization so it can be used after the " - "command exits, use 'qiime tools extract'.", +@tools.command(short_help='View a rachis Result.', + help="Displays a rachis Result until the command exits. To " + "open a rachis Visualization so it can be used after the " + "command exits, use 'rachis tools extract'.", cls=ToolCommand) @click.argument('result-path', metavar='RESULT', type=click.Path(file_okay=True, dir_okay=False, readable=True)) @@ -598,7 +598,7 @@ def view(result_path, port, verbose): 'Result viewing is currently not supported in headless ' 'environments. You can view Results at https://view.qiime2.org, ' 'or move the Result to an environment with a display and view it ' - 'with `qiime tools view`.') + 'with `rachis tools view`.') import signal import random @@ -679,7 +679,7 @@ def _handle_file_exists(self, file_path): self.wfile.write(file.read()) # Get the path to the packaged vendored view - # TODO: This won't work if we start packaging QIIME 2 as a wheel, we will + # TODO: This won't work if we start packaging rachis as a wheel, we will # have to reimplement this used importlib.resources and it may be mildly # annoying to make things work properly. It's hard to tell right now since # we do not use a wheel. @@ -757,13 +757,13 @@ def stop(): break -@tools.command(short_help="Extract a QIIME 2 Artifact or Visualization " +@tools.command(short_help="Extract a rachis Artifact or Visualization " "archive.", - help="Extract all contents of a QIIME 2 Artifact or " + help="Extract all contents of a rachis Artifact or " "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.", + "and actual data. Use 'rachis tools export' to export " + "only the data stored in an Artifact or Visualization, " + "with the choice of exporting to different formats.", cls=ToolCommand) @click.option('--input-path', required=True, metavar=_COMBO_METAVAR, type=click.Path(exists=True, file_okay=True, dir_okay=False, @@ -784,15 +784,15 @@ def extract(input_path, output_path): extracted_dir = qiime2.sdk.Result.extract(input_path, output_path) except (zipfile.BadZipFile, ValueError): raise click.BadParameter( - '%s is not a valid QIIME 2 Result. Only QIIME 2 Artifacts and ' + '%s is not a valid rachis Result. Only rachis Artifacts and ' 'Visualizations can be extracted.' % input_path) else: success = 'Extracted %s to directory %s' % (input_path, extracted_dir) click.echo(CONFIG.cfg_style('success', success)) -@tools.command(short_help='Validate data in a QIIME 2 Artifact.', - help='Validate data in a QIIME 2 Artifact. QIIME 2 ' +@tools.command(short_help='Validate data in a rachis Artifact.', + help='Validate data in a rachis Artifact. rachis ' 'automatically performs some basic validation when ' 'managing your data; use this command to perform explicit ' 'and/or more thorough validation of your data (e.g. when ' @@ -815,7 +815,7 @@ def validate(path, level): try: result = qiime2.sdk.Result.load(path) except Exception as e: - header = 'There was a problem loading %s as a QIIME 2 Result:' % path + header = 'There was a problem loading %s as a rachis Result:' % path q2cli.util.exit_with_error(e, header=header) try: @@ -833,8 +833,8 @@ def validate(path, level): f'valid at level={level}.')) -@tools.command(short_help='Print citations for a QIIME 2 result.', - help='Print citations as a BibTex file (.bib) for a QIIME 2' +@tools.command(short_help='Print citations for a rachis result.', + help='Print citations as a BibTex file (.bib) for a rachis' ' result.', cls=ToolCommand) @click.argument('path', type=click.Path(exists=True, file_okay=True, @@ -849,7 +849,7 @@ def citations(path): try: result = qiime2.sdk.Result.load(path) except Exception as e: - header = 'There was a problem loading %s as a QIIME 2 result:' % path + header = 'There was a problem loading %s as a rachis result:' % path q2cli.util.exit_with_error(e, header=header) if result.citations: @@ -904,7 +904,7 @@ def cache_remove(cache, key): try: _cache = Cache(cache) except Exception as e: - header = f"The path '{cache}' is not a valid QIIME 2 cache." + header = f"The path '{cache}' is not a valid rachis cache." q2cli.util.exit_with_error(e, header=header, traceback=None) for _key in key: @@ -998,7 +998,7 @@ def cache_store(cache, artifact_path, key): cls=ToolCommand) @click.option('--type', required=True, help='The semantic type of the artifact that will be created ' - 'upon importing. Use `qiime tools list-types` to see what ' + 'upon importing. Use `rachis tools list-types` to see what ' 'importable semantic types are available in the current ' 'deployment.') @click.option('--input-path', required=True, @@ -1015,7 +1015,7 @@ def cache_store(cache, artifact_path, key): @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. Use `qiime tools list-formats ' + 'provided via --type. Use `rachis tools list-formats ' '--importable` to see which formats of input data are ' 'importable.') @click.option('--validate-level', required=False, default='max', @@ -1050,7 +1050,7 @@ def cache_import(type, input_path, cache, key, input_format, validate_level): @tools.command(name='cache-export', - short_help='Export data from a QIIME 2 Artifact ' + short_help='Export data from a rachis Artifact ' 'or a Visualization in a given cache under a given key.', help='Exporting extracts (and optionally transforms) data ' 'stored inside an Artifact or Visualization. Note that ' @@ -1181,7 +1181,7 @@ def cache_status(cache): replay_in_fp_help = ( - 'filepath to a QIIME 2 Archive (.qza or .qzv) or directory of Archives' + 'filepath to a rachis Archive (.qza or .qzv) or directory of Archives' ) replay_recurse_help = ( 'if in-fp is a directory, will also search sub-directories when finding ' @@ -1270,7 +1270,7 @@ def provenance_replay( metadata_out_dir: str = '' ): """ - Replay provenance from a QIIME 2 Artifact filepath to a written executable + Replay provenance from a rachis Artifact filepath to a written executable """ from qiime2.core.archive.provenance_lib.replay import replay_provenance from qiime2.sdk.util import get_available_usage_drivers @@ -1343,7 +1343,7 @@ def citations_replay( verbose: bool = True ): """ - Reports all citations from a QIIME 2 Artifact or directory of Artifacts, + Reports all citations from a rachis Artifact or directory of Artifacts, with the goal of improving and simplifying attribution of/in published work. @@ -1371,7 +1371,7 @@ def citations_replay( @tools.command(name='replay-supplement', cls=ToolCommand) @click.option('--in-fp', required=True, - help='filepath to a QIIME 2 Archive or directory of Archives') + help='filepath to a rachis Archive or directory of Archives') @click.option('--recurse/--no-recurse', default=False, show_default=True, @@ -1428,7 +1428,7 @@ def supplement_replay( ): """ Produces a zipfile package of useful documentation supporting in silico - reproducibility of some QIIME 2 Result(s) from a QIIME 2 Artifact or + reproducibility of some rachis Result(s) from a rachis Artifact or directory of Artifacts. Package includes: @@ -1471,9 +1471,9 @@ def supplement_replay( @tools.command( name='annotation-create', - short_help='Add a new Annotation to a QIIME 2 Result.', + short_help='Add a new Annotation to a rachis Result.', help='Attach an Annotation to an existing Artifact or Visualization.' - ' Supported for QIIME 2 archives of version 7.0+.', + ' Supported for rachis archives of version 7.0+.', cls=ToolCommand ) @click.option( @@ -1579,7 +1579,7 @@ def annotation_create(input_path, annotation_type, name, @tools.command( name='annotation-remove', - short_help='Remove an Annotation from a QIIME 2 Result.', + short_help='Remove an Annotation from a rachis Result.', help='Remove an existing Annotation (by name) from' ' an Artifact or Visualization.', cls=ToolCommand @@ -1626,7 +1626,7 @@ def annotation_remove(input_path, name, output_path): @tools.command( name='annotation-fetch', - short_help='Fetch an Annotation from a QIIME 2 Result.', + short_help='Fetch an Annotation from a rachis Result.', help='Fetch an existing Annotation (by name) from' ' an Artifact or Visualization.', cls=ToolCommand @@ -1685,7 +1685,7 @@ def annotation_fetch(input_path, name, verbose): @tools.command( name='annotation-list', - short_help='List Annotations on a QIIME 2 Result.', + short_help='List Annotations on a rachis Result.', help='List all Annotations that are attached to' ' an Artifact or Visualization.', cls=ToolCommand @@ -1730,12 +1730,12 @@ def annotation_list(input_path): @tools.command( name='signature-verify', - short_help='Verify a Signature Annotation on a QIIME 2 Result.', + short_help='Verify a Signature Annotation on a rachis Result.', help='Verify the Signature fingerprint matches an existing key pair on ' 'the local GPG keyring, the checksum digest matches a newly ' 'calculated sha512sum of the root checksums file, and the ' 'Signature-level checksums match with the expected sha512sum of ' - 'each file on a QIIME 2 Result.', + 'each file on a rachis Result.', cls=ToolCommand ) @click.option( diff --git a/q2cli/click/option.py b/q2cli/click/option.py index d024cd85..006995a6 100644 --- a/q2cli/click/option.py +++ b/q2cli/click/option.py @@ -242,7 +242,7 @@ def type_cast_value(self, ctx, value): return value[0].merge(*value[1:]) except Exception as e: header = ("There was an issue with merging " - "QIIME 2 Metadata:") + "rachis Metadata:") tb = 'stderr' if '--verbose' in sys.argv else None q2cli.util.exit_with_error( e, header=header, traceback=tb) diff --git a/q2cli/click/type.py b/q2cli/click/type.py index fbb8cb05..9ce46a7b 100644 --- a/q2cli/click/type.py +++ b/q2cli/click/type.py @@ -135,7 +135,7 @@ def _convert_input(self, value, param, ctx): ' %r, did you mean that?)' % os.path.basename(maybe)) - self.fail('%r is a QIIME 2 visualization (.qzv), not an' + self.fail('%r is a rachis visualization (.qzv), not an' ' Artifact (.qza)%s' % (value, hint), param, ctx) style = qiime2.sdk.util.interrogate_collection_type(self.type_expr) diff --git a/q2cli/commands.py b/q2cli/commands.py index d8092a5e..4c6b47c1 100644 --- a/q2cli/commands.py +++ b/q2cli/commands.py @@ -55,7 +55,7 @@ def __init__(self, *args, **kwargs): for old, new in categories) msg = ("Error: The following options no longer exist because " "metadata *categories* are now called metadata " - "*columns* in QIIME 2.\n\n%s" % old_to_new_names) + "*columns* in rachis.\n\n%s" % old_to_new_names) click.echo(CONFIG.cfg_style('error', msg), err=True) sys.exit(-1) @@ -110,7 +110,7 @@ def get_command(self, ctx, name): hint = '' click.echo( - CONFIG.cfg_style('error', "Error: QIIME 2 has no " + CONFIG.cfg_style('error', "Error: rachis has no " "plugin/command named %r." % name + hint), err=True) ctx.exit(2) # Match exit code of `return None` @@ -178,7 +178,7 @@ def _get_version(self, ctx, param, value): pkg_name = pkg_version = "[UNKNOWN]" click.echo( - "QIIME 2 Plugin '%s' version %s (from package '%s' version %s)" + "rachis Plugin '%s' version %s (from package '%s' version %s)" % (self._plugin['name'], self._plugin['version'], pkg_name, pkg_version) ) @@ -236,7 +236,7 @@ def get_command(self, ctx, name): hint = '' click.echo( - CONFIG.cfg_style('error', "Error: QIIME 2 plugin %r has no " + CONFIG.cfg_style('error', "Error: rachis plugin %r has no " "action %r." % (self._plugin['name'], name) + hint), err=True) ctx.exit(2) # Match exit code of `return None` @@ -245,7 +245,7 @@ def get_command(self, ctx, name): class ActionCommand(BaseCommandMixin, click.Command): - """A click manifestation of a QIIME 2 API Action (Method/Visualizer) + """A click manifestation of a rachis API Action (Method/Visualizer) """ def __init__(self, name, plugin, action): @@ -276,10 +276,10 @@ def __init__(self, name, plugin, action): click.Option(['--recycle-pool'], required=False, type=str, help='Use a cache pool for pipeline resumption. ' - 'QIIME 2 will cache your results in this ' + 'rachis will cache your results in this ' 'pool for reuse by future invocations. ' 'These pool are retained until deleted by ' - 'the user. If not provided, QIIME 2 will ' + 'the user. If not provided, rachis will ' 'create a pool which is automatically ' 'reused by invocations of the same action ' 'and removed if the action is successful. ' @@ -297,6 +297,7 @@ def __init__(self, name, plugin, action): help='Execute your action in parallel using a ' 'config at the indicated path.')]) + # TODO: update default cache name from qiime2 to rachis self._misc.extend([ q2cli.util.example_data_option( self._get_plugin, self.action['id']), @@ -529,7 +530,7 @@ def __call__(self, **kwargs): if not verbose: import tempfile - log = tempfile.NamedTemporaryFile(prefix='qiime2-q2cli-err-', + log = tempfile.NamedTemporaryFile(prefix='rachis-cli-err-', suffix='.log', delete=False, mode='w') if action.deprecated: diff --git a/q2cli/core/artifact_cache_global.py b/q2cli/core/artifact_cache_global.py index 0ee04496..8044fb87 100644 --- a/q2cli/core/artifact_cache_global.py +++ b/q2cli/core/artifact_cache_global.py @@ -21,7 +21,7 @@ def set_used_artifact_cache(args): Parameters ---------- args : List[str] - The arguments provided on the cli to this QIIME 2 invocation/ + The arguments provided on the cli to this rachis invocation/ NOTES ----- diff --git a/q2cli/core/assets/cli_howto.txt b/q2cli/core/assets/cli_howto.txt index cf1ee770..17849d92 100644 --- a/q2cli/core/assets/cli_howto.txt +++ b/q2cli/core/assets/cli_howto.txt @@ -13,7 +13,7 @@ # If your data is not identical to that in the replayed analysis, # changes may be required. (e.g. sample ids or rarefaction depth) # 6. Optional: replace any filenames in this script that begin with 'XX' with -# unique file names to ensure they are preserved. QIIME 2 saves all outputs +# unique file names to ensure they are preserved. rachis saves all outputs # from all actions in this script to disk regardless of whether those # outputs were in the original collection of replayed results. The filenames # of "un-replayed" artifacts are prefixed with 'XX' so they may be easily @@ -25,4 +25,4 @@ # into the terminal for a more interactive analysis. # 9. Optional: to delete all results not required to produce the figures and # data used to generate this script, navigate to the directory in which you -# ran the script and `rm XX*.qz*` \ No newline at end of file +# ran the script and `rm XX*.qz*` diff --git a/q2cli/core/assets/copyright_note.txt b/q2cli/core/assets/copyright_note.txt index 2c8ee307..603cdd23 100644 --- a/q2cli/core/assets/copyright_note.txt +++ b/q2cli/core/assets/copyright_note.txt @@ -1,4 +1,4 @@ # This document is a representation of the scholarly work of the creator of the -# QIIME 2 Results provided as input to this software, and may be protected by +# rachis Results provided as input to this software, and may be protected by # intellectual property law. Please respect all copyright restrictions and # licenses governing the use, modification, and redistribution of this work. diff --git a/q2cli/core/cache.py b/q2cli/core/cache.py index b9ee0547..a119501d 100644 --- a/q2cli/core/cache.py +++ b/q2cli/core/cache.py @@ -8,18 +8,19 @@ class DeploymentCache: - """Cached CLI state for a QIIME deployment. + """Cached CLI state for a rachis deployment. - In this context, a QIIME deployment is the set of installed Python - packages, including their exact versions, that register one or more QIIME 2 - plugins. The exact version of q2cli is also included in the deployment. + In this context, a rachis deployment is the set of installed Python + packages, including their exact versions, that register one or more rachis + plugins. The exact version of rachis-cli is also included in the + deployment. The deployment cache stores the current deployment's package names and versions in a requirements.txt file under the cache directory. This file is used to determine if the cache is outdated. If the cache is determined to be outdated, it will be refreshed based on the current deployment state. - Thus, adding, removing, upgrading, or downgrading a plugin package or q2cli - itself will trigger a cache refresh. + Thus, adding, removing, upgrading, or downgrading a plugin package or + rachis-cli itself will trigger a cache refresh. Two mechanisms are provided to force a cache refresh. Setting the environment variable Q2CLIDEV to any value will cause the cache to be @@ -29,9 +30,10 @@ class DeploymentCache: changing their package versions. Cached CLI state is stored in a state.json file under the cache directory. - It is not a public file format and it is not versioned. q2cli is included - as part of the QIIME deployment so that the cached state can always be read - (or recreated as necessary) by the currently installed version of q2cli. + It is not a public file format and it is not versioned. rachis-cli is + included as part of the rachis deployment so that the cached state can + always be read (or recreated as necessary) by the currently installed + version of rachis-cli. This class is intended to be a singleton because it is responsible for managing the on-disk cache. Having more than one instance managing the @@ -57,6 +59,7 @@ def __init__(self): self._cache_dir = self._get_cache_dir() + # TODO: update Q2CLIDEV to RACHISCLIDEV refresh = 'Q2CLIDEV' in os.environ self._state = self._get_cached_state(refresh=refresh) @@ -131,7 +134,7 @@ def decoder(obj): # `_get_cached_state`. def _get_current_requirements(self): - """Includes installed versions of q2cli and QIIME 2 plugins.""" + """Includes installed versions of q2cli and rachis plugins.""" import os import importlib.metadata import q2cli @@ -188,7 +191,7 @@ def _cache_current_state(self, requirements): import q2cli.util click.secho( - "QIIME is caching your current deployment for improved " + "rachis is caching your current deployment for improved " "performance. This may take a few moments and should only happen " "once per deployment.", fg='yellow', err=True) diff --git a/q2cli/core/completion.py b/q2cli/core/completion.py index 243d4726..7d68277e 100644 --- a/q2cli/core/completion.py +++ b/q2cli/core/completion.py @@ -118,7 +118,7 @@ def _generate_command_reply(cmd): COMPLETION_SCRIPT_TEMPLATE = """\ #!/usr/bin/env bash -_qiime_completion() +_rachis_completion() {{ local COMP_WORDS=(${{COMP_WORDS[*]}}) local incomplete @@ -137,7 +137,7 @@ def _generate_command_reply(cmd): return 0 }} -_qiime_completion +_rachis_completion """ COMMAND_REPLY_TEMPLATE = """\ diff --git a/q2cli/core/usage.py b/q2cli/core/usage.py index d08503ab..b9c18df1 100644 --- a/q2cli/core/usage.py +++ b/q2cli/core/usage.py @@ -93,7 +93,7 @@ def to_interface_name(self): cli_name = '%s%s' % (self.name, self.ext) - # don't disturb file names, this will break importing where QIIME 2 + # don't disturb file names, this will break importing where rachis # relies on specific filenames being present in a dir if self.var_type not in ('format', 'column'): cli_name = self.to_cli_name(cli_name) @@ -113,7 +113,7 @@ def assert_has_line_matching(self, path, expression, key=None): input_path = self._key_helper(input_path, key) lines = [ - 'qiime dev assert-result-data %s \\' % (input_path,), + 'rachis dev assert-result-data %s \\' % (input_path,), INDENT + '--zip-data-path %s \\' % (path,), INDENT + '--expression %s' % (expr,), ] @@ -130,8 +130,9 @@ def assert_output_type(self, semantic_type, key=None): if key: input_path = self._key_helper(input_path, key) + # TODO: update qiime-type to rachis-type ? lines = [ - 'qiime dev assert-result-type %s \\' % (input_path,), + 'rachis dev assert-result-type %s \\' % (input_path,), INDENT + '--qiime-type %s' % (str(semantic_type),), ] @@ -231,7 +232,7 @@ def import_from_format(self, name, semantic_type, out_fp = imported_var.to_interface_name() lines = [ - 'qiime tools import \\', + 'rachis tools import \\', self.INDENT + '--type %r \\' % (semantic_type,) ] @@ -271,7 +272,7 @@ def comment(self, text): def peek(self, variable): var_name = variable.to_interface_name() - self.recorder.append('qiime tools peek %s' % var_name) + self.recorder.append('rachis tools peek %s' % var_name) def merge_metadata(self, name, *variables): var = super().merge_metadata(name, *variables) @@ -305,7 +306,7 @@ def action(self, action, inputs, outputs): plugin_name = util.to_cli_name(action.plugin_id) action_name = util.to_cli_name(action.action_id) - self.recorder.append('qiime %s %s \\' % (plugin_name, action_name)) + self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) action_f = action.get_action() action_state = get_action_state(action_f) @@ -430,7 +431,7 @@ def to_interface_name(self): cli_name = '%s%s' % (self.name, self.ext) - # don't disturb file names, this will break importing where QIIME 2 + # don't disturb file names, this will break importing where rachis # relies on specific filenames being present in a dir if self.var_type not in ('format', 'column', 'metadata'): cli_name = self.to_cli_name(cli_name) @@ -504,7 +505,7 @@ def _append_action_line(self, signature, param_name: str, value): else: # no matching param name line = self.INDENT + ( '# FIXME: The following parameter name was not found in ' - 'your current\n # QIIME 2 environment. This may occur ' + 'your current\n # rachis environment. This may occur ' 'when the plugin version you have\n # installed does not ' 'match the version used in the original analysis.\n # ' 'Please see the docs and correct the parameter name ' @@ -579,7 +580,7 @@ def import_from_format( out_fp = imported_var.to_interface_name() lines = [ - 'qiime tools import \\', + 'rachis tools import \\', self.INDENT + '--type %r \\' % (semantic_type,) ] @@ -680,7 +681,7 @@ def _action_found( inputs: UsageInputs, outputs: UsageOutputs ): - self.recorder.append('qiime %s %s \\' % (plugin_name, action_name)) + self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) variables = Usage.action(self, action, inputs, outputs) vars_dict = variables._asdict() @@ -736,10 +737,10 @@ def _action_not_found( self.recorder.append( '# FIXME: The following action was not found in your current ' - 'QIIME 2\n# environment. Please ensure the action and its ' + 'rachis\n# environment. Please ensure the action and its ' 'parameters are correct before\n# running.' ) - self.recorder.append('qiime %s %s \\' % (plugin_name, action_name)) + self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) for param_name, value in ins.items(): self._append_unknown_param(param_name, value) diff --git a/q2cli/tests/test_cache_cli.py b/q2cli/tests/test_cache_cli.py index 3f0bcd53..a426e1c0 100644 --- a/q2cli/tests/test_cache_cli.py +++ b/q2cli/tests/test_cache_cli.py @@ -47,7 +47,7 @@ def setUp(self): self.runner = CliRunner() self.plugin_command = RootCommand().get_command( ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.cache = Cache(os.path.join(self.tempdir, 'new_cache')) self.art1 = Artifact.import_data(IntSequence1, [0, 1, 2]) diff --git a/q2cli/tests/test_cli.py b/q2cli/tests/test_cli.py index fd094849..9d06c0e7 100644 --- a/q2cli/tests/test_cli.py +++ b/q2cli/tests/test_cli.py @@ -51,7 +51,7 @@ class = "_TEST_EXECUTOR_" # at the end of a bunch of lines for no apparent reason are preserved here CONFIG_LEVEL_3 = "{'parsl': Config(" -EXPECTED_CITATIONS = """% use `qiime tools citations` on a QIIME 2 result for complete list +EXPECTED_CITATIONS = """% use `rachis tools citations` on a rachis result for complete list @article{key0, author = {Unger, Donald L}, @@ -127,7 +127,7 @@ class CliTests(unittest.TestCase): def setUp(self): get_dummy_plugin() self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.artifact1_path = os.path.join(self.tempdir, 'a1.qza') self.mapping_path = os.path.join(self.tempdir, 'mapping.qza') @@ -205,8 +205,8 @@ def test_info_level_3(self): def test_list_commands(self): # top level commands, including a plugin, are present - qiime_cli = RootCommand() - commands = qiime_cli.list_commands(ctx=None) + rachis_cli = RootCommand() + commands = rachis_cli.list_commands(ctx=None) self.assertIn('info', commands) self.assertIn('tools', commands) self.assertIn('dummy-plugin', commands) @@ -214,8 +214,8 @@ def test_list_commands(self): def test_plugin_list_commands(self): # plugin commands are present including a method and visualizer - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') commands = command.list_commands(ctx=None) self.assertIn('split-ints', commands) self.assertIn('mapping-viz', commands) @@ -228,8 +228,8 @@ def test_plugin_list_commands(self): def test_plugin_list_hidden_commands(self): # plugin commands are present including a method and visualizer and # hidden method - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') commands = self.runner.invoke(command, ['--show-hidden-actions']).output self.assertIn('split-ints', commands) @@ -242,8 +242,8 @@ def test_plugin_list_hidden_commands(self): self.assertNotIn('-underscore-method', commands) def test_action_parameter_types(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') results = self.runner.invoke(command, ['typical-pipeline', '--help']) help_text = results.output @@ -302,8 +302,8 @@ def test_higher_union_bool_typemap_parameter_is_flag(self): def test_execute_hidden_action(self): int_path = os.path.join(self.tempdir, 'int.qza') - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') result = self.runner.invoke( command, ['_underscore-method', '--o-int', int_path, '--verbose']) @@ -341,8 +341,8 @@ def test_validate_max(self): self.assertIn('appears to be valid at level=max', result.output) def test_split_ints(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') # build output file names left_path = os.path.join(self.tempdir, 'left.qza') @@ -368,8 +368,8 @@ def test_split_ints(self): self.assertEqual(right.view(list), [42, 43]) def test_variadic_inputs(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') output_path = os.path.join(self.tempdir, 'output.qza') ints1 = Artifact.import_data('IntSequence1', [1, 2, 3]).save( @@ -395,8 +395,8 @@ def test_variadic_inputs(self): self.assertEqual(output.view(list), list(range(1, 14))) def test_with_parameters_only(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') output_path = os.path.join(self.tempdir, 'output.qza') result = self.runner.invoke( @@ -410,8 +410,8 @@ def test_with_parameters_only(self): self.assertEqual(artifact.view(dict), {'Peanut': '42'}) def test_without_inputs_or_parameters(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') output_path = os.path.join(self.tempdir, 'output.qza') result = self.runner.invoke( @@ -424,8 +424,8 @@ def test_without_inputs_or_parameters(self): self.assertEqual(artifact.view(dict), {'foo': '42'}) def test_qza_extension(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') # build output parameter arguments and expected output file names left_path = os.path.join(self.tempdir, 'left') @@ -449,8 +449,8 @@ def test_qza_extension(self): self.assertEqual(right.view(list), [42, 43]) def test_qzv_extension(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') # build output parameter arguments and expected output file names viz_path = os.path.join(self.tempdir, 'viz') expected_viz_path = os.path.join(self.tempdir, 'viz.qzv') @@ -468,8 +468,8 @@ def test_qzv_extension(self): 'tsv': 'data/index.tsv'}) def test_verbose_shows_stacktrace(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') output = os.path.join(self.tempdir, 'never-happens.qza') result = self.runner.invoke( @@ -501,8 +501,8 @@ def test_input_conversion(self): obj._convert_input(self.artifact1_path, None, None) def test_syntax_error_in_env(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') viz_path = os.path.join(self.tempdir, 'viz') @@ -517,8 +517,8 @@ def test_syntax_error_in_env(self): self.assertIn(self.artifact1_path, result.output) def test_deprecated_help_text(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') result = self.runner.invoke(command, ['deprecated-method', '--help']) @@ -527,8 +527,8 @@ def test_deprecated_help_text(self): self.assertIn('deprecated', result.output) def test_run_deprecated_gets_warning_msg(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') output_path = os.path.join(self.tempdir, 'output.qza') result = self.runner.invoke( @@ -546,8 +546,8 @@ def test_run_deprecated_gets_warning_msg(self): self.assertIn('deprecated', result.output) def test_get_citations(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') result = self.runner.invoke(command, ['split-ints', '--citations']) self.assertEqual(result.exit_code, 0) @@ -561,7 +561,8 @@ def test_rachis_warning_is_always_visible(self): ''' with tempfile.TemporaryDirectory() as tempdir: base_command = [ - 'qiime', 'dummy-plugin', 'raises-rachis-warning', '--o-output', + 'rachis', 'dummy-plugin', 'raises-rachis-warning', + '--o-output', Path(tempdir) / 'output.qza' ] @@ -591,7 +592,7 @@ def setUp(self): self.runner = CliRunner() self.plugin_command = \ RootCommand().get_command(ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='qiime2-cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.output_path = os.path.join(self.tempdir, 'output.qza') def tearDown(self): @@ -770,7 +771,7 @@ def setUp(self): self.runner = CliRunner() self.plugin_command = RootCommand().get_command( ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.ints1 = os.path.join(self.tempdir, 'ints1.qza') Artifact.import_data( @@ -836,7 +837,7 @@ def setUp(self): self.runner = CliRunner() self.plugin_command = RootCommand().get_command( ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.input_artifact = os.path.join(self.tempdir, 'in.qza') Artifact.import_data( @@ -1143,7 +1144,7 @@ def setUp(self): self.runner = CliRunner() self.plugin_command = RootCommand().get_command( ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.art1_path = os.path.join(self.tempdir, 'art1.qza') self.art2_path = os.path.join(self.tempdir, 'art2.qza') diff --git a/q2cli/tests/test_core.py b/q2cli/tests/test_core.py index b4e1a198..3828f625 100644 --- a/q2cli/tests/test_core.py +++ b/q2cli/tests/test_core.py @@ -44,7 +44,7 @@ class TestOption(unittest.TestCase): def setUp(self): get_dummy_plugin() self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.parser = configparser.ConfigParser() self.path = os.path.join(q2cli.util.get_app_dir(), 'cli-colors.theme') @@ -73,8 +73,8 @@ def test_repeated_builtin_flag(self): self._assertRepeatedOptionError(result, '--input-path') def test_repeated_action_flag(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') out_path = os.path.join(self.tempdir, 'out.qza') result = self.runner.invoke( @@ -98,8 +98,8 @@ def test_repeated_builtin_option(self): self._assertRepeatedOptionError(result, '--type') def test_repeated_action_option(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') out_path = os.path.join(self.tempdir, 'out.qza') result = self.runner.invoke( @@ -122,8 +122,8 @@ def test_repeated_multiple_option(self): output_path = os.path.join(self.tempdir, 'out.qza') - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') result = self.runner.invoke( command, ['identity-with-metadata', '--i-ints', input_path, @@ -298,7 +298,7 @@ def test_build_import_usage_cli(self): self.assertIsInstance(usg_var, UsageVariable) self.assertEqual(usg_var.var_type, 'artifact') self.assertEqual(usg_var.name, unq_var_nm) - self.assertRegex(rendered, r'qiime tools import \\') + self.assertRegex(rendered, r'rachis tools import \\') self.assertRegex(rendered, f" --type '{import_node.type}'") self.assertRegex(rendered, " --input-path ") self.assertRegex(rendered, f" --output-path {out_name}") @@ -341,7 +341,7 @@ def test_build_action_usage_cli(self): self.assertEqual(usg_var.var_type, 'artifact') self.assertEqual(usg_var.name, unique_var_name) - self.assertIn(f'qiime {plugin} {action}', rendered) + self.assertIn(f'rachis {plugin} {action}', rendered) self.assertIn('--i-ints1 imported-ints-0.qza', rendered) self.assertIn('--i-ints3 imported-ints-1.qza', rendered) self.assertIn('--p-int1 7', rendered) diff --git a/q2cli/tests/test_dev.py b/q2cli/tests/test_dev.py index 2249d828..220563f3 100644 --- a/q2cli/tests/test_dev.py +++ b/q2cli/tests/test_dev.py @@ -32,10 +32,10 @@ def setUp(self): get_dummy_plugin() self.parser = configparser.ConfigParser() self.runner = CliRunner(mix_stderr=False) - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.generated_config = os.path.join(self.tempdir, 'generated-theme') - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.artifact1_path = os.path.join(self.tempdir, 'a1.qza') self.mapping_path = os.path.join(self.tempdir, 'mapping.qza') @@ -123,7 +123,7 @@ def test_assert_result_type_load_failure(self): self.assertRegex(result.stderr, r'Path\s*\'turkey_sandwhere\.qza\'\s*does not exist') - def test_assert_result_type_invalid_qiime_type(self): + def test_assert_result_type_invalid_rachis_type(self): result = self.runner.invoke(dev, ['assert-result-type', self.mapping_path, diff --git a/q2cli/tests/test_tools.py b/q2cli/tests/test_tools.py index 4e929cf2..33fbb183 100644 --- a/q2cli/tests/test_tools.py +++ b/q2cli/tests/test_tools.py @@ -37,7 +37,7 @@ class TestCastMetadata(unittest.TestCase): def setUp(self): self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.metadata_file = os.path.join( self.tempdir, 'metadata.tsv') @@ -151,7 +151,7 @@ def setUp(self): dummy_plugin = get_dummy_plugin() self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.metadata_file_mixed_types = os.path.join( self.tempdir, 'metadata-mixed-types.tsv') @@ -198,13 +198,13 @@ def test_artifact_no_metadata(self): result = self.runner.invoke(tools, ['inspect-metadata', self.ints1]) self.assertEqual(result.exit_code, 1) - self.assertIn("IntSequence1 cannot be viewed as QIIME 2 metadata", + self.assertIn("IntSequence1 cannot be viewed as rachis metadata", result.output) def test_visualization(self): # make a viz first: - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') # build output parameter arguments and expected output file names viz_path = os.path.join(self.tempdir, 'viz.qzv') result = self.runner.invoke( @@ -214,7 +214,7 @@ def test_visualization(self): result = self.runner.invoke(tools, ['inspect-metadata', viz_path]) self.assertEqual(result.exit_code, 1) - self.assertIn("Visualizations cannot be viewed as QIIME 2 metadata", + self.assertIn("Visualizations cannot be viewed as rachis metadata", result.output) def test_metadata_file(self): @@ -463,7 +463,7 @@ class TestImport(unittest.TestCase): def setUpClass(cls): cls.runner = CliRunner() - cls.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + cls.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') cls.in_dir1 = os.path.join(cls.tempdir, 'input1') os.mkdir(cls.in_dir1) @@ -550,7 +550,7 @@ def setUp(self): self.plugin_command = RootCommand().get_command( ctx=None, name='dummy-plugin') self.tempdir = \ - tempfile.TemporaryDirectory(prefix='qiime2-q2cli-test-temp-') + tempfile.TemporaryDirectory(prefix='rachis-cli-test-temp-') self.art1 = Artifact.import_data('IntSequence1', [0, 1, 2]) self.art2 = Artifact.import_data('IntSequence1', [3, 4, 5]) @@ -792,7 +792,7 @@ def _get_cache_contents(cache): class TestPeek(unittest.TestCase): def setUp(self): self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') # create artifact self.artifact = os.path.join(self.tempdir, 'artifact.qza') @@ -800,8 +800,8 @@ def setUp(self): 'Mapping', {'foo': 'bar'}).save(self.artifact) # create visualization - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') self.viz = os.path.join(self.tempdir, 'viz.qzv') self.ints = os.path.join(self.tempdir, 'ints.qza') @@ -1007,7 +1007,7 @@ def setUp(self): self.runner = CliRunner() self.pm = PluginManager() self.dp = self.pm.plugins['dummy-plugin'] - self.tempdir = tempfile.mkdtemp(prefix='q2cli-test-replay-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-replay-temp-') # contrive artifacts with different sorts of provenance int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) @@ -1026,7 +1026,7 @@ def setUp(self): left_ints, _ = self.dp.actions['split_ints'](int_seq) left_ints.save(os.path.join(inner_dir, 'left_ints.qza')) - mapping = Artifact.import_data('Mapping', {'qiime': 2, 'triangle': 3}) + mapping = Artifact.import_data('Mapping', {'rachis': 2, 'triangle': 3}) int_seq_with_md, = self.dp.actions['identity_with_metadata']( int_seq1, mapping.view(Metadata)) @@ -1058,7 +1058,7 @@ def test_replay_provenance(self): with open(out_fp, 'r') as fh: rendered = fh.read() - self.assertIn('qiime tools import', rendered) + self.assertIn('rachis tools import', rendered) self.assertIn('--type \'IntSequence1\'', rendered) self.assertIn('--type \'IntSequence2\'', rendered) self.assertIn('--input-path ', rendered) @@ -1066,7 +1066,7 @@ def test_replay_provenance(self): self.assertIn('--output-path int-sequence1-1.qza', rendered) self.assertIn('--output-path int-sequence2-0.qza', rendered) - self.assertIn('qiime dummy-plugin concatenate-ints', rendered) + self.assertIn('rachis dummy-plugin concatenate-ints', rendered) self.assertRegex(rendered, '--i-ints[12] int-sequence1-0.qza') self.assertRegex(rendered, '--i-ints[12] int-sequence1-1.qza') self.assertIn('--i-ints3 int-sequence2-0.qza', rendered) @@ -1316,8 +1316,8 @@ def test_replay_supplement_cache(self): # Leave me alone I know the checksums don't match @pytest.mark.filterwarnings('ignore::UserWarning') def test_replay_param_not_found(self): - qiime_cli = RootCommand() - command = qiime_cli.get_command(ctx=None, name='dummy-plugin') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') in_fp = os.path.join(self.tempdir, 'concated_ints.qza') left_path = os.path.join(self.tempdir, 'left.qza') @@ -1356,7 +1356,7 @@ def test_replay_param_not_found(self): MISSING_PARAM = \ """ # FIXME: The following parameter name was not found in your current - # QIIME 2 environment. This may occur when the plugin version you have + # rachis environment. This may occur when the plugin version you have # installed does not match the version used in the original analysis. # Please see the docs and correct the parameter name before running. --?-not real \\ @@ -1383,7 +1383,7 @@ def test_replay_action_not_found(self): import1 = \ """ -qiime tools import \\ +rachis tools import \\ --type 'Phylogeny[Rooted]' \\ --input-path \\ --output-path phylogeny-rooted-0.qza @@ -1391,7 +1391,7 @@ def test_replay_action_not_found(self): import2 = \ """ -qiime tools import \\ +rachis tools import \\ --type 'FeatureTable[Frequency]' \\ --input-path \\ --output-path feature-table-frequency-0.qza @@ -1402,10 +1402,10 @@ def test_replay_action_not_found(self): FIXME_action = \ """ -# FIXME: The following action was not found in your current QIIME 2 +# FIXME: The following action was not found in your current rachis # environment. Please ensure the action and its parameters are correct before # running. -qiime diversity core-metrics-phylogenetic \\ +rachis diversity core-metrics-phylogenetic \\ --?-table feature-table-frequency-0.qza \\ --?-phylogeny phylogeny-rooted-0.qza \\ --?-sampling-depth 13 \\ @@ -1421,7 +1421,7 @@ def test_replay_action_not_found(self): class TestAnnotations(unittest.TestCase): def setUp(self): self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') # artifact without any starting annotations self.art1 = os.path.join(self.tempdir, 'ints1.qza') @@ -1767,7 +1767,7 @@ def setUp(self): dummy_plugin = get_dummy_plugin() self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.cache = Cache(os.path.join(self.tempdir, 'cache')) ints1 = Artifact.import_data( @@ -1928,7 +1928,7 @@ def setUp(self): dummy_plugin = get_dummy_plugin() self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') self.cache = Cache(os.path.join(self.tempdir, 'cache')) ints1 = Artifact.import_data( @@ -1999,7 +1999,7 @@ def setUp(self): # ensure dummy plugin is registered self.dp = get_dummy_plugin() self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') # create an artifact to drive visualizations self.ints = Artifact.import_data('IntSequence1', [0, 1, 2]) @@ -2050,7 +2050,7 @@ def test_make_report_duplicate_names(self): class TestRedactMetadata(unittest.TestCase): @classmethod def setUpClass(cls): - cls.tempdir = tempfile.mkdtemp(prefix='qiime2-q2cli-test-temp-') + cls.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') metadata_path = os.path.join(cls.tempdir, 'metadata.tsv') with open(metadata_path, 'w') as fh: diff --git a/q2cli/tests/test_usage.py b/q2cli/tests/test_usage.py index be384be3..1f549451 100644 --- a/q2cli/tests/test_usage.py +++ b/q2cli/tests/test_usage.py @@ -35,7 +35,7 @@ def get_templated_tests(): ('concatenate_ints', """\ # This example demonstrates basic usage. -qiime dummy-plugin concatenate-ints \\ +rachis dummy-plugin concatenate-ints \\ --i-ints1 ints-a.qza \\ --i-ints2 ints-b.qza \\ --i-ints3 ints-c.qza \\ @@ -43,7 +43,7 @@ def get_templated_tests(): --p-int2 2 \\ --o-concatenated-ints ints-d.qza # This example demonstrates chained usage (pt 1). -qiime dummy-plugin concatenate-ints \\ +rachis dummy-plugin concatenate-ints \\ --i-ints1 ints-a.qza \\ --i-ints2 ints-b.qza \\ --i-ints3 ints-c.qza \\ @@ -51,7 +51,7 @@ def get_templated_tests(): --p-int2 2 \\ --o-concatenated-ints ints-d.qza # This example demonstrates chained usage (pt 2). -qiime dummy-plugin concatenate-ints \\ +rachis dummy-plugin concatenate-ints \\ --i-ints1 ints-d.qza \\ --i-ints2 ints-b.qza \\ --i-ints3 ints-c.qza \\ @@ -64,24 +64,24 @@ def get_templated_tests(): # comment 2"""), ('identity_with_metadata', """\ -qiime dummy-plugin identity-with-metadata \\ +rachis dummy-plugin identity-with-metadata \\ --i-ints ints.qza \\ --m-metadata-file md.tsv \\ --o-out out.qza -qiime dummy-plugin identity-with-metadata \\ +rachis dummy-plugin identity-with-metadata \\ --i-ints ints.qza \\ --m-metadata-file md1.tsv md2.tsv \\ --o-out out.qza"""), ('identity_with_metadata_column', """\ -qiime dummy-plugin identity-with-metadata-column \\ +rachis dummy-plugin identity-with-metadata-column \\ --i-ints ints.qza \\ --m-metadata-file md.tsv \\ --m-metadata-column a \\ --o-out out.qza"""), ('typical_pipeline', """\ -qiime dummy-plugin typical-pipeline \\ +rachis dummy-plugin typical-pipeline \\ --i-int-sequence ints.qza \\ --i-mapping mapper.qza \\ --p-do-extra-thing \\ @@ -90,7 +90,7 @@ def get_templated_tests(): --o-right right.qza \\ --o-left-viz left-viz.qzv \\ --o-right-viz right-viz.qzv -qiime dummy-plugin typical-pipeline \\ +rachis dummy-plugin typical-pipeline \\ --i-int-sequence ints1.qza \\ --i-mapping mapper1.qza \\ --p-do-extra-thing \\ @@ -99,7 +99,7 @@ def get_templated_tests(): --o-right right1.qza \\ --o-left-viz left-viz1.qzv \\ --o-right-viz right-viz1.qzv -qiime dummy-plugin typical-pipeline \\ +rachis dummy-plugin typical-pipeline \\ --i-int-sequence left1.qza \\ --i-mapping out-map1.qza \\ --p-no-do-extra-thing \\ @@ -108,29 +108,29 @@ def get_templated_tests(): --o-right right2.qza \\ --o-left-viz left-viz2.qzv \\ --o-right-viz right-viz2.qzv -qiime dev assert-result-data right2.qza \\ +rachis dev assert-result-data right2.qza \\ --zip-data-path ints.txt \\ --expression 1 -qiime dev assert-result-type right2.qza \\ +rachis dev assert-result-type right2.qza \\ --qiime-type IntSequence1 -qiime dev assert-result-type out-map1.qza \\ +rachis dev assert-result-type out-map1.qza \\ --qiime-type Mapping"""), ('optional_artifacts_method', """\ -qiime dummy-plugin optional-artifacts-method \\ +rachis dummy-plugin optional-artifacts-method \\ --i-ints ints.qza \\ --p-num1 1 \\ --o-output output1.qza -qiime dummy-plugin optional-artifacts-method \\ +rachis dummy-plugin optional-artifacts-method \\ --i-ints ints.qza \\ --p-num1 1 \\ --p-num2 2 \\ --o-output output2.qza -qiime dummy-plugin optional-artifacts-method \\ +rachis dummy-plugin optional-artifacts-method \\ --i-ints ints.qza \\ --p-num1 1 \\ --o-output output3.qza -qiime dummy-plugin optional-artifacts-method \\ +rachis dummy-plugin optional-artifacts-method \\ --i-ints ints.qza \\ --i-optional1 output3.qza \\ --p-num1 3 \\ @@ -138,24 +138,24 @@ def get_templated_tests(): --o-output output4.qza"""), ('variadic_input_method', """\ -qiime dummy-plugin variadic-input-method \\ +rachis dummy-plugin variadic-input-method \\ --i-ints ints-a.qza ints-b.qza \\ --i-int-set single-int1.qza single-int2.qza \\ --p-nums 7 8 9 \\ --o-output out.qza"""), ('list_of_ints', """\ -qiime dummy-plugin list-of-ints \\ +rachis dummy-plugin list-of-ints \\ --i-ints ints/ \\ --o-output out/"""), ('dict_of_ints', """\ -qiime dummy-plugin dict-of-ints \\ +rachis dummy-plugin dict-of-ints \\ --i-ints ints/ \\ --o-output out/ -qiime dev assert-result-type out/Foo.qza \\ +rachis dev assert-result-type out/Foo.qza \\ --qiime-type SingleInt -qiime dev assert-result-data out/Foo.qza \\ +rachis dev assert-result-data out/Foo.qza \\ --zip-data-path file1.txt \\ --expression 1 ## constructing result collection ## @@ -165,7 +165,7 @@ def get_templated_tests(): names=( ints-a.qza ints-b.qza ) construct_result_collection ## -qiime dummy-plugin dict-of-ints \\ +rachis dummy-plugin dict-of-ints \\ --i-ints rc-in/ \\ --o-output rc-out/ ## accessing result collection member ## @@ -173,10 +173,10 @@ def get_templated_tests(): ##"""), ('viz_collection_pipeline', """\ -qiime dummy-plugin viz-collection-pipeline \\ +rachis dummy-plugin viz-collection-pipeline \\ --i-ints ints.qza \\ --o-visualizations visualizations/ -qiime dev assert-result-type visualizations/ \\ +rachis dev assert-result-type visualizations/ \\ --qiime-type Collection[Visualization]""") ] @@ -254,7 +254,7 @@ def test_construct_and_access_collection(self): names=( ints-a.qza ints-b.qza ) construct_result_collection ## -qiime dummy-plugin dict-of-ints \\ +rachis dummy-plugin dict-of-ints \\ --i-ints rc-in/ \\ --o-output rc-out/ ## accessing result collection member ## diff --git a/q2cli/util.py b/q2cli/util.py index 6d96d8e6..1122b40d 100644 --- a/q2cli/util.py +++ b/q2cli/util.py @@ -222,7 +222,7 @@ def callback(ctx, param, value): citations = qiime2.sdk.Citations( [('key%d' % i, r) for i, r in enumerate(records)]) with io.StringIO() as fh: - fh.write('% use `qiime tools citations` on a QIIME 2 result' + fh.write('% use `rachis tools citations` on a rachis result' ' for complete list\n\n') citations.save(fh) click.echo(fh.getvalue(), nl=False) @@ -327,18 +327,18 @@ def _load_metadata_artifact(fp): default_tb = None if isinstance(artifact, qiime2.Visualization): raise Exception( - 'Visualizations cannot be viewed as QIIME 2 metadata.') + 'Visualizations cannot be viewed as rachis metadata.') if not artifact.has_metadata(): raise Exception( f"Artifacts with type {artifact.type!r} cannot be viewed" - " as QIIME 2 metadata.") + " as rachis metadata.") default_tb = 'stderr' return artifact.view(qiime2.Metadata), None except Exception as e: header = ("There was an issue with viewing the artifact " - f"{fp!r} as QIIME 2 Metadata:") + f"{fp!r} as rachis Metadata:") tb = 'stderr' if '--verbose' in sys.argv else default_tb exit_with_error(e, header=header, traceback=tb) @@ -348,7 +348,7 @@ def _load_metadata_artifact(fp): def _load_input(fp, view=False): # Just initialize the plugin manager. This is slow and not necessary if we - # called this from qiime tools view. + # called this from rachis tools view. import os key = None @@ -481,7 +481,7 @@ def _load_input_file(fp): except Exception as error: if isinstance(error, SyntaxError): raise error - # ideally ValueError: X is not a QIIME archive. + # ideally ValueError: X is not a rachis archive. # but sometimes SyntaxError or worse return None, error From 9ffc91caeafd228ab5b3d8117dbd189474c84e4f Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Mon, 31 Aug 2026 15:04:54 +0200 Subject: [PATCH 02/10] update completion script with rachis & qiime, q2cli -> rachis-cli in info cmd --- bin/tab-rachis | 4 ++-- q2cli/builtin/info.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/tab-rachis b/bin/tab-rachis index 57acc6e3..17a8f075 100644 --- a/bin/tab-rachis +++ b/bin/tab-rachis @@ -41,7 +41,7 @@ _rachis_completion() # Enable default readline and bash completion behavior when `_rachis_completion` # doesn't have a reply. -complete -F _rachis_completion -o default -o bashdefault ${usr_cmd} +complete -F _rachis_completion -o default -o bashdefault rachis qiime # Execute a `rachis` command (any command will do) so that tab-completion will # work out-of-the-box (e.g. with a fresh installation of rachis-cli). Running a @@ -51,4 +51,4 @@ complete -F _rachis_completion -o default -o bashdefault ${usr_cmd} # Ignore stdout to avoid displaying help text to users enabling tab-completion. # stderr displays the note about cache refreshing, as that can take a few # moments to complete. -${usr_cmd} &> /dev/null +rachis &> /dev/null diff --git a/q2cli/builtin/info.py b/q2cli/builtin/info.py index a7dfe711..4791e598 100644 --- a/q2cli/builtin/info.py +++ b/q2cli/builtin/info.py @@ -22,7 +22,7 @@ def _echo_version(): click.echo('Parsl version: %s' % _get_parsl_ver()) click.echo('rachis release: %s' % qiime2.__release__) click.echo('rachis version: %s' % qiime2.__version__) - click.echo('q2cli version: %s' % q2cli.__version__) + click.echo('rachis-cli version: %s' % q2cli.__version__) def _get_parsl_ver(): From c8b950caad0600f7589820674583dcf261240f42 Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Mon, 31 Aug 2026 16:54:41 +0200 Subject: [PATCH 03/10] rename package --- rachis_cli/__init__.py | 13 + rachis_cli/__main__.py | 48 + rachis_cli/builtin/__init__.py | 7 + rachis_cli/builtin/dev.py | 241 ++ rachis_cli/builtin/info.py | 123 + rachis_cli/builtin/tools.py | 1805 ++++++++++++++ rachis_cli/click/__init__.py | 7 + rachis_cli/click/command.py | 365 +++ rachis_cli/click/licenses/click.LICENSE.rst | 40 + rachis_cli/click/option.py | 350 +++ rachis_cli/click/parser.py | 162 ++ rachis_cli/click/type.py | 194 ++ rachis_cli/commands.py | 676 ++++++ rachis_cli/core/__init__.py | 7 + rachis_cli/core/artifact_cache_global.py | 81 + rachis_cli/core/assets/cli_howto.txt | 28 + rachis_cli/core/assets/copyright_note.txt | 4 + rachis_cli/core/cache.py | 279 +++ rachis_cli/core/completion.py | 170 ++ rachis_cli/core/config.py | 120 + rachis_cli/core/state.py | 182 ++ rachis_cli/core/usage.py | 820 +++++++ rachis_cli/tests/__init__.py | 7 + rachis_cli/tests/data/concatenated_ints.qza | Bin 0 -> 19908 bytes rachis_cli/tests/data/mapping_config.toml | 15 + .../data/parse_dir_test/inner/right_ints.qza | Bin 0 -> 14035 bytes .../tests/data/parse_dir_test/left_ints.qza | Bin 0 -> 14033 bytes rachis_cli/tests/data/rarefied_table.qza | Bin 0 -> 82989 bytes rachis_cli/tests/test_cache_cli.py | 1047 ++++++++ rachis_cli/tests/test_cli.py | 1416 +++++++++++ rachis_cli/tests/test_core.py | 458 ++++ rachis_cli/tests/test_dev.py | 190 ++ rachis_cli/tests/test_mystery_stew.py | 51 + rachis_cli/tests/test_tools.py | 2127 +++++++++++++++++ rachis_cli/tests/test_usage.py | 264 ++ rachis_cli/util.py | 588 +++++ 36 files changed, 11885 insertions(+) create mode 100644 rachis_cli/__init__.py create mode 100644 rachis_cli/__main__.py create mode 100644 rachis_cli/builtin/__init__.py create mode 100644 rachis_cli/builtin/dev.py create mode 100644 rachis_cli/builtin/info.py create mode 100644 rachis_cli/builtin/tools.py create mode 100644 rachis_cli/click/__init__.py create mode 100644 rachis_cli/click/command.py create mode 100644 rachis_cli/click/licenses/click.LICENSE.rst create mode 100644 rachis_cli/click/option.py create mode 100644 rachis_cli/click/parser.py create mode 100644 rachis_cli/click/type.py create mode 100644 rachis_cli/commands.py create mode 100644 rachis_cli/core/__init__.py create mode 100644 rachis_cli/core/artifact_cache_global.py create mode 100644 rachis_cli/core/assets/cli_howto.txt create mode 100644 rachis_cli/core/assets/copyright_note.txt create mode 100644 rachis_cli/core/cache.py create mode 100644 rachis_cli/core/completion.py create mode 100644 rachis_cli/core/config.py create mode 100644 rachis_cli/core/state.py create mode 100644 rachis_cli/core/usage.py create mode 100644 rachis_cli/tests/__init__.py create mode 100644 rachis_cli/tests/data/concatenated_ints.qza create mode 100644 rachis_cli/tests/data/mapping_config.toml create mode 100644 rachis_cli/tests/data/parse_dir_test/inner/right_ints.qza create mode 100644 rachis_cli/tests/data/parse_dir_test/left_ints.qza create mode 100644 rachis_cli/tests/data/rarefied_table.qza create mode 100644 rachis_cli/tests/test_cache_cli.py create mode 100644 rachis_cli/tests/test_cli.py create mode 100644 rachis_cli/tests/test_core.py create mode 100644 rachis_cli/tests/test_dev.py create mode 100644 rachis_cli/tests/test_mystery_stew.py create mode 100644 rachis_cli/tests/test_tools.py create mode 100644 rachis_cli/tests/test_usage.py create mode 100644 rachis_cli/util.py diff --git a/rachis_cli/__init__.py b/rachis_cli/__init__.py new file mode 100644 index 00000000..d19a3179 --- /dev/null +++ b/rachis_cli/__init__.py @@ -0,0 +1,13 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- + + +try: + from ._version import __version__ +except ModuleNotFoundError: + __version__ = '0.0.0+notfound' diff --git a/rachis_cli/__main__.py b/rachis_cli/__main__.py new file mode 100644 index 00000000..b703d139 --- /dev/null +++ b/rachis_cli/__main__.py @@ -0,0 +1,48 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 rachis_cli.commands + + +ROOT_COMMAND_HELP = """\ +rachis command-line interface (rachis-cli) +------------------------------------------ + +To get help with rachis, visit https://qiime2.org. + +To enable tab completion in Bash, run the following command or add it to your \ +.bashrc/.bash_profile: + + source tab-rachis + +To enable tab completion in ZSH, run the following commands or add them to \ +your .zshrc: + +\b + autoload -Uz compinit && compinit + autoload bashcompinit && bashcompinit + source tab-rachis + +""" + + +# Entry point for CLI +@click.command(cls=rachis_cli.commands.RootCommand, + invoke_without_command=True, + no_args_is_help=True, help=ROOT_COMMAND_HELP) +@click.version_option(prog_name='rachis-cli', + message='%(prog)s version %(version)s\nRun `rachis info`' + ' for more version details.') +def qiime(): + pass + + +if __name__ == '__main__': + qiime() diff --git a/rachis_cli/builtin/__init__.py b/rachis_cli/builtin/__init__.py new file mode 100644 index 00000000..5a85cefd --- /dev/null +++ b/rachis_cli/builtin/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- diff --git a/rachis_cli/builtin/dev.py b/rachis_cli/builtin/dev.py new file mode 100644 index 00000000..19496297 --- /dev/null +++ b/rachis_cli/builtin/dev.py @@ -0,0 +1,241 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 rachis_cli.click.command import ToolCommand, ToolGroupCommand + +_COMBO_METAVAR = 'ARTIFACT/VISUALIZATION' + + +@click.group(help='Utilities for developers and advanced users.', + cls=ToolGroupCommand) +def dev(): + pass + + +@dev.command(name='refresh-cache', + short_help='Refresh CLI cache.', + help="Refresh the CLI cache. Use this command if you are " + "developing a plugin, or rachis_cli itself, and want your " + "changes to take effect in the CLI. A refresh of the cache " + "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.", + cls=ToolCommand) +def refresh_cache(): + import rachis_cli.core.cache + rachis_cli.core.cache.CACHE.refresh() + + +import_theme_help = \ + ("Allows for customization of rachis-cli's command line styling based on " + "an imported .theme (INI formatted) file. If you are unfamiliar with " + ".ini formatted files look here https://en.wikipedia.org/wiki/INI_file." + "\n" + "\n" + "The .theme file allows you to customize text on the basis of what that " + "text represents with the following supported text types: command, " + "option, type, default_arg, required, emphasis, problem, warning, error, " + "and success. These will be your headers in the '[]' brackets. " + "\n" + "\n" + "`command` refers to the name of the command you issued. `option` refers " + "to the arguments you give to the command when running it. `type` refers " + "to the rachis semantic typing of these arguments (where applicable). " + "`default_arg` refers to the label next to the argument indicating its " + "default value (where applicable), and if it is required (where " + "applicable). `required` refers to any arguments that must be passed to " + "the command for it to work and gives them special formatting on top of " + "your normal `option` formatting. `emphasis` refers to any emphasized " + "pieces of text within help text. `problem` refers to the text informing " + "you there were issues with the command. `warning` refers to the text " + "for non-fatal issues while `error` refers to the text for fatal issues." + "`success` refers to text indicating a process completed as expected." + "\n" + "\n" + "Depending on what your terminal supports, some or all of the following " + "pieces of the text's formatting may be customized: bold, dim (if true " + "the text's brightness is reduced), underline, blink, reverse (if true " + "foreground and background colors are reversed), and finally fg " + "(foreground color) and bg (background color). The first five may each " + "be either true or false, while the colors may be set to any of the " + "following: black, red, green, yellow, blue, magenta, cyan, white, " + "bright_black, bright_red, bright_green, bright_yellow, bright_blue, " + "bright_magenta, bright_cyan, or bright_white.") + + +@dev.command(name='import-theme', + short_help='Install new command line theme.', + help=import_theme_help, + cls=ToolCommand) +@click.option('--theme', required=True, + type=click.Path(exists=True, file_okay=True, + dir_okay=False, readable=True), + help='Path to file containing new theme info') +def import_theme(theme): + import os + import shutil + from configparser import Error + + import rachis_cli.util + from rachis_cli.core.config import CONFIG + + try: + CONFIG.parse_file(theme) + except Error as e: + # If they tried to change [error] in a valid manner before we hit our + # parsing error, we don't want to use their imported error settings + CONFIG.styles = CONFIG.get_default_styles() + header = 'Something went wrong while parsing your theme: ' + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + shutil.copy(theme, os.path.join(rachis_cli.util.get_app_dir(), + 'cli-colors.theme')) + + +@dev.command(name='export-default-theme', + short_help='Export the default settings.', + help='Create a .theme (INI formatted) file from the default ' + 'settings at the specified filepath.', + cls=ToolCommand) +@click.option('--output-path', required=True, + type=click.Path(exists=False, file_okay=True, + dir_okay=False, readable=True), + help='Path to output the config to') +def export_default_theme(output_path): + import configparser + from rachis_cli.core.config import CONFIG + + parser = configparser.ConfigParser() + parser.read_dict(CONFIG.get_default_styles()) + with open(output_path, 'w') as fh: + parser.write(fh) + + +def abort_if_false(ctx, param, value): + if not value: + ctx.abort() + + +@dev.command(name='reset-theme', + short_help='Reset command line theme to default.', + help="Reset command line theme to default. Requres the '--yes' " + "parameter to be passed asserting you do want to reset.", + cls=ToolCommand) +@click.option('--yes', is_flag=True, callback=abort_if_false, + expose_value=False, + prompt='Are you sure you want to reset your theme?') +def reset_theme(): + import os + import rachis_cli.util + + path = os.path.join(rachis_cli.util.get_app_dir(), 'cli-colors.theme') + if os.path.exists(path): + os.unlink(path) + click.echo('Theme reset.') + else: + click.echo('Theme was already default.') + + +@dev.command(name='assert-result-type', + short_help='Assert Result is a specific type.', + help='Checks that the type of a Result matches an ' + 'expected type. Intended for developer testing.', + cls=ToolCommand) +@click.argument('input-path', type=click.Path(exists=True, file_okay=True, + dir_okay=True, readable=True), + metavar=_COMBO_METAVAR) +# TODO: we should probably change this to --rachis-type +@click.option('--qiime-type', required=True, + help='rachis data type.') +def assert_result_type(input_path, qiime_type): + import rachis_cli.util + import qiime2.sdk + from os.path import isdir + from rachis_cli.core.config import CONFIG + + rachis_cli.util.get_plugin_manager() + try: + if isdir(input_path): + result = qiime2.sdk.ResultCollection.load(input_path) + else: + result = qiime2.sdk.Result.load(input_path) + except Exception as e: + header = 'There was a problem loading %s as a rachis Result:' % \ + input_path + rachis_cli.util.exit_with_error(e, header=header) + + if str(result.type) != qiime_type: + try: + msg = 'Expected %s, observed %s' % (qiime_type, result.type) + raise AssertionError(msg) + except Exception as e: + header = 'There was a problem asserting the type:' + rachis_cli.util.exit_with_error(e, header=header) + else: + msg = 'The input file (%s) type and the expected type (%s)' \ + ' match' % (input_path, qiime_type) + click.echo(CONFIG.cfg_style('success', msg)) + + +@dev.command(name='assert-result-data', + short_help='Assert expression in Result.', + help='Uses regex to check that the provided expression is present' + ' in input file. Intended for developer testing.', + cls=ToolCommand) +@click.argument('input-path', type=click.Path(exists=True, file_okay=True, + dir_okay=False, readable=True), + metavar=_COMBO_METAVAR) +@click.option('--zip-data-path', required=True, + help='The path within the zipped Result\'s data/' + ' directory that should be searched.') +@click.option('--expression', required=True, + help='The Python regular expression to match.') +def assert_result_data(input_path, zip_data_path, expression): + import re + import rachis_cli.util + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + rachis_cli.util.get_plugin_manager() + + try: + result = qiime2.sdk.Result.load(input_path) + except Exception as e: + header = 'There was a problem loading %s as a rachis result:' % \ + input_path + rachis_cli.util.exit_with_error(e, header=header) + + try: + hits = sorted(result._archiver.data_dir.glob(zip_data_path)) + if len(hits) != 1: + data_dir = result._archiver.data_dir + all_fps = sorted(data_dir.glob('**/*')) + all_fps = [x.relative_to(data_dir).name for x in all_fps] + raise ValueError('Value provided for zip_data_path (%s) did not ' + 'produce exactly one match.\nMatches: %s\n' + 'Paths observed: %s' % + (zip_data_path, hits, all_fps)) + except Exception as e: + header = 'There was a problem locating the zip_data_path (%s)' % \ + zip_data_path + rachis_cli.util.exit_with_error(e, header=header) + + try: + target = hits[0].read_text() + match = re.search(expression, target, flags=re.MULTILINE) + if match is None: + raise AssertionError('Expression %r not found in %s.' % + (expression, hits[0])) + except Exception as e: + header = 'There was a problem finding the expression.' + rachis_cli.util.exit_with_error(e, header=header) + + msg = '"%s" was found in %s' % (str(expression), str(zip_data_path)) + click.echo(CONFIG.cfg_style('success', msg)) diff --git a/rachis_cli/builtin/info.py b/rachis_cli/builtin/info.py new file mode 100644 index 00000000..0a544ef8 --- /dev/null +++ b/rachis_cli/builtin/info.py @@ -0,0 +1,123 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 rachis_cli.click.command import ToolCommand + + +def _echo_version(): + import sys + import qiime2 + import rachis_cli + + pyver = sys.version_info + click.echo('Python version: %d.%d.%d' % + (pyver.major, pyver.minor, pyver.micro)) + click.echo('Parsl version: %s' % _get_parsl_ver()) + click.echo('rachis release: %s' % qiime2.__release__) + click.echo('rachis version: %s' % qiime2.__version__) + click.echo('rachis-cli version: %s' % rachis_cli.__version__) + + +def _get_parsl_ver(): + import os + import pathlib + import importlib.metadata + + conda_env_prefix = os.environ.get('CONDA_PREFIX', '') + conda_meta_path = pathlib.Path(conda_env_prefix) / 'conda-meta' + + parsl_ver = None + + # prefer the parsl version in conda env if available + if conda_meta_path.exists(): + for file in conda_meta_path.iterdir(): + if file.stem.startswith('parsl-'): + # version is in the structure of: parsl-2026.2.23-pyhcf101f3_0 + parsl_ver = file.stem.split('-', 2)[1] + break + + # fall back to any externally installed version if conda env not detectable + # or if parsl not found in existing conda env + if parsl_ver is None: + try: + parsl_ver = importlib.metadata.version('parsl') + except importlib.metadata.PackageNotFoundError: + pass + + return parsl_ver + + +def _echo_plugins(): + import rachis_cli.core.cache + + plugins = rachis_cli.core.cache.CACHE.plugins + if plugins: + for name, plugin in sorted(plugins.items()): + click.echo('%s: %s' % (name, plugin['version'])) + else: + click.secho('No plugins are currently installed.\n' + 'Find plugins at https://library.qiime2.org.') + + +@click.command(help='Display information about current deployment.', + cls=ToolCommand) +@click.option('--config-level', + required=False, + default=1, + show_default=True, + type=click.IntRange(0, 3), + help='The level of detail to be used for displaying the ' + 'configuration summary.') +def info(config_level): + import rachis_cli.util + # This import improves performance for repeated _echo_plugins + import rachis_cli.core.cache + from qiime2.sdk.parallel_config import (get_vendored_config, + load_config_from_dict) + from tomlkit import dumps + + click.secho('System versions', fg='green') + _echo_version() + click.secho('\nInstalled plugins', fg='green') + _echo_plugins() + + click.secho('\nApplication config directory', fg='green') + click.secho(rachis_cli.util.get_app_dir()) + + if config_level > 0: + click.secho('\nConfig', fg='green') + + config, action_executor_mapping, vendored_source = \ + get_vendored_config() + + click.secho(f'Config Source: {vendored_source}') + + if action_executor_mapping: + config['parsl.executor_mapping'] = action_executor_mapping + + if config_level > 1: + if config_level == 2: + config = dumps(config) + elif config_level == 3: + config['parsl'], _ = load_config_from_dict(config) + + click.secho(f'\n{config}') + + click.secho('\nGetting help', fg='green') + click.secho('To find help and learning resources, visit ' + 'https://qiime2.org.') + + if config_level: + click.secho('To get help with configuring and/or understanding ' + 'rachis parallelization, visit ' + 'https://use.qiime2.org/en/stable/references/' + 'parallel-configuration.html') + + click.secho('\n') diff --git a/rachis_cli/builtin/tools.py b/rachis_cli/builtin/tools.py new file mode 100644 index 00000000..c6b51a53 --- /dev/null +++ b/rachis_cli/builtin/tools.py @@ -0,0 +1,1805 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os +from typing import Literal + +import click + +from qiime2.core.annotate import ANNOTATION_TYPE_DICT + +import rachis_cli.util +from rachis_cli.click.command import ToolCommand, ToolGroupCommand + +_COMBO_METAVAR = 'ARTIFACT/VISUALIZATION' + + +def _import(type, input_path, input_format, validate_level): + import qiime2.sdk + import qiime2.plugin + + try: + artifact = qiime2.sdk.Artifact.import_data( + type, input_path, view_type=input_format, + validate_level=validate_level) + except qiime2.plugin.ValidationError as e: + header = 'There was a problem importing %s:' % input_path + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + except Exception as e: + header = 'An unexpected error has occurred:' + rachis_cli.util.exit_with_error(e, header=header) + + return artifact + + +def _export(result, output_format, output_path): + import distutils + + import qiime2.sdk + import qiime2.util + from rachis_cli.core.config import CONFIG + + if output_format is None: + if isinstance(result, qiime2.sdk.Artifact): + output_format = result.format.__name__ + else: + output_format = 'Visualization' + result.export_data(output_path) + else: + if isinstance(result, qiime2.sdk.Visualization): + error = '--output-format cannot be used with visualizations' + click.echo(CONFIG.cfg_style('error', error), err=True) + click.get_current_context().exit(1) + else: + source = result.view(qiime2.sdk.parse_format(output_format)) + if os.path.isfile(str(source)): + if os.path.isfile(output_path): + os.remove(output_path) + elif os.path.dirname(output_path) == '': + # This allows the user to pass a filename as a path if they + # want their output in the current working directory + output_path = os.path.join('.', output_path) + if os.path.dirname(output_path) != '': + # create directory (recursively) if it doesn't exist yet + os.makedirs(os.path.dirname(output_path), exist_ok=True) + qiime2.util.duplicate(str(source), output_path) + else: + distutils.dir_util.copy_tree(str(source), output_path) + + return output_format + + +@click.group(help='Tools for working with rachis files.', + cls=ToolGroupCommand) +def tools(): + pass + + +@tools.command(name='export', + short_help='Export data from a rachis Artifact ' + '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', + cls=ToolCommand) +@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') +@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') +@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): + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + result = qiime2.sdk.Result.load(input_path) + + output_format = _export(result, output_format, output_path) + + output_type = 'file' if os.path.isfile(output_path) else 'directory' + success = 'Exported %s as %s to %s %s' % (input_path, output_format, + output_type, output_path) + click.echo(CONFIG.cfg_style('success', success)) + + +def _print_descriptions(descriptions, tsv): + if tsv: + for value, description in descriptions.items(): + click.echo(f"{value}\t", nl=False) + if description: + click.echo(_deformat_description(description)) + else: + click.echo() + else: + import textwrap + tabsize = 8 + for value, description in descriptions.items(): + click.secho(value, bold=True) + if description: + description = _deformat_description(description) + wrapped_description = textwrap.wrap(description, + width=72-tabsize, + initial_indent='\t', + subsequent_indent='\t', + tabsize=tabsize) + for line in wrapped_description: + click.echo(f"{line}") + else: + click.secho("\tNo description", italic=True) + click.echo() + + +def _deformat_description(description): + import re + deformatted = re.sub(r"[\t\n]+", ' ', description) + despaced = re.sub(r" +", ' ', deformatted) + return despaced + + +def _get_matches(words, possibilities, strict=False): + from difflib import get_close_matches + if strict: + cutoff = 1 + else: + cutoff = 0.6 + + matches = set() + num_possibilities = len(possibilities) + for word in words: + matches.update(get_close_matches(word, + possibilities, + n=num_possibilities, + cutoff=cutoff)) + # substring search + if cutoff != 1: + for possibility in possibilities: + if word.lower() in possibility.lower(): + matches.add(possibility) + + return list(matches) + + +@tools.command( + name='list-types', + help='List the available semantic types.', + short_help='', + cls=ToolCommand +) +@click.argument('queries', nargs=-1) +@click.option('--strict', is_flag=True, + help='Show only exact matches for the type argument(s).') +@click.option('--tsv', is_flag=True, + help='Print as machine readable tab-separated values.') +def show_types(queries, strict, tsv): + pm = rachis_cli.util.get_plugin_manager() + + if len(queries) > 0: + matches = _get_matches(queries, list(pm.artifact_classes), strict) + else: + matches = sorted(list(pm.artifact_classes)) + + descriptions = {} + for match in matches: + description = pm.artifact_classes[match].description + descriptions[match] = description + + _print_descriptions(descriptions, tsv) + + +@tools.command( + name='list-formats', + help='List the available formats.', + short_help='', + cls=ToolCommand +) +@click.argument('queries', nargs=-1) +@click.option('--importable', is_flag=True, + help='List the importable formats.') +@click.option('--exportable', is_flag=True, + help='List the exportable formats.') +@click.option('--strict', is_flag=True, + help='Show only exact matches for the format argument(s).') +@click.option('--tsv', is_flag=True, + help='Print as machine readable tab-separated values.') +def show_formats(queries, importable, exportable, strict, tsv): + if importable and exportable: + raise click.UsageError("'--importable' and '--exportable' flags are " + "mutually exclusive.") + if not importable and not exportable: + raise click.UsageError("One of '--importable' or '--exportable' flags " + "is required.") + + pm = rachis_cli.util.get_plugin_manager() + portable_formats = pm.importable_formats if importable \ + else pm.exportable_formats + + if len(queries) > 0: + matches = _get_matches(queries, portable_formats.keys(), strict) + else: + matches = sorted(portable_formats.keys()) + + descriptions = {} + for match in matches: + docstring = portable_formats[match].format.__doc__ + first_docstring_line = docstring.split('\n\n')[0].strip() \ + if docstring else '' + descriptions[match] = first_docstring_line + + _print_descriptions(descriptions, tsv) + + +@tools.command(name='import', + short_help='Import data into a new rachis Artifact.', + help="Import data to create a new rachis Artifact. See " + "https://docs.qiime2.org/ for usage examples and details " + "on the file types and associated semantic types that can " + "be imported.", + cls=ToolCommand) +@click.option('--type', required=True, + help='The semantic type of the artifact that will be created ' + 'upon importing. Use `rachis tools list-types` to see what ' + 'importable semantic types are available in the current ' + 'deployment.') +@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.') +@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.') +@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. Use `rachis tools list-formats ' + '--importable` to see which formats of input data are ' + 'importable.') +@click.option('--validate-level', default='max', + type=click.Choice(['min', 'max']), + help='How much to validate the imported data before creating the' + ' artifact. A value of "max" will generally read the entire' + ' file or directory, whereas "min" will not usually do so.' + ' [default: "max"]') +def import_data(type, input_path, output_path, input_format, validate_level): + + from rachis_cli.core.config import CONFIG + + artifact = _import(type, input_path, input_format, validate_level) + artifact.save(output_path) + + if input_format is None: + input_format = artifact.format.__name__ + + success = 'Imported %s as %s to %s' % (input_path, + input_format, + output_path) + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(short_help='Take a peek at a rachis Artifact or ' + 'Visualization.', + help="Display basic information about a rachis Artifact or " + "Visualization, including its UUID and type.", + cls=ToolCommand) +@click.argument('paths', nargs=-1, required=True, + type=click.Path(exists=True, file_okay=True, dir_okay=False, + readable=True), metavar=_COMBO_METAVAR) +@click.option('--tsv/--no-tsv', default=False, + help='Print as machine-readable tab-separated values.') +def peek(paths, tsv): + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + metadatas = {os.path.basename(path): + qiime2.sdk.Result.peek(path) for path in paths} + + if tsv: + click.echo("Filename\tType\tUUID\tData Format") + for path, m in metadatas.items(): + click.echo(f"{path}\t{m.type}\t{m.uuid}\t{m.format}") + + elif len(metadatas) == 1: + metadata = metadatas[os.path.basename(paths[0])] + click.echo(CONFIG.cfg_style('type', "UUID")+": ", nl=False) + click.echo(metadata.uuid) + click.echo(CONFIG.cfg_style('type', "Type")+": ", nl=False) + click.echo(metadata.type) + if metadata.format is not None: + click.echo(CONFIG.cfg_style('type', "Data format")+": ", nl=False) + click.echo(metadata.format) + + else: + COLUMN_FILENAME = "Filename" + COLUMN_TYPE = "Type" + COLUMN_UUID = "UUID" + COLUMN_DATA_FORMAT = "Data Format" + + filename_width = max([len(p) for p in paths] + + [len(COLUMN_FILENAME)]) + type_width = max([len(i.type) for i in metadatas.values()] + + [len(COLUMN_TYPE)]) + uuid_width = max([len(i.uuid) for i in metadatas.values()] + + [len(COLUMN_UUID)]) + data_format_width = \ + max([len(i.format) if i.format is not None else 0 + for i in metadatas.values()] + [len(COLUMN_DATA_FORMAT)]) + + padding = 2 + format_string = f"{{f:<{filename_width + padding}}} " + \ + f"{{t:<{type_width + padding}}} " + \ + f"{{u:<{uuid_width + padding}}} " + \ + f"{{d:<{data_format_width + padding}}}" + + click.secho( + format_string.format( + f=COLUMN_FILENAME, + t=COLUMN_TYPE, + u=COLUMN_UUID, + d=COLUMN_DATA_FORMAT), + bold=True, fg="green") + for path, m in metadatas.items(): + click.echo( + format_string.format( + f=path, + t=m.type, + u=m.uuid, + d=(m.format if m.format is not None else 'N/A'))) + + +_COLUMN_TYPES = ['categorical', 'numeric'] + + +@tools.command(name='cast-metadata', + short_help='Designate metadata column types.', + help='Designate metadata column types.' + ' Supported column types are as follows: %s.' + ' Providing multiple file paths to this command will merge' + ' the metadata.' % (', '.join(_COLUMN_TYPES)), + cls=ToolCommand) +@click.option('--cast', required=True, metavar='COLUMN:TYPE', multiple=True, + help='Parameter for each metadata column that should' + ' be cast as a specified column type (supported types are as' + ' follows: %s). The required formatting for this' + ' parameter is --cast COLUMN:TYPE, repeated for each column' + ' and the associated column type it should be cast to in' + ' the output.' % (', '.join(_COLUMN_TYPES))) +@click.option('--ignore-extra', is_flag=True, + help='If this flag is enabled, cast parameters that do not' + ' correspond to any of the column names within the provided' + ' metadata will be ignored.') +@click.option('--error-on-missing', is_flag=True, + help='If this flag is enabled, failing to include cast' + ' parameters for all columns in the provided metadata will' + ' result in an error.') +@click.option('--output-file', required=False, + type=click.Path(exists=False, file_okay=True, dir_okay=False, + writable=True), + help='Path to file where the modified metadata should be' + ' written to.') +@click.argument('paths', nargs=-1, required=True, metavar='METADATA...', + type=click.Path(exists=True, file_okay=True, dir_okay=False, + readable=True)) +def cast_metadata(paths, cast, output_file, ignore_extra, + error_on_missing): + import tempfile + from qiime2 import Metadata, metadata + + md = _merge_metadata(paths) + + cast_dict = {} + try: + for casting in cast: + if ':' not in casting: + raise click.BadParameter( + message=f'Missing `:` in --cast {casting}', + param_hint='cast') + splitter = casting.split(':') + if len(splitter) != 2: + raise click.BadParameter( + message=f'Incorrect number of fields in --cast {casting}.' + f' Observed {len(splitter)}' + f' {tuple(splitter)}, expected 2.', + param_hint='cast') + col, type_ = splitter + if col in cast_dict: + raise click.BadParameter( + message=(f'Column name "{col}" appears in cast more than' + ' once.'), + param_hint='cast') + cast_dict[col] = type_ + except Exception as err: + header = \ + ('Could not parse provided cast arguments into unique COLUMN:TYPE' + ' pairs. Please make sure all cast flags are of the format --cast' + ' COLUMN:TYPE') + rachis_cli.util.exit_with_error(err, header=header) + + types = set(cast_dict.values()) + if not types.issubset(_COLUMN_TYPES): + raise click.BadParameter( + message=('Unknown column type provided. Please make sure all' + ' columns included in your cast contain a valid column' + ' type. Valid types: %s' % + (', '.join(_COLUMN_TYPES))), + param_hint='cast') + + column_names = set(md.columns.keys()) + cast_names = set(cast_dict.keys()) + + if not ignore_extra: + if not cast_names.issubset(column_names): + cast = cast_names.difference(column_names) + raise click.BadParameter( + message=('The following cast columns were not found' + ' within the metadata: %s' % + (', '.join(cast))), + param_hint='cast') + + if error_on_missing: + if not column_names.issubset(cast_names): + cols = column_names.difference(cast_names) + raise click.BadParameter( + message='The following columns within the metadata' + ' were not provided in the cast: %s' % + (', '.join(cols)), + param_hint='cast') + + # Remove entries from the cast dict that are not in the metadata to avoid + # errors further down the road + for cast in cast_names: + if cast not in column_names: + cast_dict.pop(cast) + + with tempfile.NamedTemporaryFile() as temp: + md.save(temp.name) + try: + cast_md = Metadata.load(temp.name, cast_dict) + except metadata.io.MetadataFileError as e: + raise click.BadParameter(message=e, param_hint='cast') from e + + if output_file: + cast_md.save(output_file) + else: + with tempfile.NamedTemporaryFile(mode='w+') as stdout_temp: + cast_md.save(stdout_temp.name) + stdout_str = stdout_temp.read() + click.echo(stdout_str) + + +@tools.command(name='inspect-metadata', + 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.', + cls=ToolCommand) +@click.option('--tsv/--no-tsv', default=False, + help='Print as machine-readable TSV instead of text.') +@click.argument('paths', nargs=-1, required=True, metavar='METADATA...', + type=click.Path(file_okay=True, dir_okay=False, readable=True)) +@rachis_cli.util.pretty_failure(traceback=None) +def inspect_metadata(paths, tsv, failure): + metadata = _merge_metadata(paths) + + # we aren't expecting errors below this point, so set traceback to default + failure.traceback = 'stderr' + failure.header = "An unexpected error has occurred:" + + COLUMN_NAME = "COLUMN NAME" + COLUMN_TYPE = "TYPE" + max_name_len = max([len(n) for n in metadata.columns] + + [len(COLUMN_NAME)]) + max_type_len = max([len(p.type) for p in metadata.columns.values()] + + [len(COLUMN_TYPE)]) + + if tsv: + import csv + import io + + def formatter(*row): + # This is gross, but less gross than robust TSV writing. + with io.StringIO() as fh: + writer = csv.writer(fh, dialect='excel-tab', lineterminator='') + writer.writerow(row) + return fh.getvalue() + else: + formatter = ("{0:>%d} {1:%d}" % (max_name_len, max_type_len)).format + + click.secho(formatter(COLUMN_NAME, COLUMN_TYPE), bold=True) + if not tsv: + click.secho(formatter("=" * max_name_len, "=" * max_type_len), + bold=True) + + for name, props in metadata.columns.items(): + click.echo(formatter(name, props.type)) + + if not tsv: + click.secho(formatter("=" * max_name_len, "=" * max_type_len), + bold=True) + click.secho(("{0:>%d} " % max_name_len).format("IDS:"), + bold=True, nl=False) + click.echo(metadata.id_count) + click.secho(("{0:>%d} " % max_name_len).format("COLUMNS:"), + bold=True, nl=False) + click.echo(metadata.column_count) + + +def _merge_metadata(paths): + m = [rachis_cli.util.load_metadata(p) for p in paths] + metadata = m[0] + if m[1:]: + metadata = metadata.merge(*m[1:]) + + return metadata + + +@tools.command(short_help='Make a rachis Report.', + help='Make a rachis report consisting of multiple ' + 'visualizations. Visualizations must have unique ' + 'filenames.', + cls=ToolCommand) +@click.argument('visualization_paths', metavar='VIZ', required=True, nargs=-1, + type=click.Path(file_okay=True, dir_okay=False, readable=True)) +@click.option('--report-path', required=True, metavar='REPORT', + type=click.Path(exists=False, file_okay=True, dir_okay=False, + writable=True), + help='Path where output report should be written.') +def make_report(visualization_paths, report_path): + from qiime2 import Visualization + from q2templates.reports import matryoshka_template + from rachis_cli.core.config import CONFIG + + loaded = [Visualization.load(path) for path in visualization_paths] + keys = [os.path.basename(x).rsplit('.', 1)[0] for x in visualization_paths] + if len(set(keys)) < len(keys): + error = ('Multiple files share the same name and cannot be' + ' disambiguated. Please ensure that all filenames provided' + ' are unique.') + click.echo(CONFIG.cfg_style('error', error), err=True) + click.get_current_context().exit(1) + + entries = dict(zip(keys, loaded)) + + report = Visualization.make_report(matryoshka_template, entries) + result = report.save(report_path) + click.echo(CONFIG.cfg_style('success', f'Report saved to {result}')) + + +@tools.command(short_help='View a rachis Result.', + help="Displays a rachis Result until the command exits. To " + "open a rachis Visualization so it can be used after the " + "command exits, use 'rachis tools extract'.", + cls=ToolCommand) +@click.argument('result-path', metavar='RESULT', + type=click.Path(file_okay=True, dir_okay=False, readable=True)) +@click.option('--port', required=False, type=click.IntRange(1024, 65535), + default=None, help='The port to serve the webapp on.') +@click.option('--verbose', is_flag=True, + help='Display all GET requests in the terminal.') +def view(result_path, port, verbose): + # Get the abspath to the result + result_path = os.path.abspath(result_path) + + # Guard headless envs from having to import anything large + import sys + + if not os.getenv('DISPLAY') and sys.platform != 'darwin': + raise click.UsageError( + 'Result viewing is currently not supported in headless ' + 'environments. You can view Results at https://view.qiime2.org, ' + 'or move the Result to an environment with a display and view it ' + 'with `rachis tools view`.') + + import signal + import random + import tempfile + import threading + import http.server + import contextlib + import urllib.parse + import mimetypes + + from qiime2.sdk import Result + + from rachis_cli.core.config import CONFIG + + # Load and extract result + result = Result.load(result_path) + + extracted_path = os.path.join(tempfile.gettempdir(), str(result.uuid)) + if not os.path.exists(extracted_path): + result.extract(result_path, tempfile.gettempdir()) + + # This ought to look like a session id generated by normal view + CHAR_SET = 'abcdefghijklmnopqrstuvwxyz0123456789' + SESSION_LEN = 32 + session = ''.join(random.choice(CHAR_SET) for i in range(SESSION_LEN)) + + # Start server + class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # Redirect the output from these requests to devnull if not verbose + with open(os.devnull, 'w') as devnull: + with contextlib.redirect_stderr( + sys.stderr if verbose else devnull): + # Determine if this is a request for the file we are + # supposed to be viewing + if self.path == f'/{session}': + if not os.path.exists(result_path): + self.send_error(404) + self.end_headers() + else: + self._handle_file_exists(result_path) + # Determine if this is a request for a file within the + # visualization + elif self.path.startswith(f'/_/{session}/{result.uuid}/'): + file_path = self.path.split('?', 1)[0] + file_path = file_path.split('#', 1)[0] + file_path = file_path.split(str(result.uuid))[1] + file_path = urllib.parse.unquote(file_path) + file_path = extracted_path + file_path + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path) or \ + not file_path.startswith(extracted_path): + self.send_error(404) + self.end_headers() + else: + self._handle_file_exists(file_path) + # Otherwise default to super class. This will respond + # appropriately to requests for assets that are part of the + # vendored view app and will reject any requests for files + # outside the served directory + else: + super().do_GET() + + def _handle_file_exists(self, file_path): + type_, _ = mimetypes.guess_type(self.path, strict=False) + if type_ is None: + type_ = 'application/octet-stream' + + file_size = os.path.getsize(file_path) + + self.send_response(200) + self.send_header('content-type', type_) + self.send_header('content-length', file_size) + self.end_headers() + + with open(file_path, 'rb') as file: + self.wfile.write(file.read()) + + # Get the path to the packaged vendored view + # TODO: This won't work if we start packaging rachis as a wheel, we will + # have to reimplement this used importlib.resources and it may be mildly + # annoying to make things work properly. It's hard to tell right now since + # we do not use a wheel. + # https://docs.python.org/3/library/importlib.resources.html + import importlib + MODULE_INIT = importlib.import_module('rachis_cli').__file__ + MODULE_BASE_DIR = os.path.abspath(os.path.dirname(MODULE_INIT)) + VENDOR_PATH = os.path.join(MODULE_BASE_DIR, 'assets', 'view') + + # Set up the server socket + import socket + + server_socket = socket.socket() + # If port is None then slap a 0 into here to get a free port + server_socket.bind(('localhost', 0 if port is None else port)) + # Get the port off the opened socket, if a port was passed, this will set + # the port to itself no harm done. If no port was passed, this will get the + # open port that .bind found. + port = server_socket.getsockname()[1] + + # Start up the server + server = http.server.HTTPServer( + ('localhost', port), lambda *_: Handler(*_, directory=VENDOR_PATH), + bind_and_activate=False) + server.socket = server_socket + + # Don't listen until the server is already going + server_socket.listen(0) + click.echo(f'Agent started on port: {port}') + + # Stop server on termination of main thread + def stop(): + server.shutdown() + sys.exit(0) + + signal.signal(signal.SIGTERM, stop) + + # Start the server in a new thread + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + # Open page on server + filename = os.path.basename(result_path) + url = f'http://localhost:{port}?file={filename}&session={session}' + launch_status = click.launch(url) + click.echo('Your view should open in your default browser shortly. You ' + f'may open it manually at the URL:\n\n{url}\n') + + # Yell if there was an error + if launch_status != 0: + click.echo( + CONFIG.cfg_style('error', 'Viewing result failed while attempting ' + f'to open {result_path}'), + err=True) + + # Wait for shut down request + while True: + click.echo("Press the 'q' key, Control-C, or Control-D to quit. This " + "view may no longer be accessible or work correctly after " + "quitting.") + # There is currently a bug in click.getchar where translation + # of Control-C and Control-D into KeyboardInterrupt and + # EOFError (respectively) does not work on Python 3. The code + # here should continue to work as expected when the bug is + # fixed in Click. + # + # https://github.com/pallets/click/issues/583 + try: + char = click.getchar() + click.echo() + if char in {'q', '\x03', '\x04'}: + break + except (KeyboardInterrupt, EOFError): + break + + +@tools.command(short_help="Extract a rachis Artifact or Visualization " + "archive.", + help="Extract all contents of a rachis Artifact or " + "Visualization's archive, including provenance, metadata, " + "and actual data. Use 'rachis tools export' to export " + "only the data stored in an Artifact or Visualization, " + "with the choice of exporting to different formats.", + cls=ToolCommand) +@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') +@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 ' + '[default: current working directory]', + default=os.getcwd()) +def extract(input_path, output_path): + import zipfile + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + try: + extracted_dir = qiime2.sdk.Result.extract(input_path, output_path) + except (zipfile.BadZipFile, ValueError): + raise click.BadParameter( + '%s is not a valid rachis Result. Only rachis Artifacts and ' + 'Visualizations can be extracted.' % input_path) + else: + success = 'Extracted %s to directory %s' % (input_path, extracted_dir) + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(short_help='Validate data in a rachis Artifact.', + help='Validate data in a rachis Artifact. rachis ' + 'automatically performs some basic validation when ' + 'managing your data; use this command to perform explicit ' + '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.', + cls=ToolCommand) +@click.argument('path', type=click.Path(exists=True, file_okay=True, + dir_okay=False, readable=True), + metavar=_COMBO_METAVAR) +@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).', + default='max', show_default=True) +def validate(path, level): + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + try: + result = qiime2.sdk.Result.load(path) + except Exception as e: + header = 'There was a problem loading %s as a rachis Result:' % path + rachis_cli.util.exit_with_error(e, header=header) + + try: + result.validate(level) + except qiime2.plugin.ValidationError as e: + header = 'Result %s does not appear to be valid at level=%s:' % ( + path, level) + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + except Exception as e: + header = ('An unexpected error has occurred while attempting to ' + 'validate result %s:' % path) + rachis_cli.util.exit_with_error(e, header=header) + else: + click.echo(CONFIG.cfg_style('success', f'Result {path} appears to be ' + f'valid at level={level}.')) + + +@tools.command(short_help='Print citations for a rachis result.', + help='Print citations as a BibTex file (.bib) for a rachis' + ' result.', + cls=ToolCommand) +@click.argument('path', type=click.Path(exists=True, file_okay=True, + dir_okay=False, readable=True), + metavar=_COMBO_METAVAR) +def citations(path): + import qiime2.sdk + import io + from rachis_cli.core.config import CONFIG + ctx = click.get_current_context() + + try: + result = qiime2.sdk.Result.load(path) + except Exception as e: + header = 'There was a problem loading %s as a rachis result:' % path + rachis_cli.util.exit_with_error(e, header=header) + + if result.citations: + with io.StringIO() as fh: + result.citations.save(fh) + click.echo(fh.getvalue(), nl=False) + ctx.exit(0) + else: + click.echo(CONFIG.cfg_style('problem', 'No citations found.'), + err=True) + ctx.exit(1) + + +@tools.command(name='cache-create', + short_help='Create an empty cache at the given location.', + help='Create an empty cache at the given location.', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=False, readable=True), + help='Path to a nonexistent directory to be created as a cache.') +def cache_create(cache): + from qiime2.core.cache import Cache + from rachis_cli.core.config import CONFIG + + try: + Cache(cache) + except Exception as e: + header = "There was a problem creating a cache at '%s':" % cache + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + success = "Created cache at '%s'" % cache + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-remove', + short_help='Removes a given key from a cache.', + help='Removes a given key from a cache then runs garbage ' + 'collection on the cache.', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to remove the key from.') +@click.option('--key', required=True, multiple=True, + help='The key to remove from the cache. Pass `key` parameter ' + 'multiple times to remove multiple keys.') +def cache_remove(cache, key): + from qiime2.core.cache import Cache + from rachis_cli.core.config import CONFIG + + # I don't want to instantiate this every time in the loop + try: + _cache = Cache(cache) + except Exception as e: + header = f"The path '{cache}' is not a valid rachis cache." + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + for _key in key: + try: + # TODO: This will run gc after every key removal. Don't need to do + # that + _cache.remove(_key) + except Exception as e: + header = f"There was a problem removing the key '{_key}' from "\ + f"the cache '{cache}'" + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + else: + success = f"Removed key '{_key}' from cache '{cache}'" + click.echo(CONFIG.cfg_style('success', success)) + + success = f"Removed key(s) '{key}' from cache '{cache}'" + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-garbage-collection', + short_help='Runs garbage collection on the cache at the ' + 'specified location.', + help='Runs garbage collection on the cache at the specified ' + 'location if the specified location is a cache.', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to run garbage collection on.') +def cache_garbage_collection(cache): + from qiime2.core.cache import Cache + from rachis_cli.core.config import CONFIG + + try: + _cache = Cache(cache) + _cache.garbage_collection() + except Exception as e: + header = "There was a problem running garbage collection on the " \ + "cache at '%s':" % cache + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + success = "Ran garbage collection on cache at '%s'" % cache + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-store', + short_help='Stores a .qza in the cache under a key.', + help='Stores a .qza in the cache under a key.', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to save into.') +@click.option('--artifact-path', required=True, + type=click.Path(exists=True, file_okay=True, dir_okay=False, + readable=True), + help='Path to a .qza to save into the cache.') +@click.option('--key', required=True, + help='The key to save the artifact under (must be a valid ' + 'Python identifier).') +def cache_store(cache, artifact_path, key): + from qiime2.sdk.result import Result + from qiime2.core.cache import Cache + from rachis_cli.core.config import CONFIG + from rachis.sdk.util import validate_result_collection_keys + + # If the Result takes a long time to load, we can waste all that time + # then explode on cache.save, better to validate the key twice once here + # and once in .save than load the entire Result before exploding. Key + # validation is cheap. + validate_result_collection_keys(key) + + try: + artifact = Result.load(artifact_path) + _cache = Cache(cache) + _cache.save(artifact, key) + except Exception as e: + header = "There was a problem saving the artifact '%s' to the cache " \ + "'%s' under the key '%s':" % (artifact_path, cache, key) + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + success = "Saved the artifact '%s' to the cache '%s' under the key " \ + "'%s'" % (artifact_path, cache, key) + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-import', + short_help='Imports data into an Artifact in the cache under a ' + 'key.', + help='Imports data into an Artifact in the cache under a key.', + cls=ToolCommand) +@click.option('--type', required=True, + help='The semantic type of the artifact that will be created ' + 'upon importing. Use `rachis tools list-types` to see what ' + 'importable semantic types are available in the current ' + 'deployment.') +@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.') +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to save into.') +@click.option('--key', required=True, + help='The key to save the artifact under (must be a valid ' + 'Python identifier).') +@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. Use `rachis tools list-formats ' + '--importable` to see which formats of input data are ' + 'importable.') +@click.option('--validate-level', required=False, default='max', + type=click.Choice(['min', 'max']), + help='How much to validate the imported data before creating the' + ' artifact. A value of "max" will generally read the entire' + ' file or directory, whereas "min" will not usually do so.' + ' [default: "max"]') +def cache_import(type, input_path, cache, key, input_format, validate_level): + from qiime2 import Cache + from rachis_cli.core.config import CONFIG + from rachis.sdk.util import validate_result_collection_keys + + # If the Artifact takes a long time to import, we can waste all that time + # then explode on cache.save, better to validate the key twice once here + # and once in .save than import the entire Artifact before exploding. Key + # validation is cheap. + validate_result_collection_keys(key) + + artifact = _import(type, input_path, input_format, validate_level) + _cache = Cache(cache) + _cache.save(artifact, key) + + if input_format is None: + input_format = artifact.format.__name__ + + success = 'Imported %s as %s to %s:%s' % (input_path, + input_format, + cache, + key) + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-export', + short_help='Export data from a rachis Artifact ' + 'or a Visualization in a given cache under a given key.', + help='Exporting extracts (and optionally transforms) data ' + 'stored inside an Artifact or Visualization. Note that ' + 'Visualizations cannot be transformed with --output-format', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to export from.') +@click.option('--key', required=True, + help='The key of the Artifact or Visualization to export from ' + 'the cache.') +@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 data should be exported to.') +@click.option('--output-format', required=False, + help='Format which the data should be exported as. ' + 'This option cannot be used with Visualizations') +def export_cache(cache, key, output_path, output_format): + from qiime2 import Cache + from rachis_cli.core.config import CONFIG + + try: + _cache = Cache(cache) + result = _cache.load(key) + + output_format = _export(result, output_format, output_path) + except Exception as e: + header = "There was a problem exporting the Artifact/Visualization " \ + f"with the key '{key}' from the cache '{cache}'." + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + output_type = 'file' if os.path.isfile(output_path) else 'directory' + success = f"Exported {cache}:{key} as {output_format} to {output_type} "\ + f"{output_path}" + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-fetch', + short_help='Fetches an artifact out of a cache into a .qza.', + help='Fetches the artifact saved to the specified cache under ' + 'the specified key into a .qza at the specified location.', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to load from.') +@click.option('--key', required=True, + help='The key to the artifact being loaded.') +@click.option('--output-path', required=True, + type=click.Path(exists=False, readable=True), + help='Path to put the .qza we are loading the artifact into.') +def cache_fetch(cache, key, output_path): + from qiime2.core.cache import Cache + from rachis_cli.core.config import CONFIG + + try: + _cache = Cache(cache) + artifact = _cache.load(key) + artifact.save(output_path) + except Exception as e: + header = "There was a problem loading the artifact with the key " \ + "'%s' from the cache '%s' and saving it to the file '%s':" % \ + key, cache, output_path + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + success = "Loaded artifact with the key '%s' from the cache '%s' and " \ + "saved it to the file '%s'" % (key, cache, output_path) + click.echo(CONFIG.cfg_style('success', success)) + + +@tools.command(name='cache-status', + short_help='Checks the status of the cache.', + help='Lists all keys in the given cache. Peeks artifacts ' + 'pointed to by keys to data and lists the number of ' + 'artifacts in the pool for keys to pools.', + cls=ToolCommand) +@click.option('--cache', required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True, + readable=True), + help='Path to an existing cache to check the status of.') +def cache_status(cache): + from qiime2.core.cache import Cache + from qiime2.sdk.result import Result + + from rachis_cli.core.config import CONFIG + + data_output = [] + pool_output = [] + try: + _cache = Cache(cache) + with _cache.lock: + for key in _cache.get_keys(): + key_values = _cache.read_key(key) + + if 'data' in key_values: + data = key_values['data'] + data_output.append( + 'data: %s -> %s' % + (key, str(Result.peek(_cache.data / data)))) + elif 'pool' in key_values: + pool = key_values['pool'] + pool_output.append( + 'pool: %s -> size = %s' % + (key, str(len(os.listdir(_cache.pools / pool))))) + except Exception as e: + header = "There was a problem getting the status of the cache at " \ + "path '%s':" % cache + rachis_cli.util.exit_with_error(e, header=header, traceback=None) + + if not data_output: + data_output = 'No data keys in cache' + else: + data_output = '\n'.join(data_output) + data_output = 'Data keys in cache:\n' + data_output + + if not pool_output: + pool_output = 'No pool keys in cache' + else: + pool_output = '\n'.join(pool_output) + pool_output = 'Pool keys in cache:\n' + pool_output + + output = data_output + '\n\n' + pool_output + success = "Status of the cache at the path '%s':\n\n%s" % \ + (cache, output) + click.echo(CONFIG.cfg_style('success', success)) + + +replay_in_fp_help = ( + 'filepath to a rachis Archive (.qza or .qzv) or directory of Archives' +) +replay_recurse_help = ( + 'if in-fp is a directory, will also search sub-directories when finding ' + 'Archives to parse' +) +replay_validate_checksums_help = ( + 'check that replayed archives are intact and uncorrupted' +) +replay_parse_metadata_help = ( + 'parse the original metadata captured in provenance for review or replay' +) +replay_use_recorded_metadata_help = ( + 're-use the original metadata captured in provenance' +) +replay_suppress_header_help = ( + 'do not write header/footer blocks in the output script' +) +replay_verbose_help = ( + 'print status messages to stdout while processing' +) +replay_dump_recorded_metadata_help = ( + 'write the original metadata captured in provenance to disk in the ' + '--metadata-out-dir directory' +) + + +@tools.command(name='replay-provenance', cls=ToolCommand) +@click.option('--in-fp', required=True, help=replay_in_fp_help) +@click.option('--recurse/--no-recurse', + default=False, + show_default=True, + help=replay_recurse_help) +@click.option('--usage-driver', + default='cli', + show_default=True, + help='the target interface for your replay script', + type=click.Choice(['python3', 'cli'], case_sensitive=False)) +@click.option('--validate-checksums/--no-validate-checksums', + default=True, + show_default=True, + help=replay_validate_checksums_help) +@click.option('--parse-metadata/--no-parse-metadata', + default=True, + show_default=True, + help=replay_parse_metadata_help) +@click.option('--use-recorded-metadata/--no-use-recorded-metadata', + default=False, + show_default=True, + help=replay_use_recorded_metadata_help) +@click.option('--suppress-header/--no-suppress-header', + default=False, + show_default=True, + help=replay_suppress_header_help) +@click.option('--verbose/--no-verbose', + default=True, + show_default=True, + help=replay_verbose_help) +@click.option('--dump-recorded-metadata/--no-dump-recorded-metadata', + default=True, + show_default=True, + help=replay_dump_recorded_metadata_help) +@click.option('--metadata-out-dir', + default='', + show_default=True, + help=('the directory where captured study metadata ' + 'should be written if --dump-recorded-metadata. This ' + 'often produces many outputs, so a dedicated directory ' + 'should generally be used. Creates the directory if it ' + 'does not already exist. By default, metadata is written ' + 'to `${PWD}/recorded_metadata/`')) +@click.option('--out-fp', + required=True, + type=click.Path(exists=False, writable=True), + help='the filepath where your replay script should be written') +def provenance_replay( + in_fp: str, + out_fp: str, + usage_driver: Literal['python3', 'cli'], + recurse: bool = False, + validate_checksums: bool = True, + parse_metadata: bool = True, + use_recorded_metadata: bool = False, + suppress_header: bool = False, + verbose: bool = True, + dump_recorded_metadata: bool = True, + metadata_out_dir: str = '' +): + """ + Replay provenance from a rachis Artifact filepath to a written executable + """ + from qiime2.core.archive.provenance_lib.replay import replay_provenance + from qiime2.sdk.util import get_available_usage_drivers + from rachis_cli.util import get_plugin_manager + + get_plugin_manager() + usage_drivers = get_available_usage_drivers() + try: + usage_driver_type = usage_drivers[usage_driver] + except KeyError: + msg = ( + f'The {usage_driver} usage driver is not available in the ' + 'current environment.' + ) + raise ValueError(msg) + + try: + in_fp = rachis_cli.util.try_as_cache_input(in_fp) + except ValueError: + pass + + replay_provenance( + usage_driver=usage_driver_type, + payload=in_fp, + out_fp=out_fp, + validate_checksums=validate_checksums, + parse_metadata=parse_metadata, + recurse=recurse, + use_recorded_metadata=use_recorded_metadata, + suppress_header=suppress_header, + verbose=verbose, + dump_recorded_metadata=dump_recorded_metadata, + md_out_dir=metadata_out_dir + ) + filename = os.path.realpath(out_fp) + click.echo(f'{usage_driver} replay script written to {filename}') + + +@tools.command(name='replay-citations', cls=ToolCommand) +@click.option('--in-fp', required=True, help=replay_in_fp_help) +@click.option('--recurse/--no-recurse', + default=False, + show_default=True, + help=replay_recurse_help) +@click.option('--deduplicate/--no-deduplicate', + default=True, + show_default=True, + help=('If deduplicate, duplicate citations will be removed ' + 'heuristically, e.g. by comparing DOI fields. ' + 'This greatly reduces manual curation of reference lists, ' + 'but introduces a small risk of reference loss.')) +@click.option('--suppress-header/--no-suppress-header', + default=False, + show_default=True, + help=replay_suppress_header_help) +@click.option('--verbose/--no-verbose', + default=True, + show_default=True, + help=replay_verbose_help) +@click.option('--out-fp', + required=True, + type=click.Path(exists=False, writable=True), + help='the filepath where your bibtex file should be written') +def citations_replay( + in_fp: str, + out_fp: str, + recurse: bool = False, + deduplicate: bool = True, + suppress_header: bool = False, + verbose: bool = True +): + """ + Reports all citations from a rachis Artifact or directory of Artifacts, + with the goal of improving and simplifying attribution of/in published + work. + + Not for use in reporting e.g. software versions used in an analysis, as + deduplication removes duplicate references with different plugin versions. + """ + from qiime2.core.archive.provenance_lib.parse import ProvDAG + from qiime2.core.archive.provenance_lib.replay import replay_citations + + try: + in_fp = rachis_cli.util.try_as_cache_input(in_fp) + except ValueError: + pass + + dag = ProvDAG(in_fp, verbose=verbose, recurse=recurse) + replay_citations( + dag, + out_fp=out_fp, + deduplicate=deduplicate, + suppress_header=suppress_header + ) + filename = os.path.realpath(out_fp) + click.echo(f'citations bibtex file written to {filename}') + + +@tools.command(name='replay-supplement', cls=ToolCommand) +@click.option('--in-fp', required=True, + help='filepath to a rachis Archive or directory of Archives') +@click.option('--recurse/--no-recurse', + default=False, + show_default=True, + help=('if in-fp is a directory, will also search sub-directories' + ' when finding .qza/.qzv files to parse')) +@click.option('--deduplicate/--no-deduplicate', + default=True, + show_default=True, + help=('If deduplicate, duplicate citations will be removed ' + 'heuristically, e.g. by comparing DOI fields. ' + 'This greatly reduces manual curation of reference lists, ' + 'but introduces a small risk of reference loss.')) +@click.option('--validate-checksums/--no-validate-checksums', + default=True, + show_default=True, + help=replay_validate_checksums_help) +@click.option('--parse-metadata/--no-parse-metadata', + default=True, + show_default=True, + help=replay_parse_metadata_help) +@click.option('--use-recorded-metadata/--no-use-recorded-metadata', + default=False, + show_default=True, + help=replay_use_recorded_metadata_help) +@click.option('--suppress-header/--no-suppress-header', + default=False, + show_default=True, + help=replay_suppress_header_help) +@click.option('--verbose/--no-verbose', + default=True, + show_default=True, + help=replay_verbose_help) +@click.option('--dump-recorded-metadata/--no-dump-recorded-metadata', + default=True, + show_default=True, + help='write the original metadata captured in provenance to ' + 'recorded_metadata/ inside the archive') +@click.option('--out-fp', + required=True, + type=click.Path(exists=False, writable=True), + help='the filepath where your reproduciblity supplement zipfile ' + 'should be written') +def supplement_replay( + in_fp: str, + out_fp: str, + validate_checksums: bool = True, + parse_metadata: bool = True, + use_recorded_metadata: bool = False, + recurse: bool = False, + deduplicate: bool = True, + suppress_header: bool = False, + verbose: bool = True, + dump_recorded_metadata: bool = True +): + """ + Produces a zipfile package of useful documentation supporting in silico + reproducibility of some rachis Result(s) from a rachis Artifact or + directory of Artifacts. + + Package includes: + - replay scripts for all supported interfaces + - a bibtex-formatted collection of all citations + """ + from qiime2.core.archive.provenance_lib.replay import replay_supplement + from qiime2.sdk.util import get_available_usage_drivers + from rachis_cli.util import get_plugin_manager + + get_plugin_manager() + usage_drivers = get_available_usage_drivers() + usage_driver_types = list(usage_drivers.values()) + if not usage_driver_types: + msg = ( + 'There are no available usage drivers registered in the current ' + 'environment.' + ) + raise ValueError(msg) + + try: + in_fp = rachis_cli.util.try_as_cache_input(in_fp) + except ValueError: + pass + + replay_supplement( + usage_drivers=usage_driver_types, + payload=in_fp, + out_fp=out_fp, + validate_checksums=validate_checksums, + parse_metadata=parse_metadata, + use_recorded_metadata=use_recorded_metadata, + recurse=recurse, + deduplicate=deduplicate, + suppress_header=suppress_header, + verbose=verbose, + dump_recorded_metadata=dump_recorded_metadata + ) + + +@tools.command( + name='annotation-create', + short_help='Add a new Annotation to a rachis Result.', + help='Attach an Annotation to an existing Artifact or Visualization.' + ' Supported for rachis archives of version 7.0+.', + cls=ToolCommand +) +@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 the `.qza` or `.qzv` you want to annotate.' +) +@click.option( + '--annotation-type', + required=True, + type=click.Choice(list(ANNOTATION_TYPE_DICT.keys()), case_sensitive=True), + help='Annotation type to create.' +) +@click.option( + '--name', + required=True, + help='Name for your Annotation (must be unique for a given Result).' +) +# NOTE-specific inputs +@click.option( + '--text', + required=False, + help='Inline text for the Annotation.' + ' Mutually exclusive with `--file`.' +) +@click.option( + '--file', + 'filepath', + required=False, + type=click.Path(exists=True, file_okay=True, + dir_okay=False, readable=True), + help='Path to a text file whose contents will be added' + ' to your Annotation. Mutually exclusive with `--text`.' +) +# SIGNATURE-specific input +@click.option( + '--fingerprint', + required=False, + help='Fingerprint of the keypair in GnuPG (spaces ok). ' +) +@click.option( + '--output-path', + required=False, + type=click.Path(file_okay=True, dir_okay=False, writable=True), + help='Where to write the newly annotated result.' + '[default: overwrite input file]' +) +def annotation_create(input_path, annotation_type, name, + # note inputs + text, filepath, + # signature input + fingerprint, + output_path): + """Load a Result, add an Annotation and save.""" + import qiime2.sdk + from qiime2.core.annotate import Note, Signature + from rachis_cli.core.config import CONFIG + + result = qiime2.sdk.Result.load(input_path) + + if annotation_type == 'Note': + # enforce exactly one of `--text` or `--file` + if (text is None) == (filepath is None): + raise click.UsageError('Exactly one of `--text` or `--file`' + ' must be provided.') + # ensure Signature-specific inputs aren't used + if fingerprint: + raise click.UsageError('`--fingerprint` is only valid for ' + 'annotations of type Signature.') + + annotation = Note(name=name, text=text, filepath=filepath) + + elif annotation_type == 'Signature': + if fingerprint is None: + raise click.UsageError('`--signature` must be provided.') + if text or filepath: + raise click.UsageError('`--text`/`--filepath` are only valid for ' + 'annotations of type Note.') + + annotation = Signature(name=name, fingerprint=fingerprint) + + else: + raise click.BadParameter('Unsupported annotation type: ' + f'{annotation_type}') + + try: + result.add_annotation(annotation) + except Exception as e: + click.echo(CONFIG.cfg_style('error', str(e)), err=True) + raise click.Abort() + + out_fp = output_path or input_path + result.save(out_fp) + + click.echo( + CONFIG.cfg_style('success', + f'Added {annotation_type} "{name}" to {out_fp}') + ) + + +@tools.command( + name='annotation-remove', + short_help='Remove an Annotation from a rachis Result.', + help='Remove an existing Annotation (by name) from' + ' an Artifact or Visualization.', + cls=ToolCommand +) +@click.option( + '--input-path', + required=True, + type=click.Path(exists=True, dir_okay=False, readable=True), + help='The `.qza` or `.qzv` from which to remove the Annotation.' +) +@click.option( + '--name', + required=True, + help='The name of the Annotation to remove.' +) +@click.option( + '--output-path', + required=False, + type=click.Path(dir_okay=False, writable=True), + help='Where to write the updated Result.' + '[default: overwrite input filepath]' +) +def annotation_remove(input_path, name, output_path): + """Load a Result, remove an Annotation by name and save.""" + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + result = qiime2.sdk.Result.load(input_path) + + try: + result.remove_annotation(name) + except Exception as e: + click.echo(CONFIG.cfg_style('error', str(e)), err=True) + raise click.Abort() + + out_fp = output_path or input_path + result.save(out_fp) + + click.echo( + CONFIG.cfg_style('success', + f'Removed annotation "{name}" from {out_fp}') + ) + + +@tools.command( + name='annotation-fetch', + short_help='Fetch an Annotation from a rachis Result.', + help='Fetch an existing Annotation (by name) from' + ' an Artifact or Visualization.', + cls=ToolCommand +) +@click.option( + '--input-path', + required=True, + type=click.Path(exists=True, dir_okay=False, readable=True), + help='The `.qza` or `.qzv` to fetch the Annotation from.' +) +@click.option( + '--name', + required=True, + help='The name of the Annotation to fetch.' +) +@click.option( + '--verbose/--no-verbose', + default=False, + help='Whether to display the contents of the Annotation.' +) +def annotation_fetch(input_path, name, verbose): + """ + Load a Result, retrieve an Annotation by name and display the contents. + """ + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + result = qiime2.sdk.Result.load(input_path) + + try: + annotation = result.get_annotation(name) + except Exception as e: + click.echo(CONFIG.cfg_style('error', str(e)), err=True) + raise click.Abort() + + click.echo(CONFIG.cfg_style('type', "ID")+": ", nl=False) + click.echo(annotation.id) + click.echo(CONFIG.cfg_style('type', "name")+": ", nl=False) + click.echo(annotation.name) + click.echo(CONFIG.cfg_style('type', "type")+": ", nl=False) + click.echo(annotation.annotation_type) + if verbose: + if annotation.annotation_type == 'Signature': + click.echo('', nl=True) + click.echo(CONFIG.cfg_style('type', "signer_name")+": ", nl=False) + click.echo(annotation.signer_name) + click.echo(CONFIG.cfg_style('type', "signer_email")+": ", nl=False) + click.echo(annotation.signer_email) + click.echo(CONFIG.cfg_style('type', "fingerprint")+": ", nl=False) + click.echo(annotation.fingerprint) + else: + click.echo('', nl=True) + click.echo(CONFIG.cfg_style('type', "contents")+": ", nl=False) + click.echo(annotation.contents) + + +@tools.command( + name='annotation-list', + short_help='List Annotations on a rachis Result.', + help='List all Annotations that are attached to' + ' an Artifact or Visualization.', + cls=ToolCommand +) +@click.option( + '--input-path', + required=True, + type=click.Path(exists=True, dir_okay=False, readable=True), + help='The `.qza` or `.qzv` to list Annotations from.' +) +def annotation_list(input_path): + """ + Load a Result, retrieve all Annotations + and display their ids, names & types. + """ + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + result = qiime2.sdk.Result.load(input_path) + + try: + annotations_iter = result.iter_annotations() + except Exception as e: + click.echo(CONFIG.cfg_style('error', str(e)), err=True) + raise click.Abort() + + annotations = list(annotations_iter) + # we may as well raise an error here to make it clear + # that there are no annotations on the given result + if not annotations: + raise click.ClickException("No Annotations found.") + + for annotation in annotations: + click.echo(CONFIG.cfg_style('type', "ID")+": ", nl=False) + click.echo(annotation.id) + click.echo(CONFIG.cfg_style('type', "name")+": ", nl=False) + click.echo(annotation.name) + click.echo(CONFIG.cfg_style('type', "type")+": ", nl=False) + click.echo(annotation.annotation_type) + click.echo('', nl=True) + + +@tools.command( + name='signature-verify', + short_help='Verify a Signature Annotation on a rachis Result.', + help='Verify the Signature fingerprint matches an existing key pair on ' + 'the local GPG keyring, the checksum digest matches a newly ' + 'calculated sha512sum of the root checksums file, and the ' + 'Signature-level checksums match with the expected sha512sum of ' + 'each file on a rachis Result.', + cls=ToolCommand +) +@click.option( + '--input-path', + required=True, + type=click.Path(exists=True, dir_okay=False, readable=True), + help='The `.qza` or `.qzv` to verify a Signature from.' +) +@click.option( + '--name', + required=True, + help='The name of the Signature to verify.' +) +def signature_verify(input_path, name): + import qiime2.sdk + from rachis_cli.core.config import CONFIG + + result = qiime2.sdk.Result.load(input_path) + + try: + result.verify(name) + except Exception as e: + click.echo(CONFIG.cfg_style('error', str(e)), err=True) + raise click.Abort() + + click.echo(CONFIG.cfg_style('success', + f'Signature {name} on Result ' + f'{input_path} verified successfully.')) + + +@tools.command( + name='redact-metadata', + short_help='Remove metadata from a Result.', + help='Remove all metadata from a Result and return the Result ' + 'otherwise unchanged.' +) +@click.option( + '--input-path', + required=True, + help='The path to the Result to remove metadata from.' +) +@click.option( + '--output-path', + required=True, + help='Path to save the Result with redacted metadata to.' +) +def redact_metadata(input_path, output_path): + from rachis.sdk.result import Result + from rachis_cli.core.config import CONFIG + + result = Result.load(input_path) + + try: + result.redact_metadata() + except ValueError as e: + click.echo(CONFIG.cfg_style('error', str(e)), err=True) + raise click.Abort() + + result.save(output_path) + + click.echo( + CONFIG.cfg_style( + 'success', + f'Succesfully redacted metadata from {input_path}, and saved to ' + f'{output_path}.' + ) + ) diff --git a/rachis_cli/click/__init__.py b/rachis_cli/click/__init__.py new file mode 100644 index 00000000..5a85cefd --- /dev/null +++ b/rachis_cli/click/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- diff --git a/rachis_cli/click/command.py b/rachis_cli/click/command.py new file mode 100644 index 00000000..dbd4ced9 --- /dev/null +++ b/rachis_cli/click/command.py @@ -0,0 +1,365 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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): + from rachis_cli.core.config import CONFIG + 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.echo(CONFIG.cfg_style('problem', + problems.center(78, ' ')), 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.echo(CONFIG.cfg_style('error', msg), err=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): + from rachis_cli.core.config import CONFIG + """Writes the usage line into the formatter.""" + pieces = self.collect_usage_pieces(ctx) + formatter.write_usage(CONFIG.cfg_style('command', ctx.command_path), + ' '.join(pieces)) + + def format_options(self, ctx, formatter, COL_MAX=23, COL_MIN=10): + from rachis_cli.core.config import CONFIG + # 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((CONFIG.cfg_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 + from rachis_cli.core.config import CONFIG + 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 = CONFIG.cfg_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 = " " + CONFIG.cfg_style('type', line) + first_iter = False + else: + dangling_edge = len(type_indent) + len(line) + line = type_indent + CONFIG.cfg_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, CONFIG.cfg_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) + CONFIG.cfg_style( + 'default_arg', requirements) + '\n') + + def _color_important(self, tokens, ctx): + import re + from rachis_cli.core.config import CONFIG + + 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 = CONFIG.cfg_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 diff --git a/rachis_cli/click/licenses/click.LICENSE.rst b/rachis_cli/click/licenses/click.LICENSE.rst new file mode 100644 index 00000000..6078d1bc --- /dev/null +++ b/rachis_cli/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. + diff --git a/rachis_cli/click/option.py b/rachis_cli/click/option.py new file mode 100644 index 00000000..17f627e2 --- /dev/null +++ b/rachis_cli/click/option.py @@ -0,0 +1,350 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 rachis_cli.util + + if metadata is not None: + prefix = 'm' + if multiple is not None: + if multiple == 'list': + multiple = list + elif multiple == 'dict': + multiple = dict + else: + multiple = set + + if is_bool_flag: + yes = rachis_cli.util.to_cli_name(name) + no = rachis_cli.util.to_cli_name('no_' + name) + opt = f'--{prefix}-{yes}/--{prefix}-{no}' + elif metadata is not None: + cli_name = rachis_cli.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 = rachis_cli.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 + + # This nonsense: + # https://github.com/pallets/click/blob + # /08f71b08e2b7ee9b1ea27daf6d3040999fc68551 + # /src/click/core.py#L2576-L2584 + if is_bool_flag and multiple is not None: + to_add_multiple = attrs.pop('multiple') + + super().__init__(**attrs) + + if is_bool_flag and multiple is not None: + self.multiple = to_add_multiple + + # 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 + # this consume deals with the metadata file + md_file, source = super().consume_value(ctx, opts) + # consume uses self.name, so mutate but backup for after + backup, self.name = self.name, self.q2_extra_dest + try: + # this consume deals with the metadata column + md_col, _ = super().consume_value(ctx, opts) + # If `--m-metadata-column` isn't provided, need to set md_col to None + # in order for the click.MissingParameter errors below to be raised + except click.MissingParameter: + md_col = None + + self.name = backup + # These branches won't get hit unless there's a value associated with + # md_col - the try/except case above handled the situation where the + # metadata_column parameter itself wasn't provided (vs just a value) + 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, source) + else: + return ((md_file, md_col), source) + + 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(opts=self.opts, action='store', dest=self.name, + nargs=1, obj=self) + parser.add_option(opts=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(opts=self.opts, action=action, const=True, + **shared) + parser.add_option(opts=self.secondary_opts, action=action, + const=False, **shared) + elif self.multiple: + action = 'append_greedy' + parser.add_option(opts=self.opts, action='append_greedy', **shared) + else: + super().add_to_parser(parser, ctx) + + def get_default(self, ctx, call=True): + if self.required and not ctx.resilient_parsing and not ( + self.q2_prefix == 'o' and ctx.params.get('output_dir', False)): + raise click.MissingParameter(ctx=ctx, param=self) + return super().get_default(ctx, call=call) + + def process_value(self, ctx, value): + try: + return super().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 rachis_cli.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) + keys, value = self._split_and_validate_input_keys(value) + + if self.q2_multiple is set: + self._check_length(value, ctx) + + # This means we loaded a proper Collection directory. When we + # load in a Collection directory for an action that takes a + # Collection input, we get a tuple containing a dictionary of + # the Collection we wanted. When we load in a Collection + # directory for an action that takes a List, we get a list + # containing a dictionary of the Collection we wanted. We just + # extract that dictionary. + if (isinstance(value, tuple) or isinstance(value, list)) \ + and len(value) == 1 and isinstance(value[0], dict): + value = value[0] + + # We already have a dict, so we already have keys + if isinstance(value, dict): + keys = value.keys() + value = list(value.values()) + elif self.q2_multiple is dict: + if keys is None: + keys = range(len(value)) + value = value + else: + 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( + 'received <%s> as an argument, which is incompatible' + ' with parameter type: %r' % (args, type_expr), + ctx=ctx, param=self) + + if self.q2_multiple is dict: + value = {str(k): v for k, v in zip(keys, value)} + 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 " + "rachis Metadata:") + tb = 'stderr' if '--verbose' in sys.argv else None + rachis_cli.util.exit_with_error( + e, header=header, traceback=tb) + elif self.q2_prefix == 'p': + try: + _values = [] + + if self.q2_multiple is set: + self._check_length(value, ctx) + + keys = [] + if self.q2_multiple is dict and type(value) is not dict: + _values = {} + + keyed = False + unkeyed = False + # All params in a Collection must be either keyed or + # unkeyed. We cannot have a mix because it makes things + # ambiguous + for idx, item in enumerate(value): + if ':' in item: + if unkeyed: + raise KeyError( + 'The keyed value <%s> has been mixed' + ' with unkeyed values. All values must' + ' be keyed or unkeyed.' % item) + key, _value = item.split(':', 1) + _values[key] = _value + keyed = True + else: + if keyed: + raise KeyError( + 'The unkeyed value <%s> has been' + ' mixed with keyed values. All values' + ' must be keyed or unkeyed.' % item) + _values[str(idx)] = item + unkeyed = True + else: + _values = value + + value = \ + qiime2.sdk.util.parse_primitive(self.q2_ast, _values) + except ValueError: + args = ', '.join(map(repr, value)) + expr = qiime2.sdk.util.type_from_ast(self.q2_ast) + raise click.BadParameter( + 'received <%s> as an argument, which is incompatible' + ' with parameter type: %r' % (args, expr), + ctx=ctx, param=self) + return value + elif self.q2_prefix == 'i': + value = super().type_cast_value(ctx, value) + if value is not None: + return value[1] + return value + + # We have an output here + return super().type_cast_value(ctx, value) + + def _split_and_validate_input_keys(self, value): + """ This function ensures that if a user passed in a de-facto + collection they did so properly. + """ + keys = [t[0] for t in value] + values = [t[1] for t in value] + + if any(key is not None and not key.isidentifier() for key in keys): + raise ValueError('All keys must be valid Python identifiers.' + ' Python identifier rules may be found here' + ' https://www.askpython.com/python/' + 'python-identifiers-rules-best-practices') + + # If we had no keys, we are fine + if all(key is None for key in keys): + return None, values + + has_nones = any(key is None for key in keys) + has_keys = any(key is not None for key in keys) + + # We cannot have keys for something that isn't a dict + if self.q2_multiple is not dict and has_keys: + raise ValueError('Keyed values may only be supplied for ' + 'Collection inputs.') + # We cannot have a mixture of keyed and unkeyed values + elif self.q2_multiple is dict and has_keys and has_nones: + raise ValueError('Keyed values cannot be mixed with unkeyed ' + 'values.') + + return keys, values + + def _check_length(self, value, ctx): + import collections + + if isinstance(value, tuple) and len(value) == 1 and \ + isinstance(value[0], dict): + value = list(value[0].values()) + + 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( + 'received <%s> as an argument, which contains duplicates' + ' of the following: <%s>' % (args, dups), ctx=ctx, param=self) diff --git a/rachis_cli/click/parser.py b/rachis_cli/click/parser.py new file mode 100644 index 00000000..cb426961 --- /dev/null +++ b/rachis_cli/click/parser.py @@ -0,0 +1,162 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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': + 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, obj, opts, dest, action=None, nargs=1, const=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. + """ + 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(obj=obj, opts=opts, dest=dest, action=action, + nargs=nargs, const=const) + # 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): + from rachis_cli.core.artifact_cache_global import ( + set_used_artifact_cache) + + backup = args.copy() # args will be mutated by super() + try: + # We need to set this before we would normally parse it out so we + # can use the requested cache for all the operations that use a + # cache. Look for all uses of USED_ARTIFACT_CACHE. Some of these + # are during arg parsing + if '--use-cache' in backup: + set_used_artifact_cache(backup) + + 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 rachis_cli.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) diff --git a/rachis_cli/click/type.py b/rachis_cli/click/type.py new file mode 100644 index 00000000..30a9aef1 --- /dev/null +++ b/rachis_cli/click/type.py @@ -0,0 +1,194 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 + from rachis_cli.core.artifact_cache_global import ( + get_used_artifact_cache) + + with get_used_artifact_cache(): + 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 + from rachis_cli.util import output_in_cache + from qiime2.core.type.util import is_collection_type + # Click path fails to validate writability on new paths + + # Check if our output path is actually in a cache and if it is skip our + # other checks + if output_in_cache(value): + return value + + 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 (directory and not os.path.exists(directory) + and not is_collection_type(param.type.type_expr)): + self.fail('Directory %r does not exist, cannot save %r into it.' + % (directory, os.path.basename(value)), param, ctx) + + 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 + import rachis_cli.util + + try: + result, error = rachis_cli.util._load_input(value) + if result is not None: + result_value = result[1] + except Exception as e: + header = f'There was a problem loading {value!r} as an artifact:' + rachis_cli.util.exit_with_error( + e, header=header, traceback='stderr') + + if error: + self.fail(str(error), param, ctx) + # We want to use click's fail to pretty print whatever error we got + # from get_input + + if isinstance(result_value, 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 rachis 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_value 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_value.type), param, ctx) + return result + + def _convert_metadata(self, value, param, ctx): + import rachis_cli.util + + if self.type_expr.name == 'MetadataColumn': + value, column = value + + metadata = rachis_cli.util.load_metadata(value) + + 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 + + try: + return qiime2.sdk.util.parse_primitive(self.type_expr, value) + except ValueError: + expr = qiime2.sdk.util.type_from_ast(self.type_ast) + raise click.BadParameter( + 'received <%s> as an argument, which is incompatible' + ' with parameter type: %r' % (value, expr), + ctx=ctx) + + @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)' diff --git a/rachis_cli/commands.py b/rachis_cli/commands.py new file mode 100644 index 00000000..49ad2eda --- /dev/null +++ b/rachis_cli/commands.py @@ -0,0 +1,676 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 rachis_cli.builtin.dev +import rachis_cli.builtin.info +import rachis_cli.builtin.tools + +from rachis_cli.click.command import BaseCommandMixin +from rachis_cli.core.config import CONFIG + + +class RootCommand(BaseCommandMixin, click.MultiCommand): + """This class defers to either the PluginCommand or the builtin cmds""" + _builtin_commands = { + 'info': rachis_cli.builtin.info.info, + 'tools': rachis_cli.builtin.tools.tools, + 'dev': rachis_cli.builtin.dev.dev + } + + def __init__(self, *args, **kwargs): + import re + import sys + + unicodes = ["\u2018", "\u2019", "\u201C", "\u201D", "\u2014", "\u2013"] + category_regex = re.compile(r'--m-(\S+)-category') + + invalid_chars = [] + categories = [] + for command in sys.argv: + if any(x in command for x in unicodes): + invalid_chars.append(command) + + match = category_regex.fullmatch(command) + if match is not None: + param_name, = match.groups() + # Maps old-style option name to new name. + categories.append((command, '--m-%s-column' % param_name)) + + if invalid_chars or categories: + if invalid_chars: + msg = ("Error: Detected invalid character in: %s\nVerify the " + "correct quotes or dashes (ASCII) are being used." + % ', '.join(invalid_chars)) + click.echo(CONFIG.cfg_style('error', msg), err=True) + if categories: + old_to_new_names = '\n'.join( + 'Instead of %s, trying using %s' % (old, new) + for old, new in categories) + msg = ("Error: The following options no longer exist because " + "metadata *categories* are now called metadata " + "*columns* in rachis.\n\n%s" % old_to_new_names) + click.echo(CONFIG.cfg_style('error', msg), err=True) + sys.exit(-1) + + super().__init__(*args, **kwargs) + + # Plugin state for current deployment that will be loaded from cache. + # Used to construct the dynamic CLI. + self._plugins = None + + @property + def _plugin_lookup(self): + import rachis_cli.util + + # See note in `rachis_cli.completion.write_bash_completion_script` + # for why `self._plugins` will not always be obtained from + # `rachis_cli.cache.CACHE.plugins`. + if self._plugins is None: + import rachis_cli.core.cache + self._plugins = rachis_cli.core.cache.CACHE.plugins + + name_map = {} + for name, plugin in self._plugins.items(): + if plugin['actions']: + name_map[rachis_cli.util.to_cli_name(name)] = plugin + return name_map + + def list_commands(self, ctx): + import itertools + + # Avoid sorting builtin commands as they have a predefined order based + # on applicability to users. For example, it isn't desirable to have + # the `dev` command listed before `info` and `tools`. + builtins = self._builtin_commands + plugins = sorted(self._plugin_lookup) + return itertools.chain(builtins, plugins) + + def get_command(self, ctx, name): + if name in self._builtin_commands: + return self._builtin_commands[name] + + try: + plugin = self._plugin_lookup[name] + except KeyError: + from rachis_cli.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.echo( + CONFIG.cfg_style('error', "Error: rachis has no " + "plugin/command named %r." % name + hint), + err=True) + ctx.exit(2) # Match exit code of `return None` + + return PluginCommand(plugin, name) + + +class PluginCommand(BaseCommandMixin, click.MultiCommand): + """Provides ActionCommands based on available Actions""" + def __init__(self, plugin, name, *args, **kwargs): + import rachis_cli.util + + # the cli currently doesn't differentiate between methods + # and visualizers, it treats them generically as Actions + self._plugin = plugin + self._action_lookup = {} + self._hidden_actions = {} + + # Hide actions that start with _ by default + for id, a in plugin['actions'].items(): + if id.startswith('_'): + self._hidden_actions[rachis_cli.util.hidden_to_cli_name(id)] = a + else: + self._action_lookup[rachis_cli.util.to_cli_name(id)] = a + + support = 'Getting user support: %s' % plugin['user_support_text'] + website = 'Plugin website: %s' % plugin['website'] + description = 'Description: %s' % plugin['description'] + help_ = '\n\n'.join([description, website, support]) + + params = [ + click.Option(('--version',), is_flag=True, expose_value=False, + is_eager=True, callback=self._get_version, + help='Show the version and exit.'), + rachis_cli.util.example_data_option(self._get_plugin), + rachis_cli.util.citations_option(self._get_citation_records) + ] + + if self._hidden_actions != {}: + params.append( + click.Option( + ('--show-hidden-actions',), is_flag=True, + expose_value=False, is_eager=True, + callback=self._get_hidden_actions, + help="This plugin has hidden actions with names starting " + "with '_'. These are generally called internally by " + "pipelines. Passing this flag will display those " + "actions.")) + + super().__init__(name, *args, short_help=plugin['short_description'], + help=help_, params=params, **kwargs) + + def _get_version(self, ctx, param, value): + if not value or ctx.resilient_parsing: + return + + import rachis_cli.util + pm = rachis_cli.util.get_plugin_manager() + for plugin in pm.plugins.values(): + if (self._plugin['name'] == plugin.name): + pkg_name = plugin.project_name + pkg_version = plugin.version + break + else: + pkg_name = pkg_version = "[UNKNOWN]" + + click.echo( + "rachis 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): + import rachis_cli.util + pm = rachis_cli.util.get_plugin_manager() + return pm.plugins[self._plugin['name']].citations + + def _get_hidden_actions(self, ctx, param, value): + """Add actions that start with _ back to the lookup""" + # Click calls this whether the flag was provided or not. If the flag + # was not provided value is, unsurprisingly, False. We do not want to + # execute this if the flag was not provided. + # + # Resilient parsing has something to do with ignoring default values + # which for this is False. Honestly not 100% sure why we need that + # here, but it is in the check in _get_version, and I a mimicking that + if not value or ctx.resilient_parsing: + return + + from click.utils import echo + + self._action_lookup.update(self._hidden_actions) + + # Handle the printing and exiting here. This feels like a pretty + # serious misuse of click, but it probably isn't the most egregious in + # the cli + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + def _get_plugin(self): + import rachis_cli.util + pm = rachis_cli.util.get_plugin_manager() + return pm.plugins[self._plugin['name']] + + def list_commands(self, ctx): + return sorted(self._action_lookup) + + def get_command(self, ctx, name): + try: + # Hidden actions are still valid commands + self._action_lookup.update(self._hidden_actions) + action = self._action_lookup[name] + except KeyError: + from rachis_cli.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.echo( + CONFIG.cfg_style('error', "Error: rachis plugin %r has no " + "action %r." % (self._plugin['name'], name) + + hint), err=True) + ctx.exit(2) # Match exit code of `return None` + + return ActionCommand(name, self._plugin, action) + + +class ActionCommand(BaseCommandMixin, click.Command): + """A click manifestation of a rachis API Action (Method/Visualizer) + + """ + def __init__(self, name, plugin, action): + import rachis_cli.util + import rachis_cli.click.type + + self.plugin = plugin + self.action = action + + self._inputs, self._params, self._outputs = \ + self._build_generated_options() + + self._misc = [ + click.Option(['--output-dir'], + type=rachis_cli.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).') + ] + + # If this action is a pipeline it needs additional options for + # recycling and parallelization + if action['type'] == 'pipeline': + self._misc.extend([ + click.Option(['--recycle-pool'], required=False, + type=str, + help='Use a cache pool for pipeline resumption. ' + 'rachis will cache your results in this ' + 'pool for reuse by future invocations. ' + 'These pool are retained until deleted by ' + 'the user. If not provided, rachis will ' + 'create a pool which is automatically ' + 'reused by invocations of the same action ' + 'and removed if the action is successful. ' + 'Note: these pools are local to the ' + 'cache you are using.'), + click.Option(['--no-recycle'], is_flag=True, required=False, + help='Do not recycle results from a previous ' + 'failed pipeline run or save the results ' + 'from this run for future recycling.'), + click.Option(['--parallel'], is_flag=True, required=False, + help='Execute your action in parallel. This flag ' + 'will use your default parallel config.'), + click.Option(['--parallel-config'], required=False, + type=click.Path(exists=True, dir_okay=False), + help='Execute your action in parallel using a ' + 'config at the indicated path.')]) + + # TODO: update default cache name from qiime2 to rachis + self._misc.extend([ + rachis_cli.util.example_data_option( + self._get_plugin, self.action['id']), + rachis_cli.util.citations_option(self._get_citation_records), + click.Option(['--use-cache'], required=False, + type=click.Path(exists=True, file_okay=False), + help='Specify the cache to be used for the ' + 'intermediate work of this action. If not ' + 'provided, the default cache under ' + '$TMP/qiime2/ will be used. ' + 'IMPORTANT FOR HPC USERS: If you are on an HPC ' + 'system and are using parallel execution it is ' + 'important to set this to a location that is ' + 'globally accessible to all nodes in the ' + 'cluster.')]) + + options = [*self._inputs, *self._params, *self._outputs, *self._misc] + help_ = [action['description']] + + if self.action['deprecated']: + help_.append(CONFIG.cfg_style( + 'warning', 'WARNING:\n\nThis command is deprecated and will ' + 'be removed in a future version of this plugin.')) + elif self.action['migrated']: + mig_dict = self.action['migrated'] + mig_help = self._format_migration_help( + rachis_cli.util.to_cli_name(self.plugin['name']), mig_dict + ) + help_.append(CONFIG.cfg_style('warning', mig_help)) + + super().__init__(name, params=options, callback=self, + short_help=action['name'], help='\n\n'.join(help_)) + + def _format_migration_help(self, plugin, mig_dict): + # required + to_plugin = mig_dict['to_plugin'] + # optional + from_distro = mig_dict.get('from_distro') + to_distro = mig_dict.get('to_distro') + epoch = mig_dict.get('epoch') + + base_msg = f'is slated for migration from the {plugin} plugin' + destination = f'to the {to_plugin} plugin' + + if from_distro: + base_msg += f' of the {from_distro} distribution' + + if to_distro: + destination += f' of the {to_distro} distribution' + + when = f'in {epoch}' if epoch else 'in a future release' + + return ( + 'WARNING:\n\n' + f'This command {base_msg} {destination} {when}.' + ) + + def _build_generated_options(self): + import rachis_cli.click.option + + inputs = [] + params = [] + outputs = [] + + for item in self.action['signature']: + item = item.copy() + type = item.pop('type') + + if type == 'input': + storage = inputs + elif type == 'parameter': + storage = params + else: + storage = outputs + + opt = rachis_cli.click.option.GeneratedOption(prefix=type[0], **item) + storage.append(opt) + + return inputs, params, outputs + + 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): + # Get plugin level citations + citations = list(self._get_plugin().citations) + # Add action level citations + citations.extend(self._get_action().citations) + return tuple(citations) + + def _get_plugin(self): + import rachis_cli.util + pm = rachis_cli.util.get_plugin_manager() + return pm.plugins[self.plugin['name']] + + def _get_action(self): + plugin = self._get_plugin() + return plugin.actions[self.action['id']] + + def __call__(self, **kwargs): + """Called when user hits return, **kwargs are Dict[click_names, Obj]""" + import os + import click + from contextlib import nullcontext + + import qiime2.util + from qiime2.core.cache import Cache + from qiime2.sdk import ResultCollection + + from rachis_cli.util import ( + output_in_cache, _get_cache_path_and_key, get_default_recycle_pool, + capture_rachis_warnings + ) + from rachis_cli.core.artifact_cache_global import ( + get_used_artifact_cache, unset_used_artifact_cache) + + cache = get_used_artifact_cache() + + output_dir = kwargs.pop('output_dir') + # If they gave us a cache and key combo as an output dir, we want to + # error out, so we check if their output dir contains a : and the part + # before it is a cache + if output_dir: + potential_cache = output_dir.rsplit(':', 1)[0] + if potential_cache and os.path.exists(potential_cache) and \ + Cache.is_cache(potential_cache): + exc = ValueError(f"The given output dir '{output_dir}' " + "appears to be a cache:key combo. Cache keys " + "cannot be used as output dirs.") + rachis_cli.util.exit_with_error(exc) + + # Args pertaining to pipeline resumption + recycle_pool = kwargs.pop('recycle_pool', None) + no_recycle = kwargs.pop('no_recycle', False) + + if recycle_pool is not None and no_recycle: + exc = ValueError('Cannot set a pool to be used for recycling and ' + 'no recycle simultaneously.') + rachis_cli.util.exit_with_error(exc) + + # We already dealt with this kwarg, we needed to do it earlier to make + # sure we used the desired cache for all operations. Look for the call + # site for set_used_artifact_cache + kwargs.pop('use_cache') + + parallel = kwargs.pop('parallel', False) + parallel_config_fp = kwargs.pop('parallel_config', None) + + if parallel_config_fp is not None: + parallel = True + + 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 + # Make sure our inputs are backed by the cache we are using. This + # is necessary for HPCs where our input .qzas may be in a location + # that is not globally accessible to the cluster. The user should + # be using a cache that is in a globally accessible location. We + # need to ensure we put our artifacts in that cache. + elif prefix == 'i': + value_ = value + + if isinstance(value, list): + value_ = [cache.process_pool.save(v) + for v in value] + elif isinstance(value, dict) or \ + isinstance(value, ResultCollection): + value_ = { + k: cache.process_pool.save(v) + for k, v in value.items()} + elif isinstance(value, set): + value_ = set([cache.process_pool.save(v) + for v in value]) + elif value is not None: + value_ = cache.process_pool.save(value) + + arguments[key] = value_ + else: + arguments[key] = value + + outputs = self._order_outputs(init_outputs) + action = self._get_action() + + # If --no-recycle is not set, pipelines attempt to recycle their + # outputs from a pool by default allowing recovery of failed pipelines + # from point of failure without needing to restart the pipeline from + # the beginning + default_pool = get_default_recycle_pool( + f'{action.plugin_id}_{action.id}') + if not no_recycle and action.type == 'pipeline' and \ + recycle_pool is None: + # We implicitly use a pool named + # recycle___ if no pool is + # provided + recycle_pool = default_pool + + if recycle_pool is not None and recycle_pool != default_pool and \ + recycle_pool not in cache.get_pools(): + msg = ("The pool '%s' does not exist on the cache at '%s'. It " + "will be created." % + (recycle_pool, cache.path)) + click.echo(CONFIG.cfg_style('warning', msg)) + + # `qiime2.util.redirected_stdio` defaults to stdout/stderr when + # supplied `None`. + log = None + + if not verbose: + import tempfile + log = tempfile.NamedTemporaryFile(prefix='rachis-cli-err-', + suffix='.log', + delete=False, mode='w') + if action.deprecated: + # We don't need to worry about redirecting this, since it should a) + # always be shown to the user and b) the framework-originated + # FutureWarning will wind up in the log file in quiet mode. + + msg = ('Plugin warning from %s:\n\n%s is deprecated and ' + 'will be removed in a future version of this plugin.' % + (rachis_cli.util.to_cli_name(self.plugin['name']), + self.name)) + click.echo(CONFIG.cfg_style('warning', msg)) + + if action.migrated: + msg = ('Plugin warning from %s:\n\n%s' % ( + rachis_cli.util.to_cli_name(self.plugin['name']), + action._build_migration_message())) + click.echo(CONFIG.cfg_style('warning', msg)) + + cleanup_logfile = False + try: + with ( + capture_rachis_warnings() if not quiet else nullcontext(), + qiime2.util.redirected_stdio(stdout=log, stderr=log) + ): + if parallel: + from qiime2.sdk.parallel_config import \ + (load_config_from_file, ParallelConfig) + + action = action.parallel + if parallel_config_fp is None: + parallel_config = ParallelConfig() + else: + config, mapping = \ + load_config_from_file(parallel_config_fp) + parallel_config = ParallelConfig(config, mapping) + + with parallel_config: + results = self._execute_action( + action, arguments, cache, recycle_pool) + else: + results = self._execute_action( + action, arguments, cache, recycle_pool) + except Exception as e: + header = ('Plugin error from %s:' + % rachis_cli.util.to_cli_name(self.plugin['name'])) + if verbose: + # log is not a file + log = 'stderr' + rachis_cli.util.exit_with_error(e, header=header, traceback=log) + else: + cleanup_logfile = True + finally: + # OS X will reap temporary files that haven't been touched in + # 36 hours, double check that the log is still on the filesystem + # before trying to delete. Otherwise this will fail and the + # output won't be written. + if log and cleanup_logfile and os.path.exists(log.name): + log.close() + os.remove(log.name) + + if output_dir is not None: + os.makedirs(output_dir) + + for result, output in zip(results, outputs): + if isinstance(output, tuple) and len(output) == 1: + output = output[0] + + if output_in_cache(output) and output_dir is None: + cache_path, key = _get_cache_path_and_key(output) + output_cache = Cache(cache_path) + + if isinstance(result, ResultCollection): + output_cache.save_collection(result, key) + path = output + else: + output_cache.save(result, key) + path = output + else: + path = result.save(output) + + if not quiet: + if output_in_cache(output): + message = \ + f"Added {result.type} to cache: {cache_path} as: {key}" + else: + type = f'Collection[{list(result.values())[0].type}]' if \ + isinstance(result, ResultCollection) else result.type + message = f"Saved {type} to: {path}" + + click.echo(CONFIG.cfg_style('success', message)) + + # If we used a default recycle pool for a pipeline and the pipeline + # succeeded, then we need to clean up the pool. Make sure to do this at + # the very end so if a failure happens during writing results we still + # have them + # + # It is possible that another run of the same action already finished + # and removed the pool we were using in which case we do not want to + # attempt to delete it again and get an error + with cache.lock: + if recycle_pool == default_pool \ + and os.path.exists(cache.pools / recycle_pool): + cache.remove(recycle_pool) + + # Set the USED_ARTIFACT_CACHE back to the default cache. This is mostly + # useful for the tests that invoke actions back to back to back without + # exiting the process + unset_used_artifact_cache() + + def _execute_action(self, action, arguments, cache, recycle_pool=None): + with cache: + if recycle_pool is None: + results = action(**arguments) + results = results._result() + else: + pool = cache.create_pool( + key=recycle_pool, reuse=True) + with pool: + results = action(**arguments) + + # If we executed in a pool using parsl we need to get + # our results inside of the context manager to ensure + # that the pool is set for the entirety of the + # execution + results = results._result() + + return results + + def _order_outputs(self, outputs): + ordered = [] + for item in self.action['signature']: + if item['type'] == 'output': + ordered.append(outputs[item['name']]) + return ordered + + def format_epilog(self, ctx, formatter): + if self.action['epilog']: + with formatter.section(click.style('Examples', bold=True)): + for line in self.action['epilog']: + formatter.write(' ' * formatter.current_indent) + formatter.write(line) + formatter.write('\n') diff --git a/rachis_cli/core/__init__.py b/rachis_cli/core/__init__.py new file mode 100644 index 00000000..5a85cefd --- /dev/null +++ b/rachis_cli/core/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- diff --git a/rachis_cli/core/artifact_cache_global.py b/rachis_cli/core/artifact_cache_global.py new file mode 100644 index 00000000..8ff28256 --- /dev/null +++ b/rachis_cli/core/artifact_cache_global.py @@ -0,0 +1,81 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- + +# Do not make this the default cache. If this is the default cache then we will +# instantiate the default cache when the module is imported which will write a +# process pool to the default cache which is undesirable if that isn't the +# cache we will be using for this action +_USED_ARTIFACT_CACHE = None + + +def set_used_artifact_cache(args): + """Validates that the args passed in actually contain a valid path to an + existing cache following the --use-cache argument and if so sets that cache + as the used cache for this invocation. + + Parameters + ---------- + args : List[str] + The arguments provided on the cli to this rachis invocation/ + + NOTES + ----- + Should only be called if --use-cache is already known to be in the args + provided. + + Should only be called once to init the used cache for this invocation. + """ + from qiime2.core.cache import Cache + from rachis_cli.util import exit_with_error + + global _USED_ARTIFACT_CACHE + + use_cache_idx = args.index('--use-cache') + + # They need to provide some kind of arg to use_cache + if len(args) < use_cache_idx + 2: + exc = ValueError('--use-cache expected an argument but none was ' + 'provided.') + exit_with_error(exc) + + cache_path = args[use_cache_idx + 1] + + # The arg given should be a path that points to an existing cache + if not Cache.is_cache(cache_path): + exc = ValueError('--use-cache expected a path to an existing cache as ' + f"an argument but received '{cache_path}' which is " + 'not a path to an existing cache.') + exit_with_error(exc) + + _USED_ARTIFACT_CACHE = Cache(cache_path) + + +def unset_used_artifact_cache(): + """Set the USED_ARTIFACT_CACHE back to None. + """ + global _USED_ARTIFACT_CACHE + + _USED_ARTIFACT_CACHE = None + + +def get_used_artifact_cache(): + """If the used cache has been set then return it otherwise return the + default cache. We use this getter because we don't want to instantiate the + default cache unless that is the cache we are using. This is because if we + instantiate the default cache we will put a process pool in it, and we want + to avoid that unless necessary. + + Returns + ------- + Cache + The default cache if the user didn't set a cache or the cache they set + if they did set one. + """ + from qiime2.core.cache import Cache + + return Cache() if _USED_ARTIFACT_CACHE is None else _USED_ARTIFACT_CACHE diff --git a/rachis_cli/core/assets/cli_howto.txt b/rachis_cli/core/assets/cli_howto.txt new file mode 100644 index 00000000..17849d92 --- /dev/null +++ b/rachis_cli/core/assets/cli_howto.txt @@ -0,0 +1,28 @@ + +# Instructions for use: +# 1. Open this script in a text editor or IDE. Support for BASH +# syntax highlighting can be helpful. +# 2. Search or scan visually for '<' or '>' characters to find places where +# user input (e.g. a filepath or column name) is required. These must be +# replaced with your own values. E.g. -> 'patient_id'. +# Failure to remove '<' or '>' may result in `No such File ...` errors +# 3. Search for 'FIXME' comments in the script, and respond as directed. +# 4. Remove all 'FIXME' comments from the script completely. Failure to do so +# may result in 'Missing Option' errors +# 5. Adjust the arguments to the commands below to suit your data and metadata. +# If your data is not identical to that in the replayed analysis, +# changes may be required. (e.g. sample ids or rarefaction depth) +# 6. Optional: replace any filenames in this script that begin with 'XX' with +# unique file names to ensure they are preserved. rachis saves all outputs +# from all actions in this script to disk regardless of whether those +# outputs were in the original collection of replayed results. The filenames +# of "un-replayed" artifacts are prefixed with 'XX' so they may be easily +# located. These names are not guaranteed to be unique, so 'XX_table.qza' +# may be overwritten by another 'XX_table.qza' later in the script. +# 7. Activate your replay conda environment, and confirm you have installed all +# plugins used by the script. +# 8. Run this script with `bash `, or copy-paste commands +# into the terminal for a more interactive analysis. +# 9. Optional: to delete all results not required to produce the figures and +# data used to generate this script, navigate to the directory in which you +# ran the script and `rm XX*.qz*` diff --git a/rachis_cli/core/assets/copyright_note.txt b/rachis_cli/core/assets/copyright_note.txt new file mode 100644 index 00000000..603cdd23 --- /dev/null +++ b/rachis_cli/core/assets/copyright_note.txt @@ -0,0 +1,4 @@ +# This document is a representation of the scholarly work of the creator of the +# rachis Results provided as input to this software, and may be protected by +# intellectual property law. Please respect all copyright restrictions and +# licenses governing the use, modification, and redistribution of this work. diff --git a/rachis_cli/core/cache.py b/rachis_cli/core/cache.py new file mode 100644 index 00000000..84d18697 --- /dev/null +++ b/rachis_cli/core/cache.py @@ -0,0 +1,279 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- + + +class DeploymentCache: + """Cached CLI state for a rachis deployment. + + In this context, a rachis deployment is the set of installed Python + packages, including their exact versions, that register one or more rachis + plugins. The exact version of rachis-cli is also included in the + deployment. + + The deployment cache stores the current deployment's package names and + versions in a requirements.txt file under the cache directory. This file is + used to determine if the cache is outdated. If the cache is determined to + be outdated, it will be refreshed based on the current deployment state. + Thus, adding, removing, upgrading, or downgrading a plugin package or + rachis-cli itself will trigger a cache refresh. + + Two mechanisms are provided to force a cache refresh. Setting the + environment variable Q2CLIDEV to any value will cause the cache to be + refreshed upon instantiation. Calling `.refresh()` will also refresh the + cache. Forced refreshing of the cache is useful for plugin and/or + rachis-cli developers who want their changes to take effect in the CLI + without changing their package versions. + + Cached CLI state is stored in a state.json file under the cache directory. + It is not a public file format and it is not versioned. rachis-cli is + included as part of the rachis deployment so that the cached state can + always be read (or recreated as necessary) by the currently installed + version of rachis-cli. + + This class is intended to be a singleton because it is responsible for + managing the on-disk cache. Having more than one instance managing the + cache has the possibility of two instances clobbering the cache (e.g. in a + multithreaded/multiprocessing situation). Also, having a single instance + improves performance by only reading and/or refreshing the cache a + single time during its lifetime. Having two instances could, for example, + trigger two cache refreshes if Q2CLIDEV is set. To support these use-cases, + a module-level `CACHE` variable stores a single instance of this class. + + """ + + # Public API + + def __init__(self): + import os + + # Indicates if the cache has been refreshed. For performance purposes, + # the cache is only refreshed a single time (at maximum) during the + # object's lifetime. Thus, "hot reloading" isn't supported, but this + # shouldn't be necessary for the CLI. + self._refreshed = False + + self._cache_dir = self._get_cache_dir() + + # TODO: update Q2CLIDEV to RACHISCLIDEV + refresh = 'Q2CLIDEV' in os.environ + self._state = self._get_cached_state(refresh=refresh) + + @property + def plugins(self): + """Decoded JSON object representing CLI state on a per-plugin basis.""" + return self._state['plugins'] + + def refresh(self): + """Trigger a forced refresh of the cache. + + If the cache has already been refreshed (either by this method or at + some point during instantiation), this method is a no-op. + + """ + if not self._refreshed: + self._state = self._get_cached_state(refresh=True) + + # Private API + + def _get_cache_dir(self): + import os + import rachis_cli.util + + cache_dir = rachis_cli.util.get_cache_dir() + os.makedirs(cache_dir, exist_ok=True) + return cache_dir + + def _get_cached_state(self, refresh): + import json + import os.path + import rachis_cli.util + + current_requirements = self._get_current_requirements() + state_path = os.path.join(self._cache_dir, 'state.json') + # See note on `get_completion_path` for why knowledge of this path + # exists in `rachis_cli.util` and not in this class. + completion_path = rachis_cli.util.get_completion_path() + + # The cache must be refreshed in the following cases: + + # 1) We have been explicitly told to refresh. + if refresh: + self._cache_current_state(current_requirements) + # 2) The current deployment requirements are different than the cached + # requirements. + elif current_requirements != self._get_cached_requirements(): + self._cache_current_state(current_requirements) + # 3) The cached state file does not exist. + elif not os.path.exists(state_path): + self._cache_current_state(current_requirements) + # 4) The cached bash completion script does not exist. + elif not os.path.exists(completion_path): + self._cache_current_state(current_requirements) + + def decoder(obj): + if obj.get('__q2type__', None) == 'set': + return set(obj['value']) + return obj + + # Now that the cache is up-to-date, read it. + try: + with open(state_path, 'r') as fh: + return json.load(fh, object_hook=decoder) + except json.JSONDecodeError: + # 5) The cached state file can't be read as JSON. + self._cache_current_state(current_requirements) + with open(state_path, 'r') as fh: + return json.load(fh, object_hook=decoder) + + # NOTE: The private methods below are all used internally within + # `_get_cached_state`. + + def _get_current_requirements(self): + """Includes installed versions of rachis_cli and rachis plugins.""" + import os + import importlib.metadata + import rachis_cli + + reqs = {f'rachis_cli=={rachis_cli.__version__}'} + + # A distribution (i.e. Python package) can have multiple plugins, where + # each plugin is its own entry point. A distribution's `Requirement` is + # hashable, and the `set` is used to exclude duplicates. Thus, we only + # gather the set of requirements for all installed Python packages + # containing one or more plugins. It is not necessary to track + # individual plugin names and versions in order to determine if the + # cache is outdated. + for entry_point in importlib.metadata.entry_points( + group='qiime2.plugins'): + if 'QIIMETEST' in os.environ: + if entry_point.name in ('dummy-plugin', 'other-plugin'): + reqs.add(f'{entry_point.name}=={entry_point.dist.version}') + else: + if entry_point.name not in ('dummy-plugin', 'other-plugin'): + reqs.add(f'{entry_point.name}=={entry_point.dist.version}') + + return reqs + + def _get_cached_requirements(self): + import os.path + + path = os.path.join(self._cache_dir, 'requirements.txt') + + if not os.path.exists(path): + # No cached requirements. The empty set will always trigger a cache + # refresh because the current requirements will, at minimum, + # contain rachis_cli. + return set() + else: + with open(path, 'r') as fh: + contents = fh.read() + try: + # Each line in the file is a different dep + deps = set(contents.split('\n')) + if '' in deps: + # Pop off the empty newline at the bottom of the file + deps.remove('') + return deps + except Exception: + # Unreadable cached requirements, trigger a cache refresh. + return set() + + def _cache_current_state(self, requirements): + import json + import os.path + import click + import rachis_cli.core.completion + import rachis_cli.util + + click.secho( + "rachis is caching your current deployment for improved " + "performance. This may take a few moments and should only happen " + "once per deployment.", fg='yellow', err=True) + + cache_dir = self._cache_dir + state = self._get_current_state() + + path = os.path.join(cache_dir, 'state.json') + + class Q2JSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, set): + return { + '__q2type__': 'set', + 'value': list(obj), + } + return super().default(obj) + + with open(path, 'w') as fh: + json.dump(state, fh, cls=Q2JSONEncoder) + + rachis_cli.core.completion.write_bash_completion_script( + state['plugins'], rachis_cli.util.get_completion_path()) + + # Write requirements file last because the above steps may raise errors + # (e.g. a plugin can't be loaded in `_get_current_state`). If any part + # of the cache writing fails, it needs to be refreshed the next time + # the cache is accessed. The absence of a requirements file will + # trigger this cache refresh, avoiding this bug: + # https://github.com/rachis-org/rachis-cli/issues/88 + path = os.path.join(cache_dir, 'requirements.txt') + with open(path, 'w') as fh: + for req in requirements: + # `str(Requirement)` is the recommended way to format a + # `Requirement` that can be read with `Requirement.parse`. + fh.write(str(req)) + fh.write('\n') + + self._refreshed = True + + def _get_current_state(self): + """Get current CLI state as an object that is serializable as JSON. + + WARNING: This method is very slow and should only be called when the + cache needs to be refreshed. + + """ + import rachis_cli.util + + state = { + 'plugins': {} + } + + plugin_manager = rachis_cli.util.get_plugin_manager() + for name, plugin in plugin_manager.plugins.items(): + state['plugins'][name] = self._get_plugin_state(plugin) + + return state + + def _get_plugin_state(self, plugin): + import rachis_cli.core.state + + state = rachis_cli.core.state.get_plugin_state(plugin) + for id, action in plugin.actions.items(): + state['actions'][id]['epilog'] = self._get_action_epilog(action) + + return state + + def _get_action_epilog(self, action): + import rachis_cli.core.usage + + lines = [] + for name, example in action.examples.items(): + use = rachis_cli.core.usage.CLIUsage() + + use.comment('### example: %s\n' % (name.replace('_', ' '),)) + example(use) + use.recorder.append('') + + lines += use.recorder + + return lines + + +# Singleton. Import and use this instance as necessary. +CACHE = DeploymentCache() diff --git a/rachis_cli/core/completion.py b/rachis_cli/core/completion.py new file mode 100644 index 00000000..39eaa3f2 --- /dev/null +++ b/rachis_cli/core/completion.py @@ -0,0 +1,170 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- + + +# NOTE: This module is closely coupled with `rachis_cli.cache`. It's a separate +# module to avoid cluttering `rachis_cli.cache` with completion script code, +# and provides a place to support other shell completion scripts in the future +# (e.g. zsh). + +def write_bash_completion_script(plugins, path): + """ + + Parameters + ---------- + plugins : dict + Decoded JSON object representing CLI state on a per-plugin basis (e.g. + as returned by `DeploymentCache.plugins`). See note within this + function for why this parameter is necessary. + path : str + Path to write completion script to. + + """ + import os + import os.path + import stat + import textwrap + from rachis_cli.__main__ import qiime as root + + # `write_bash_completion_script` is called by + # `rachis_cli.cache.DeploymentCache` when it is refreshing its cache. + # `rachis_cli.commands.RootCommand` could have already asked for the cache, + # for example, if the user ran a command and the cache must be refreshed. + # The bash completion script is generated by traversing the `RootCommand` + # tree, so there is a cycle when `RootCommand` attempts to access the + # cache in order to build itself. We work around this by bootstrapping + # the `RootCommand`'s `._plugins` attribute with the plugin state that + # has already been loaded by `DeploymentCache`. + root._plugins = plugins + + cmd_reply = _generate_command_reply(root) + cmd_reply = textwrap.indent(cmd_reply, ' ') + completion_script = COMPLETION_SCRIPT_TEMPLATE.format(cmd_reply=cmd_reply) + + with open(path, 'w') as fh: + fh.write(completion_script) + + # Make bash completion script executable: + # http://stackoverflow.com/a/12792002/3776794 + st = os.stat(path) + # Set executable bit for user,group,other for root/sudo installs + os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _generate_command_reply(cmd): + """Recursively generate completion reply for this command and subcommands. + + Parameters + ---------- + cmd : click.Command + Command to generate completion replies for (including its subcommands). + + """ + import textwrap + import click + + ctx = None + + options = ['--help'] + for param in cmd.params: + 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): + subcmd_names.extend(cmd.list_commands(ctx)) + + subcmd_cases = [] + for subcmd_name in subcmd_names: + subcmd_reply = _generate_command_reply( + cmd.get_command(ctx, subcmd_name)) + subcmd_reply = textwrap.indent(subcmd_reply, ' ') + + case = SUBCOMMAND_CASE_TEMPLATE.format( + subcmd_name=subcmd_name, subcmd_reply=subcmd_reply) + subcmd_cases.append(case) + + subcmd_cases = textwrap.indent('\n'.join(subcmd_cases), ' ' * 6) + + cmd_reply = COMMAND_REPLY_TEMPLATE.format( + options=' '.join(options), subcmd_names=' '.join(subcmd_names), + subcmd_cases=subcmd_cases) + + return cmd_reply + + +# NOTE: using double braces to avoid `str.format` interpolation when bash needs +# curly braces in the generated code. +# +# NOTE: the handling of a negative COMP_CWORD is necessary in certain versions +# of bash (e.g. at least the bash shipped with OS X 10.9.5). When adding +# whitespace to the end of a command, and then moving the cursor backwards in +# the command and hitting , COMP_CWORD can be negative (I've only seen -2 +# as its value). This is a bash bug and is not documented behavior. Other CLIs +# with tab completion suffer from the same issue, and each one deals with this +# bug differently (some not at all, e.g. `git`). The workaround used below +# seems to provide the least destructive completion behavior for our CLI. +# +# Bug report reference: +# https://lists.gnu.org/archive/html/bug-bash/2009-07/msg00108.html + +COMPLETION_SCRIPT_TEMPLATE = """\ +#!/usr/bin/env bash + +_rachis_completion() +{{ + local COMP_WORDS=(${{COMP_WORDS[*]}}) + local incomplete + if [[ ${{COMP_CWORD}} -lt 0 ]] ; then + COMP_CWORD="${{#COMP_WORDS[*]}}" + incomplete="" + else + incomplete="${{COMP_WORDS[COMP_CWORD]}}" + fi + + local curpos nextpos nextword + nextpos=0 + +{cmd_reply} + + return 0 +}} + +_rachis_completion +""" + +COMMAND_REPLY_TEMPLATE = """\ +curpos=${{nextpos}} +while : +do + nextpos=$((curpos + 1)) + nextword="${{COMP_WORDS[nextpos]}}" + if [[ ${{nextpos}} -eq ${{COMP_CWORD}} ]] ; then + if [[ ${{incomplete}} == -* ]] ; then + echo "$(compgen -W "{options}" -- $incomplete)" + else + echo "$(compgen -W "{subcmd_names}" -- $incomplete)" + fi + return 0 + else + case "${{nextword}}" in +{subcmd_cases} + esac + curpos=${{nextpos}} + fi +done\ +""" + +SUBCOMMAND_CASE_TEMPLATE = """\ +{subcmd_name}) +{subcmd_reply} + ;; +""" diff --git a/rachis_cli/core/config.py b/rachis_cli/core/config.py new file mode 100644 index 00000000..99fd7573 --- /dev/null +++ b/rachis_cli/core/config.py @@ -0,0 +1,120 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os +import configparser + +import click + +import rachis_cli.util + + +class CLIConfig(): + path = os.path.join(rachis_cli.util.get_app_dir(), 'cli-colors.theme') + VALID_SELECTORS = frozenset( + ['option', 'type', 'default_arg', 'command', 'emphasis', 'problem', + 'warning', 'error', 'required', 'success']) + VALID_STYLINGS = frozenset( + ['fg', 'bg', 'bold', 'dim', 'underline', 'blink', 'reverse']) + VALID_COLORS = frozenset( + ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'bright_black', 'bright_red', 'bright_green', 'bright_yellow', + 'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white']) + VALID_BOOLEANS = {'true': True, + 'false': False, + 't': True, + 'f': False} + + def __init__(self): + if os.path.exists(self.path): + self.styles = self.get_editable_styles() + try: + self.parse_file(self.path) + except Exception as e: + # Let's just be safe and make no attempt to use CONFIG to + # format this text if the CONFIG is broken + click.secho( + "We encountered the following error when parsing your " + f"theme:\n\n{str(e)}\n\nIf you want to use a custom " + "theme, please either import a new theme, or reset your " + "current theme. If you encountered this message while " + "importing a new theme or resetting your current theme, " + "ignore it.", + fg='red') + self.styles = self.get_default_styles() + else: + self.styles = self.get_default_styles() + + def get_default_styles(self): + return {'option': {'fg': 'bright_blue'}, + 'type': {'fg': 'green'}, + 'default_arg': {'fg': 'magenta'}, + 'command': {'fg': 'bright_blue'}, + 'emphasis': {'underline': True}, + 'problem': {'fg': 'yellow'}, + 'warning': {'fg': 'yellow', 'bold': True}, + 'error': {'fg': 'red', 'bold': True}, + 'required': {'underline': True}, + 'success': {'fg': 'green'}} + + # This maintains the default colors while getting rid of all the default + # styling modifiers so what the user puts in their file is all they'll see + def get_editable_styles(self): + return {'option': {}, + 'type': {}, + 'default_arg': {}, + 'command': {}, + 'emphasis': {}, + 'problem': {}, + 'warning': {}, + 'error': {}, + 'required': {}, + 'success': {}} + + def _build_error(self, current, valid_list, valid_string): + valids = ', '.join(valid_list) + raise configparser.Error(f'{current!r} is not a {valid_string}. The ' + f'{valid_string}s are:\n{valids}') + + def parse_file(self, fp): + if os.path.exists(fp): + parser = configparser.ConfigParser() + parser.read(fp) + for selector_user in parser.sections(): + selector = selector_user.lower() + if selector not in self.VALID_SELECTORS: + self._build_error(selector_user, self.VALID_SELECTORS, + 'valid selector') + for styling_user in parser[selector]: + styling = styling_user.lower() + if styling not in self.VALID_STYLINGS: + self._build_error(styling_user, self.VALID_STYLINGS, + 'valid styling') + val_user = parser[selector][styling] + val = val_user.lower() + if styling == 'fg' or styling == 'bg': + if val not in self.VALID_COLORS: + self._build_error(val_user, self.VALID_COLORS, + 'valid color') + else: + if val not in self.VALID_BOOLEANS: + self._build_error(val_user, self.VALID_BOOLEANS, + 'valid boolean') + val = self.VALID_BOOLEANS[val] + self.styles[selector][styling] = val + else: + raise configparser.Error(f'{fp!r} is not a valid filepath.') + + def cfg_style(self, selector, text, required=False): + kwargs = self.styles[selector] + if required: + kwargs = {**self.styles[selector], **self.styles['required']} + return click.style(text, **kwargs) + + +CONFIG = CLIConfig() diff --git a/rachis_cli/core/state.py b/rachis_cli/core/state.py new file mode 100644 index 00000000..1d3ce727 --- /dev/null +++ b/rachis_cli/core/state.py @@ -0,0 +1,182 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- + + +def get_plugin_state(plugin): + state = { + # TODO this conversion also happens in the framework + # (qiime2/plugins.py) to generate an importable module name from a + # plugin's `.name` attribute. Centralize this knowledge in the + # framework, ideally as a machine-friendly plugin ID (similar to + # `Action.id`). + 'id': plugin.name.replace('-', '_'), + 'name': plugin.name, + 'version': plugin.version, + 'website': plugin.website, + 'user_support_text': plugin.user_support_text, + 'description': plugin.description, + 'short_description': plugin.short_description, + 'actions': {} + } + + for id, action in plugin.actions.items(): + state['actions'][id] = get_action_state(action) + + return state + + +def get_action_state(action): + import itertools + + state = { + 'id': action.id, + 'name': action.name, + 'type': action.type, + 'description': action.description, + 'signature': [], + 'epilog': [], + 'deprecated': action.deprecated, + 'migrated': dict(action.migrated) if action.migrated else False, + } + + sig = action.signature + for name, spec in itertools.chain(sig.signature_order.items(), + sig.outputs.items()): + data = {'name': name, 'repr': _get_type_repr(spec.qiime_type), + 'ast': spec.qiime_type.to_ast()} + + if name in sig.inputs: + type = 'input' + elif name in sig.parameters: + type = 'parameter' + else: + type = 'output' + data['type'] = type + + if spec.has_description(): + data['description'] = spec.description + if spec.has_default(): + data['default'] = spec.default + + data['metavar'] = _get_metavar(spec.qiime_type) + data['multiple'], data['is_bool_flag'], data['metadata'] = \ + _special_option_flags(spec.qiime_type) + + state['signature'].append(data) + + return state + + +def _special_option_flags(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 _is_bool(type): + is_bool_flag = True + + return multiple, is_bool_flag, metadata + + +def _is_bool(type_expr): + return all(t.name == 'Bool' for t in type_expr) \ + and not type_expr.is_bottom() + + +def _get_type_repr(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) and \ + not qiime2.sdk.util.is_union(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(type): + import qiime2.sdk.util + + name_to_var = { + 'Visualization': 'VISUALIZATION', + 'Int': 'INTEGER', + 'Str': 'TEXT', + 'Float': 'NUMBER', + 'Bool': '', + 'Jobs': 'NJOBS', + 'Threads': 'NTHREADS', + } + + 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' + elif _is_bool(type): + metavar = '' + elif qiime2.sdk.util.is_union(type): + 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 diff --git a/rachis_cli/core/usage.py b/rachis_cli/core/usage.py new file mode 100644 index 00000000..0c0f2253 --- /dev/null +++ b/rachis_cli/core/usage.py @@ -0,0 +1,820 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 +import os +import importlib.resources +import re +import shlex +import textwrap +from typing import Any, Callable, Dict, List, Tuple, Type + +from qiime2 import ResultCollection +import qiime2.sdk.usage as usage +from qiime2.sdk.usage import ( + UsageVariable, Usage, UsageInputs, UsageOutputs +) +from qiime2.core.archive.provenance_lib.usage_drivers import ( + build_header, build_footer, ReplayUsageAction +) +from qiime2.core.archive.provenance_lib import ProvDAG + +import rachis_cli.util as util +from rachis_cli.core.state import get_action_state +import rachis_cli.click.option + + +def write_example_data(action, output_dir): + for example_name, example in action.examples.items(): + cli_name = util.to_cli_name(example_name) + example_path = os.path.join(output_dir, cli_name) + + use = CLIUsage() + example(use) + + for fn, val in use.get_example_data(): + os.makedirs(example_path, exist_ok=True) + path = os.path.join(example_path, fn) + val.save(path) + try: + hint = repr(val.type) + except AttributeError: + hint = 'Metadata' + + yield hint, path + + +def write_plugin_example_data(plugin, output_dir): + for name, action in plugin.actions.items(): + path = os.path.join(output_dir, util.to_cli_name(name)) + + yield from write_example_data(action, path) + + +class CLIUsageVariable(usage.UsageVariable): + EXT = { + 'artifact': '.qza', + 'artifact_collection': '/', + 'visualization_collection': '/', + 'visualization': '.qzv', + 'metadata': '.tsv', + 'column': '', + 'format': '', + } + + ELEMENT_EXT = { + 'artifact_collection': EXT['artifact'], + 'visualization_collection': EXT['visualization'] + } + + @property + def ext(self): + return self.EXT[self.var_type] + + @staticmethod + def to_cli_name(val): + return util.to_cli_name(val) + + def _key_helper(self, input_path, key): + if self.var_type not in self.COLLECTION_VAR_TYPES: + raise KeyboardInterrupt( + f'Cannot key non-collection type {self.var_type}') + + return "%s%s%s" % (input_path, key, self.ELEMENT_EXT[self.var_type]) + + def to_interface_name(self): + if hasattr(self, '_rachis_cli_ref'): + return self._rachis_cli_ref + + cli_name = '%s%s' % (self.name, self.ext) + + # don't disturb file names, this will break importing where rachis + # relies on specific filenames being present in a dir + if self.var_type not in ('format', 'column'): + cli_name = self.to_cli_name(cli_name) + + cli_name = shlex.quote(cli_name) + return cli_name + + def assert_has_line_matching(self, path, expression, key=None): + if not self.use.enable_assertions: + return + + INDENT = self.use.INDENT + input_path = self.to_interface_name() + expr = shlex.quote(expression) + + if key: + input_path = self._key_helper(input_path, key) + + lines = [ + 'rachis dev assert-result-data %s \\' % (input_path,), + INDENT + '--zip-data-path %s \\' % (path,), + INDENT + '--expression %s' % (expr,), + ] + + self.use.recorder.extend(lines) + + def assert_output_type(self, semantic_type, key=None): + if not self.use.enable_assertions: + return + + INDENT = self.use.INDENT + input_path = self.to_interface_name() + + if key: + input_path = self._key_helper(input_path, key) + + # TODO: update qiime-type to rachis-type ? + lines = [ + 'rachis dev assert-result-type %s \\' % (input_path,), + INDENT + '--qiime-type %s' % (str(semantic_type),), + ] + + self.use.recorder.extend(lines) + + +class CLIUsage(usage.Usage): + INDENT = ' ' * 2 + + def __init__(self, enable_assertions=False, action_collection_size=None): + super().__init__() + self.recorder = [] + self.init_data = [] + self.enable_assertions = enable_assertions + self.action_collection_size = action_collection_size + self.output_dir_counter = collections.defaultdict(int) + + def usage_variable(self, name, factory, var_type): + return CLIUsageVariable(name, factory, var_type, self) + + def render(self, flush=False): + rendered = '\n'.join(self.recorder) + if flush: + self.recorder = [] + self.init_data = [] + return rendered + + def init_artifact(self, name, factory): + variable = super().init_artifact(name, factory) + + self.init_data.append(variable) + + return variable + + def init_artifact_collection(self, name, factory): + variable = super().init_artifact_collection(name, factory) + + self.init_data.append(variable) + + return variable + + def construct_artifact_collection(self, name, members): + variable = super().construct_artifact_collection( + name, members + ) + + rc_dir = variable.to_interface_name() + + keys = members.keys() + names = [name.to_interface_name() for name in members.values()] + + keys_arg = '( ' + for key in keys: + keys_arg += f'{key} ' + keys_arg += ')' + names_arg = '( ' + for name in names: + names_arg += f'{name} ' + names_arg += ')' + + lines = [ + '## constructing result collection ##', + f'rc_name={rc_dir}', + 'ext=.qza', + f'keys={keys_arg}', + f'names={names_arg}', + 'construct_result_collection', + '##', + ] + self.recorder.extend(lines) + + return variable + + def get_artifact_collection_member(self, name, variable, key): + accessed_variable = super().get_artifact_collection_member( + name, variable, key + ) + + rc_dir = variable.to_interface_name() + member_fp = os.path.join(rc_dir, f'{key}.qza') + + lines = [ + '## accessing result collection member ##', + f'ln -s {member_fp} {accessed_variable.to_interface_name()}', + '##', + ] + self.recorder.extend(lines) + + return accessed_variable + + def import_from_format(self, name, semantic_type, + variable, view_type=None): + imported_var = super().import_from_format( + name, semantic_type, variable, view_type=view_type) + + in_fp = variable.to_interface_name() + out_fp = imported_var.to_interface_name() + + lines = [ + 'rachis tools import \\', + self.INDENT + '--type %r \\' % (semantic_type,) + ] + + if view_type is not None: + if type(view_type) is not str: + view_type = view_type.__name__ + lines.append(self.INDENT + '--input-format %s \\' % (view_type,)) + + lines += [ + self.INDENT + '--input-path %s \\' % (in_fp,), + self.INDENT + '--output-path %s' % (out_fp,), + ] + + self.recorder.extend(lines) + + return imported_var + + def init_format(self, name, factory, ext=None): + if ext is not None: + name = '%s.%s' % (name, ext.lstrip('.')) + + variable = super().init_format(name, factory, ext=ext) + + self.init_data.append(variable) + + return variable + + def init_metadata(self, name, factory): + variable = super().init_metadata(name, factory) + + self.init_data.append(variable) + + return variable + + def comment(self, text): + self.recorder += ['# ' + ln for ln in textwrap.wrap(text, width=74)] + + def peek(self, variable): + var_name = variable.to_interface_name() + self.recorder.append('rachis tools peek %s' % var_name) + + def merge_metadata(self, name, *variables): + var = super().merge_metadata(name, *variables) + + # this is our special "short-circuit" attr to handle special-case + # .to_interface_name() needs + var._rachis_cli_ref = \ + ' '.join(v.to_interface_name() for v in variables) + return var + + def get_metadata_column(self, name, column_name, variable): + var = super().get_metadata_column(name, column_name, variable) + + # this is our special "short-circuit" attr to handle special-case + # .to_interface_name() needs + var._rachis_cli_ref = (variable.to_interface_name(), column_name) + return var + + def view_as_metadata(self, name, variable): + # use the given name so that namespace behaves as expected, + # then overwrite it because viewing is a no-op in rachis-cli + var = super().view_as_metadata(name, variable) + # preserve the original interface name of the QZA as this will be + # implicitly converted to metadata when executed. + var._rachis_cli_ref = variable.to_interface_name() + return var + + def action(self, action, inputs, outputs): + variables = super().action(action, inputs, outputs) + + vars_dict = variables._asdict() + + plugin_name = util.to_cli_name(action.plugin_id) + action_name = util.to_cli_name(action.action_id) + self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) + + action_f = action.get_action() + action_state = get_action_state(action_f) + + ins = inputs.map_variables(lambda v: v.to_interface_name()) + outs = {k: v.to_interface_name() for k, v in vars_dict.items()} + signature = {s['name']: s for s in action_state['signature']} + + for param_name, value in ins.items(): + self._append_action_line(signature, param_name, value) + + max_collection_size = self.action_collection_size + if max_collection_size is not None and len(outs) > max_collection_size: + dir_name = self._build_output_dir_name(plugin_name, action_name) + self.recorder.append( + self.INDENT + '--output-dir %s \\' % (dir_name)) + self._rename_outputs(vars_dict, dir_name) + else: + for param_name, value in outs.items(): + self._append_action_line(signature, param_name, value) + + self.recorder[-1] = self.recorder[-1][:-2] # remove trailing \ + + return variables + + def _build_output_dir_name(self, plugin_name, action_name): + base_name = '%s-%s' % (plugin_name, action_name) + self.output_dir_counter[base_name] += 1 + current_inc = self.output_dir_counter[base_name] + if current_inc == 1: + return base_name + return '%s-%d' % (base_name, current_inc) + + def _rename_outputs(self, vars_dict, dir_name): + for signature_name, variable in vars_dict.items(): + name = '%s%s' % (signature_name, variable.ext) + variable._rachis_cli_ref = os.path.join(dir_name, name) + + def _append_action_line(self, signature, param_name, value): + param_state = signature[param_name] + if value is not None: + for opt, val in self._make_param(value, param_state): + line = self.INDENT + opt + if val is not None: + line += ' ' + val + line += ' \\' + + self.recorder.append(line) + + def _make_param(self, value, state): + state = state.copy() + type_ = state.pop('type') + + opt = rachis_cli.click.option.GeneratedOption(prefix=type_[0], **state) + option = opt.opts[0] + + # INPUTS AND OUTPUTS + if type_ in ('input', 'output'): + if isinstance(value, str): + return [(option, value)] + else: + if isinstance(value, set): + value = sorted(value) + return [(option, ' '.join(value))] + + # METADATA FILE + if state['metadata'] == 'file': + return [(option, value)] + + # METADATA COLUMN + if state['metadata'] == 'column': + # md cols are special, we have pre-computed the interface-specific + # names and stashed them in an attr, so unpack to get the values + fn, col_name = value + return [(option, fn), (opt.q2_extra_opts[0], col_name)] + + # PARAMETERS + if type_ == 'parameter': + if isinstance(value, set): + value = [shlex.quote(str(v)) for v in value] + return [(option, ' '.join(sorted(value)))] + + if isinstance(value, list): + return [(option, ' '.join(shlex.quote(str(v)) for v in value))] + + if isinstance(value, (dict, ResultCollection)): + return [(option, ' '.join(f'{k}:{shlex.quote(str(v))}' + for k, v in value.items()))] + + if type(value) is bool: + if state['ast']['type'] == 'expression': + if value: + return [(option, None)] + else: + return [(opt.secondary_opts[0], None)] + else: + # This is a more complicated param that can't be expressed + # as a typical `--p-foo/--p-no-foo` so default to baseline + # parameter handling behavior. + pass + + if type(value) is str: + return [(option, shlex.quote(value))] + + return [(option, str(value))] + + raise Exception('Something went terribly wrong!') + + def get_example_data(self): + for val in self.init_data: + yield val.to_interface_name(), val.execute() + + +class ReplayCLIUsageVariable(CLIUsageVariable): + def to_interface_name(self): + ''' + Differs from parent method in that metadata is not kebab-cased, + filepaths are preserved instead. + ''' + if hasattr(self, '_rachis_cli_ref'): + return self._rachis_cli_ref + + cli_name = '%s%s' % (self.name, self.ext) + + # don't disturb file names, this will break importing where rachis + # relies on specific filenames being present in a dir + if self.var_type not in ('format', 'column', 'metadata'): + cli_name = self.to_cli_name(cli_name) + + return cli_name + + +class ReplayCLIUsage(CLIUsage): + UsageAction: Type[ReplayUsageAction] = ReplayUsageAction + + shebang = '#!/usr/bin/env bash' + header_boundary = ('#' * 79) + set_ex = [ + '# This tells bash to -e exit immediately if a command fails', + '# and -x show all commands in stdout so you can track progress', + 'set -e -x', + '' + ] + + copyright = importlib.resources.read_text( + f'{__package__}.assets', 'copyright_note.txt' + ).split('\n') + how_to = importlib.resources.read_text( + f'{__package__}.assets', 'cli_howto.txt' + ).split('\n') + + def __init__(self, enable_assertions=False, action_collection_size=None): + ''' + Identical to parent but creates header and footer attributes. + + Parameters + ---------- + enable_assertions : bool + Whether to render has-line-matching and output type assertions. + action_collection_size : int + The number of outputs returned by an action above which outputs are + grouped into and accessed from an --output-dir. + ''' + super().__init__() + self.header = [] + self.footer = [] + self.enable_assertions = enable_assertions + self.action_collection_size = action_collection_size + + def usage_variable(self, name, factory, var_type): + return ReplayCLIUsageVariable(name, factory, var_type, self) + + def _append_action_line(self, signature, param_name: str, value): + ''' + Extends the parent method to accommodate action signatures that may + differ between those found in provenance and those accessible in the + currently executing environment. + + Parameters + ---------- + signature : dict of str -> dict + Mapping of name of signature item to dict of signature spec data. + param_name : str + The name of the parameter for which to render a CLI line. + value : any + The value of an item from a Results. + ''' + param_state = signature.get(param_name) + if param_state is not None: + for opt, val in self._make_param(value, param_state): + line = self.INDENT + opt + if val is not None: + line += ' ' + val + line += ' \\' + self.recorder.append(line) + else: # no matching param name + line = self.INDENT + ( + '# FIXME: The following parameter name was not found in ' + 'your current\n # rachis environment. This may occur ' + 'when the plugin version you have\n # installed does not ' + 'match the version used in the original analysis.\n # ' + 'Please see the docs and correct the parameter name ' + 'before running.') + self.recorder.append(line) + self._append_unknown_param(param_name, value) + + def _append_unknown_param(self, param_name, value): + line = '' + cli_name = re.sub('_', '-', param_name) + line += self.INDENT + '--?-' + cli_name + ' ' + str(value) + line += ' \\' + self.recorder.append(line) + + def _make_param(self, value: Any, state: Dict) -> List[Tuple]: + ''' + Wraps metadata filenames in <> to force users to replace them. + + Parameters + ---------- + value : any + A value of a Result. + state : dict + A collection of info about an item from an action signature. + See rachis_cli.core.state.py. + + Returns + ------- + list of tuple + See rachis_cli.core.usage.CLIUsage._make_param + for possible outputs. + ''' + if state['metadata'] == 'column': + value = (f'{value[0]}', *value[1:]) + if state['metadata'] == 'file': + value = f'{value}' + return super()._make_param(value, state) + + def import_from_format( + self, + name: str, + semantic_type: str, + variable: UsageVariable, + view_type: Any = None + ) -> UsageVariable: + ''' + Identical to super.import_from_format, but writes + --input-path and follows import block with a + blank line. + + Parameters + ---------- + name : str + The name of the created UsageVariable. + semantic_type : str + The semantic type of the created UsageVariable. + variable : UsageVariable + A UsageVariable of some format type that will materialize the + data to be imported. + view_type : format or str + The view type for importing. + + Returns + ------- + UsageVariable + Of type artifact. + ''' + # need the super().super() here, so pass self to super + imported_var = Usage.import_from_format( + self, name, semantic_type, variable, view_type=view_type + ) + + out_fp = imported_var.to_interface_name() + + lines = [ + 'rachis tools import \\', + self.INDENT + '--type %r \\' % (semantic_type,) + ] + + if view_type is not None: + lines.append( + self.INDENT + '--input-format %s \\' % (view_type,) + ) + + lines += [ + self.INDENT + '--input-path \\', + self.INDENT + '--output-path %s' % (out_fp,), + ] + + lines.append('') + self.recorder.extend(lines) + + return imported_var + + def init_metadata( + self, name: str, factory: Callable, dumped_md_fn: str = '' + ) -> UsageVariable: + ''' + Like parent, but appropriately handles filepaths for recorded md fps. + + Parameters + ---------- + name : str + The name of the UsageVariable to be created. + factory : Callable + The factory responsible for generating the realized value of the + UsageVariable. + + Returns + ------- + UsageVariable + Of type metadata. + ''' + variable = super().init_metadata(name, factory) + + self.init_data.append(variable) + + if dumped_md_fn: + variable.name = f'"{dumped_md_fn}.tsv"' + else: + variable.name = '' + + return variable + + def comment(self, text): + ''' + Identical to parent method, but pads comments with an extra newline. + ''' + super().comment(text) + self.recorder.append('') + + def action( + self, + action: ReplayUsageAction, + inputs: UsageInputs, + outputs: UsageOutputs + ) -> UsageOutputs: + ''' + Overrides parent method to fill in missing outputlines from + action_f.signature. + Also pads actions with an extra newline. + + Parameters + ---------- + action : ReplayUsageAction + The object representing the Action we are replaying. + inputs : UsageInputs + Mapping of parameter names to arguments for the action. + outputs : ReplayUsageOutputNames + Mapping of registered output names to usage variable names. + + Returns + ------- + UsageOutputs + The results returned by the action. + ''' + plugin_name = rachis_cli.util.to_cli_name(action.plugin_id) + action_name = rachis_cli.util.to_cli_name(action.action_id) + + if action.node._action_present_: + variables = self._action_found( + plugin_name, action_name, action, inputs, outputs + ) + else: + variables = self._action_not_found( + plugin_name, action_name, action, inputs, outputs + ) + + return variables + + def _action_found( + self, plugin_name: str, action_name: str, + action: ReplayUsageAction, + inputs: UsageInputs, + outputs: UsageOutputs + ): + self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) + + variables = Usage.action(self, action, inputs, outputs) + vars_dict = variables._asdict() + + # get registered collection of output names so we don't miss any + action_f = action.get_action() + missing_outputs = {} + for output in action_f.signature.outputs: + try: + # If we get a match on output-name, the correct pair is already + # in vars_dict and we can continue + getattr(variables, output) + except AttributeError: + # Otherwise, we should add filler values to missing_outputs + missing_outputs[output] = f'XX_{output}' + + action_state = get_action_state(action_f) + + ins = inputs.map_variables(lambda v: v.to_interface_name()) + outs = {k: v.to_interface_name() for k, v in vars_dict.items()} + outs.update(missing_outputs) + signature = {s['name']: s for s in action_state['signature']} + + for param_name, value in ins.items(): + self._append_action_line(signature, param_name, value) + + max_collection_size = self.action_collection_size + if max_collection_size is not None and len(outs) > max_collection_size: + dir_name = self._build_output_dir_name(plugin_name, action_name) + self.recorder.append( + self.INDENT + '--output-dir %s \\' % (dir_name) + ) + self._rename_outputs(vars_dict, dir_name) + else: + for param_name, value in outs.items(): + self._append_action_line(signature, param_name, value) + + self.recorder[-1] = self.recorder[-1][:-2] # remove trailing `\` + + self.recorder.append('') + return variables + + def _action_not_found( + self, plugin_name: str, action_name: str, + action: ReplayUsageAction, + inputs: UsageInputs, + outputs: UsageOutputs + ): + variables = Usage.action_not_found(self, action, inputs, outputs) + vars_dict = variables._asdict() + + ins = inputs.map_variables(lambda v: v.to_interface_name()) + + self.recorder.append( + '# FIXME: The following action was not found in your current ' + 'rachis\n# environment. Please ensure the action and its ' + 'parameters are correct before\n# running.' + ) + self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) + + for param_name, value in ins.items(): + self._append_unknown_param(param_name, value) + + dir_name = self._build_output_dir_name(plugin_name, action_name) + self.recorder.append( + self.INDENT + '--output-dir %s \\' % (dir_name) + ) + self._rename_outputs(vars_dict, dir_name) + + self.recorder[-1] = self.recorder[-1][:-2] # remove trailing `\` + + self.recorder.append('') + + return variables + + def render(self, flush=False): + ''' + Return a newline-seperated string of CLI script. + + Parameters + ---------- + flush : bool + Whether to 'flush' the current code. Importantly, this will clear + the top-line imports for future invocations. + + Returns + ------- + str + The rendered string of CLI code. + ''' + if self.header: + self.header = self.header + [''] + if self.footer: + self.footer = [''] + self.footer + rendered = '\n'.join( + self.header + self.set_ex + self.recorder + self.footer + ) + if flush: + self.header = [] + self.footer = [] + self.recorder = [] + self.init_data = [] + return rendered + + def build_header(self): + '''Constructs a renderable header from its components.''' + self.header.extend(build_header( + self.shebang, self.header_boundary, self.copyright, self.how_to + )) + + # for creating result collections in bash + bash_rc_function = [ + 'construct_result_collection () {', + '\tmkdir $rc_name', + '\ttouch $rc_name.order', + '\tfor key in "${keys[@]}"; do', + '\t\techo $key >> $rc_name.order', + '\tdone', + '\tfor i in "${!keys[@]}"; do', + '\t\tln -s ../"${names[i]}" $rc_name"${keys[i]}"$ext', + '\tdone', + '}' + ] + self.header.extend([ + '## function to create result collections ##', + *bash_rc_function, + '##', + ]) + + def build_footer(self, dag: ProvDAG): + ''' + Constructs a renderable footer using the terminal uuids of a ProvDAG. + ''' + self.footer.extend(build_footer(dag, self.header_boundary)) diff --git a/rachis_cli/tests/__init__.py b/rachis_cli/tests/__init__.py new file mode 100644 index 00000000..5a85cefd --- /dev/null +++ b/rachis_cli/tests/__init__.py @@ -0,0 +1,7 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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. +# ---------------------------------------------------------------------------- diff --git a/rachis_cli/tests/data/concatenated_ints.qza b/rachis_cli/tests/data/concatenated_ints.qza new file mode 100644 index 0000000000000000000000000000000000000000..a87e69c14461afc9aab13d3d3776975adc37502b GIT binary patch literal 19908 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 literal 0 HcmV?d00001 diff --git a/rachis_cli/tests/data/mapping_config.toml b/rachis_cli/tests/data/mapping_config.toml new file mode 100644 index 00000000..a30b82ce --- /dev/null +++ b/rachis_cli/tests/data/mapping_config.toml @@ -0,0 +1,15 @@ +[parsl] +strategy = "None" + +[[parsl.executors]] +class = "ThreadPoolExecutor" +label = "default" +max_threads = 1 + +[[parsl.executors]] +class = "_TEST_EXECUTOR_" +label = "test" +max_threads = 1 + +[parsl.executor_mapping.dummy_plugin] +list_of_ints = "test" diff --git a/rachis_cli/tests/data/parse_dir_test/inner/right_ints.qza b/rachis_cli/tests/data/parse_dir_test/inner/right_ints.qza new file mode 100644 index 0000000000000000000000000000000000000000..07aa4dd2f6322314426572e800df3b796c315c5d GIT binary patch literal 14035 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 literal 0 HcmV?d00001 diff --git a/rachis_cli/tests/data/parse_dir_test/left_ints.qza b/rachis_cli/tests/data/parse_dir_test/left_ints.qza new file mode 100644 index 0000000000000000000000000000000000000000..dfd29fd99c566190347925e772569923e429175c GIT binary patch literal 14033 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В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%0AR6>fY!*S6)M{-tgh3hJg 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 EchoFormat: + return a + + plugin = get_dummy_plugin() + action_id = 'higher_union_bool_flag_swaps_output_method' + if action_id not in plugin.actions: + P, R = TypeMap({ + Bool % Choices(True): C1[Foo], + Bool % Choices(False): Foo, + }) + plugin.methods.register_function( + function=higher_union_bool_flag_swaps_output_method, + inputs={'a': Bar}, + parameters={'b': P}, + outputs=[('x', R)], + name='Higher Union Bool Flag Swaps Output Method', + description=( + 'Test if a higher-union bool parameter can change output'), + ) + return plugin + + +class CliTests(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + self.artifact1_path = os.path.join(self.tempdir, 'a1.qza') + self.mapping_path = os.path.join(self.tempdir, 'mapping.qza') + + artifact1 = Artifact.import_data(IntSequence1, [0, 42, 43]) + artifact1.save(self.artifact1_path) + self.artifact1_root_dir = str(artifact1.uuid) + + mapping = Artifact.import_data('Mapping', {'foo': '42'}) + mapping.save(self.mapping_path) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_info(self): + result = self.runner.invoke(info) + self.assertEqual(result.exit_code, 0) + # May not always start with "System versions" if cache updating message + # is printed. + self.assertIn('System versions', result.output) + self.assertIn('Installed plugins', result.output) + self.assertIn('dummy-plugin', result.output) + self.assertIn('other-plugin', result.output) + self.assertIn('Config', result.output) + self.assertIn('Config Source: test config dict', result.output) + + def test_info_level_0(self): + result = self.runner.invoke(info, '--config-level 0') + self.assertEqual(result.exit_code, 0) + # May not always start with "System versions" if cache updating message + # is printed. + self.assertIn('System versions', result.output) + self.assertIn('Installed plugins', result.output) + self.assertIn('dummy-plugin', result.output) + self.assertIn('other-plugin', result.output) + self.assertNotIn('Config', result.output) + self.assertNotIn('Config Source: test config dict', result.output) + + def test_info_level_1(self): + result = self.runner.invoke(info, '--config-level 1') + self.assertEqual(result.exit_code, 0) + # May not always start with "System versions" if cache updating message + # is printed. + self.assertIn('System versions', result.output) + self.assertIn('Installed plugins', result.output) + self.assertIn('dummy-plugin', result.output) + self.assertIn('other-plugin', result.output) + self.assertIn('Config', result.output) + self.assertIn('Config Source: test config dict', result.output) + + def test_info_level_2(self): + result = self.runner.invoke(info, '--config-level 2') + self.assertEqual(result.exit_code, 0) + # May not always start with "System versions" if cache updating message + # is printed. + self.assertIn('System versions', result.output) + self.assertIn('Installed plugins', result.output) + self.assertIn('dummy-plugin', result.output) + self.assertIn('other-plugin', result.output) + self.assertIn('Config', result.output) + self.assertIn('Config Source: test config dict', result.output) + self.assertIn(CONFIG_LEVEL_2, result.output) + + def test_info_level_3(self): + result = self.runner.invoke(info, '--config-level 3') + self.assertEqual(result.exit_code, 0) + # May not always start with "System versions" if cache updating message + # is printed. + self.assertIn('System versions', result.output) + self.assertIn('Installed plugins', result.output) + self.assertIn('dummy-plugin', result.output) + self.assertIn('other-plugin', result.output) + self.assertIn('Config', result.output) + self.assertIn('Config Source: test config dict', result.output) + self.assertIn(CONFIG_LEVEL_3, result.output) + + def test_list_commands(self): + # top level commands, including a plugin, are present + rachis_cli = RootCommand() + commands = rachis_cli.list_commands(ctx=None) + self.assertIn('info', commands) + self.assertIn('tools', commands) + self.assertIn('dummy-plugin', commands) + self.assertIn('other-plugin', commands) + + def test_plugin_list_commands(self): + # plugin commands are present including a method and visualizer + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + commands = command.list_commands(ctx=None) + self.assertIn('split-ints', commands) + self.assertIn('mapping-viz', commands) + + self.assertNotIn('split_ints', commands) + self.assertNotIn('mapping_viz', commands) + self.assertNotIn('-underscore-method', commands) + self.assertNotIn('_underscore-method', commands) + + def test_plugin_list_hidden_commands(self): + # plugin commands are present including a method and visualizer and + # hidden method + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + commands = self.runner.invoke(command, + ['--show-hidden-actions']).output + self.assertIn('split-ints', commands) + self.assertIn('mapping-viz', commands) + self.assertIn('_underscore-method', commands) + + self.assertNotIn('split_ints', commands) + self.assertNotIn('mapping_viz', commands) + self.assertNotIn('_underscore_method', commands) + self.assertNotIn('-underscore-method', commands) + + def test_action_parameter_types(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + results = self.runner.invoke(command, ['typical-pipeline', '--help']) + help_text = results.output + + # Check the help text to make sure the types are displayed correctly + # boolean primitive + self.assertIn('--p-do-extra-thing / --p-no-do-extra-thing', help_text) + # int primitive + self.assertIn('--p-add INTEGER', help_text) + + # Run it to make sure the types are converted correctly, the framework + # will error if it recieves the wrong type from the interface. + output_dir = os.path.join(self.tempdir, 'output-test') + result = self.runner.invoke(command, [ + 'typical-pipeline', '--i-int-sequence', self.artifact1_path, + '--i-mapping', self.mapping_path, '--p-do-extra-thing', '--p-add', + '10', '--output-dir', output_dir, '--verbose']) + self.assertEqual(result.exit_code, 0) + + def test_higher_union_bool_typemap_parameter_is_flag(self): + plugin = _register_higher_union_bool_flag_action() + command = PluginCommand(get_plugin_state(plugin), 'dummy-plugin') + + results = self.runner.invoke( + command, [ + 'higher-union-bool-flag-swaps-output-method', '--help']) + self.assertEqual(results.exit_code, 0) + self.assertIn('--p-b / --p-no-b', results.output) + + input_path = Artifact.import_data( + 'Bar', 'element', view_type=str).save( + os.path.join(self.tempdir, 'bar.qza')) + true_output_path = os.path.join(self.tempdir, 'true-output.qza') + false_output_path = os.path.join(self.tempdir, 'false-output.qza') + + result = self.runner.invoke( + command, [ + 'higher-union-bool-flag-swaps-output-method', + '--i-a', input_path, + '--p-b', + '--o-x', true_output_path, + '--verbose' + ]) + self.assertEqual(result.exit_code, 0) + self.assertEqual(repr(Artifact.load(true_output_path).type), 'C1[Foo]') + + result = self.runner.invoke( + command, [ + 'higher-union-bool-flag-swaps-output-method', + '--i-a', input_path, + '--p-no-b', + '--o-x', false_output_path, + '--verbose' + ]) + self.assertEqual(result.exit_code, 0) + self.assertEqual(repr(Artifact.load(false_output_path).type), 'Foo') + + def test_execute_hidden_action(self): + int_path = os.path.join(self.tempdir, 'int.qza') + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + result = self.runner.invoke( + command, ['_underscore-method', '--o-int', int_path, + '--verbose']) + + self.assertEqual(result.exit_code, 0) + + def test_extract(self): + result = self.runner.invoke( + tools, ['extract', '--input-path', self.artifact1_path, + '--output-path', self.tempdir]) + # command completes sucessfully and creates the correct + # output directory + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists( + os.path.join(self.tempdir, self.artifact1_root_dir))) + # results are correct + data_f = open(os.path.join(self.tempdir, self.artifact1_root_dir, + 'data', 'ints.txt')) + self.assertEqual(data_f.read(), "0\n42\n43\n") + + def test_validate_min(self): + result = self.runner.invoke( + tools, ['validate', self.artifact1_path, '--level', 'min']) + self.assertEqual(result.exit_code, 0) + self.assertIn('appears to be valid at level=min', result.output) + + def test_validate_max(self): + result = self.runner.invoke( + tools, ['validate', self.artifact1_path, '--level', 'max']) + self.assertEqual(result.exit_code, 0) + self.assertIn('appears to be valid at level=max', result.output) + + result = self.runner.invoke(tools, ['validate', self.artifact1_path]) + self.assertEqual(result.exit_code, 0) + self.assertIn('appears to be valid at level=max', result.output) + + def test_split_ints(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + + # build output file names + left_path = os.path.join(self.tempdir, 'left.qza') + right_path = os.path.join(self.tempdir, 'right.qza') + + # TODO: currently must pass `--verbose` to commands invoked by Click's + # test runner because redirecting stdout/stderr raises an + # "io.UnsupportedOperation: fileno" error. Likely related to Click + # mocking a filesystem in the test runner. + result = self.runner.invoke( + command, ['split-ints', '--i-ints', self.artifact1_path, + '--o-left', left_path, '--o-right', right_path, + '--verbose']) + # command completes successfully and creates the correct + # output files + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(left_path)) + self.assertTrue(os.path.exists(right_path)) + # results are correct + left = Artifact.load(left_path) + right = Artifact.load(right_path) + self.assertEqual(left.view(list), [0]) + self.assertEqual(right.view(list), [42, 43]) + + def test_variadic_inputs(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + output_path = os.path.join(self.tempdir, 'output.qza') + + ints1 = Artifact.import_data('IntSequence1', [1, 2, 3]).save( + os.path.join(self.tempdir, 'ints1.qza')) + ints2 = Artifact.import_data('IntSequence2', [4, 5, 6]).save( + os.path.join(self.tempdir, 'ints2.qza')) + set1 = Artifact.import_data('SingleInt', 7).save( + os.path.join(self.tempdir, 'set1.qza')) + set2 = Artifact.import_data('SingleInt', 8).save( + os.path.join(self.tempdir, 'set2.qza')) + + result = self.runner.invoke( + command, + ['variadic-input-method', '--i-ints', ints1, '--i-ints', ints2, + '--i-int-set', set1, '--i-int-set', set2, '--p-nums', '9', + '--p-nums', '10', '--p-opt-nums', '11', '--p-opt-nums', '12', + '--p-opt-nums', '13', '--o-output', output_path, '--verbose']) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(output_path)) + + output = Artifact.load(output_path) + self.assertEqual(output.view(list), list(range(1, 14))) + + def test_with_parameters_only(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + output_path = os.path.join(self.tempdir, 'output.qza') + + result = self.runner.invoke( + command, ['params-only-method', '--p-name', 'Peanut', '--p-age', + '42', '--o-out', output_path, '--verbose']) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(output_path)) + + artifact = Artifact.load(output_path) + self.assertEqual(artifact.view(dict), {'Peanut': '42'}) + + def test_without_inputs_or_parameters(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + output_path = os.path.join(self.tempdir, 'output.qza') + + result = self.runner.invoke( + command, ['no-input-method', '--o-out', output_path, '--verbose']) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(output_path)) + + artifact = Artifact.load(output_path) + self.assertEqual(artifact.view(dict), {'foo': '42'}) + + def test_qza_extension(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + + # build output parameter arguments and expected output file names + left_path = os.path.join(self.tempdir, 'left') + expected_left_path = os.path.join(self.tempdir, 'left.qza') + right_path = os.path.join(self.tempdir, 'right') + expected_right_path = os.path.join(self.tempdir, 'right.qza') + + result = self.runner.invoke( + command, ['split-ints', '--i-ints', self.artifact1_path, + '--o-left', left_path, '--o-right', right_path, + '--verbose']) + # command completes successfully and creates the correct + # output files + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(expected_left_path)) + self.assertTrue(os.path.exists(expected_right_path)) + # results are correct + left = Artifact.load(expected_left_path) + right = Artifact.load(expected_right_path) + self.assertEqual(left.view(list), [0]) + self.assertEqual(right.view(list), [42, 43]) + + def test_qzv_extension(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + # build output parameter arguments and expected output file names + viz_path = os.path.join(self.tempdir, 'viz') + expected_viz_path = os.path.join(self.tempdir, 'viz.qzv') + + result = self.runner.invoke( + command, ['most-common-viz', '--i-ints', self.artifact1_path, + '--o-visualization', viz_path, '--verbose']) + # command completes successfully and creates the correct + # output file + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(expected_viz_path)) + # Visualization contains expected contents + viz = Visualization.load(expected_viz_path) + self.assertEqual(viz.get_index_paths(), {'html': 'data/index.html', + 'tsv': 'data/index.tsv'}) + + def test_verbose_shows_stacktrace(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + output = os.path.join(self.tempdir, 'never-happens.qza') + + result = self.runner.invoke( + command, + ['failing-pipeline', '--i-int-sequence', self.artifact1_path, + '--o-mapping', output, '--p-break-from', 'internal', '--verbose']) + + self.assertEqual(result.exit_code, 1) + self.assertIn('Traceback (most recent call last)', result.output) + + def test_input_conversion(self): + obj = QIIME2Type(IntSequence1.to_ast(), repr(IntSequence1)) + + with self.assertRaisesRegex(click.exceptions.BadParameter, + "x does not exist"): + obj._convert_input('x', None, None) + + # This is to ensure the temp in the regex matches the temp used in the + # method under test in type.py + temp = str(get_cache().path) + with unittest.mock.patch('qiime2.sdk.Result.peek', + side_effect=OSError(errno.ENOSPC, + 'No space left on ' + 'device')): + with self.assertRaisesRegex(click.exceptions.BadParameter, + f'{temp!r}.*' + f'{self.artifact1_path!r}.*' + f'{temp!r}'): + obj._convert_input(self.artifact1_path, None, None) + + def test_syntax_error_in_env(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + + viz_path = os.path.join(self.tempdir, 'viz') + + with unittest.mock.patch('qiime2.sdk.Result.peek', + side_effect=SyntaxError): + result = self.runner.invoke( + command, ['most-common-viz', '--i-ints', self.artifact1_path, + '--o-visualization', viz_path, '--verbose']) + + self.assertEqual(result.exit_code, 1) + self.assertIn('problem loading', result.output) + self.assertIn(self.artifact1_path, result.output) + + def test_deprecated_help_text(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + + result = self.runner.invoke(command, ['deprecated-method', '--help']) + + self.assertEqual(result.exit_code, 0) + self.assertIn('WARNING', result.output) + self.assertIn('deprecated', result.output) + + def test_run_deprecated_gets_warning_msg(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + output_path = os.path.join(self.tempdir, 'output.qza') + + result = self.runner.invoke( + command, + ['deprecated-method', '--o-out', output_path, '--verbose']) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(output_path)) + + artifact = Artifact.load(output_path) + + # Just make sure that the command ran as expected + self.assertEqual(artifact.view(dict), {'foo': '43'}) + + self.assertIn('deprecated', result.output) + + def test_get_citations(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + result = self.runner.invoke(command, ['split-ints', '--citations']) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.output, EXPECTED_CITATIONS) + + def test_rachis_warning_is_always_visible(self): + ''' + Tests that the `RachisWarning` warning type gets passed to the standard + error stream even when --verbose is not set. Also ensures that such + errors are still visible with --verbose, and not visible with --quiet. + ''' + with tempfile.TemporaryDirectory() as tempdir: + base_command = [ + 'rachis', 'dummy-plugin', 'raises-rachis-warning', + '--o-output', + Path(tempdir) / 'output.qza' + ] + + # no verbose, no quiet + output = subprocess.run( + base_command, capture_output=True, text=True + ) + self.assertIn('This is an important warning', output.stderr) + + # verbose + output = subprocess.run( + base_command + ['--verbose'], capture_output=True, text=True + ) + self.assertIn('This is an important warning', output.stderr) + + # quiet + output = subprocess.run( + base_command + ['--quiet'], capture_output=True, text=True + ) + self.assertNotIn('This is an important warning', output.stderr) + + +class TestMigrated(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + + self.runner = CliRunner() + self.plugin_command = \ + RootCommand().get_command(ctx=None, name='dummy-plugin') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + self.output_path = os.path.join(self.tempdir, 'output.qza') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_migrated_all_optional_keys(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-all-optional-keys', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-all-optional-keys', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin of the old distribution to ' + 'the smart plugin of the new distribution ' + 'in 2025.4.', result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_no_optional_keys(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-no-optional-keys', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-no-optional-keys', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin to the smart plugin in a ' + 'future release.', result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_from_distro(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-from-distro', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-from-distro', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin of the old distribution ' + 'to the smart plugin in a future release.', + result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_to_distro(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-to-distro', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-to-distro', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin to the smart plugin of the ' + 'new distribution in a future release.', + result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_epoch(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-epoch', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-epoch', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin to the smart plugin ' + 'in 2025.4.', result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_from_distro_to_distro(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-from-distro-to-distro', + '--o-out', self.output_path, '--verbose']) + result_help = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-from-distro-to-distro', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin of the old distribution to ' + 'the smart plugin of the new distribution ' + 'in a future release.', result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_from_distro_epoch(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-from-distro-epoch', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-from-distro-epoch', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin of the old distribution to ' + 'the smart plugin in 2025.4.', result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + def test_migrated_to_distro_epoch(self): + result = \ + self.runner.invoke(self.plugin_command, + ['migrated-method-to-distro-epoch', + '--o-out', self.output_path, '--verbose']) + result_help = self.runner.invoke(self.plugin_command, + ['migrated-method-to-distro-epoch', + '--help']) + + for rslt in [result, result_help]: + self.assertEqual(rslt.exit_code, 0) + + self.assertIn('This Method is slated for migration from the ' + 'dummy_plugin plugin to the smart plugin of the ' + 'new distribution in 2025.4.', result.output) + + self.assertTrue(os.path.exists(self.output_path)) + artifact = Artifact.load(self.output_path) + self.assertEqual(artifact.view(dict), {'ziggy': '42'}) + + +class TestOptionalArtifactSupport(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + + self.runner = CliRunner() + self.plugin_command = RootCommand().get_command( + ctx=None, name='dummy-plugin') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + self.ints1 = os.path.join(self.tempdir, 'ints1.qza') + Artifact.import_data( + IntSequence1, [0, 42, 43], list).save(self.ints1) + self.ints2 = os.path.join(self.tempdir, 'ints2.qza') + Artifact.import_data( + IntSequence1, [99, -22], list).save(self.ints2) + self.ints3 = os.path.join(self.tempdir, 'ints3.qza') + Artifact.import_data( + IntSequence2, [43, 43], list).save(self.ints3) + self.output = os.path.join(self.tempdir, 'output.qza') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def _run_command(self, *args): + return self.runner.invoke(self.plugin_command, args) + + 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') + + self.assertEqual(result.exit_code, 0) + self.assertEqual(Artifact.load(self.output).view(list), + [0, 42, 43, 42]) + + 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, + '--o-output', self.output, '--verbose') + + self.assertEqual(result.exit_code, 0) + self.assertEqual(Artifact.load(self.output).view(list), + [0, 42, 43, 42, 99, -22]) + + 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', + '--o-output', self.output, '--verbose') + + self.assertEqual(result.exit_code, 0) + self.assertEqual(Artifact.load(self.output).view(list), + [0, 42, 43, 42, 99, -22, 43, 43, 111]) + + 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, + '--o-output', self.output, '--verbose') + + self.assertEqual(result.exit_code, 1) + self.assertRegex(str(result.output), + 'type IntSequence1.*type IntSequence2.*') + + +class MetadataTestsBase(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + self.runner = CliRunner() + self.plugin_command = RootCommand().get_command( + ctx=None, name='dummy-plugin') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + self.input_artifact = os.path.join(self.tempdir, 'in.qza') + Artifact.import_data( + IntSequence1, [0, 42, 43], list).save(self.input_artifact) + self.output_artifact = os.path.join(self.tempdir, 'out.qza') + + self.metadata_file1 = os.path.join(self.tempdir, 'metadata1.tsv') + with open(self.metadata_file1, 'w') as f: + f.write('id\tcol1\n0\tfoo\nid1\tbar\n') + + self.metadata_file_alt_id_header = os.path.join( + self.tempdir, 'metadata-alt-id-header.tsv') + with open(self.metadata_file_alt_id_header, 'w') as f: + f.write('#SampleID\tcol1\n0\tfoo\nid1\tbar\n') + + self.metadata_file2 = os.path.join(self.tempdir, 'metadata2.tsv') + with open(self.metadata_file2, 'w') as f: + f.write('id\tcol2\n0\tbaz\nid1\tbaa\n') + + self.metadata_file_mixed_types = os.path.join( + self.tempdir, 'metadata-mixed-types.tsv') + with open(self.metadata_file_mixed_types, 'w') as f: + f.write('id\tnumbers\tstrings\nid1\t42\tabc\nid2\t-1.5\tdef\n') + + self.metadata_artifact = os.path.join(self.tempdir, 'metadata.qza') + Artifact.import_data( + 'Mapping', {'a': 'dog', 'b': 'cat'}).save(self.metadata_artifact) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def _run_command(self, *args): + return self.runner.invoke(self.plugin_command, args) + + def _assertMetadataOutput(self, result, *, exp_tsv, exp_yaml): + self.assertEqual(result.exit_code, 0) + + artifact = Artifact.load(self.output_artifact) + action_dir = artifact._archiver.provenance_dir / 'action' + + if exp_tsv is None: + self.assertFalse((action_dir / 'metadata.tsv').exists()) + else: + with (action_dir / 'metadata.tsv').open() as fh: + self.assertEqual(fh.read(), exp_tsv) + + with (action_dir / 'action.yaml').open() as fh: + self.assertIn(exp_yaml, fh.read()) + + +class TestMetadataSupport(MetadataTestsBase): + def test_required_metadata_missing(self): + result = self._run_command( + 'identity-with-metadata', '--i-ints', self.input_artifact, + '--o-out', self.output_artifact) + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn("Missing option '--m-metadata-file'", result.output) + + def test_optional_metadata_missing(self): + result = self._run_command( + 'identity-with-optional-metadata', '--i-ints', self.input_artifact, + '--o-out', self.output_artifact, '--verbose') + + self._assertMetadataOutput(result, exp_tsv=None, + exp_yaml='metadata: null') + + def test_single_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, '--m-metadata-file', self.metadata_file1, + '--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_single_metadata_alt_id_header(self): + # Test that the Metadata ID header is preserved, and not normalized to + # 'id' (this used to be a bug). ID header normalization should only + # happen when 2+ Metadata are being merged. + 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, '--m-metadata-file', + self.metadata_file_alt_id_header, '--verbose') + + exp_tsv = ( + '#SampleID\tcol1\n' + '#q2:types\tcategorical\n' + '0\tfoo\n' + 'id1\tbar\n' + ) + self._assertMetadataOutput( + result, exp_tsv=exp_tsv, + exp_yaml="metadata: !metadata 'metadata.tsv'") + + def test_multiple_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, '--m-metadata-file', + self.metadata_file_alt_id_header, '--m-metadata-file', + self.metadata_file2, '--m-metadata-file', + self.metadata_artifact, '--verbose') + + exp_tsv = ( + 'id\tcol1\tcol2\ta\tb\n' + '#q2:types\tcategorical\tcategorical\tcategorical\tcategorical' + '\n0\tfoo\tbaz\tdog\tcat\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) + + def test_invalid_metadata_merge(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, '--m-metadata-file', self.metadata_file1, + '--m-metadata-file', self.metadata_file1) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn('overlapping columns', result.output) + + +class TestMetadataColumnSupport(MetadataTestsBase): + # Neither md file or column params provided + def test_required_missing_file_and_column(self): + result = self._run_command( + 'identity-with-metadata-column', '--i-ints', self.input_artifact, + '--o-out', self.output_artifact) + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn("Missing option '--m-metadata-file'", result.output) + + # md file param missing, md column param & value provided + def test_required_missing_file(self): + result = self._run_command( + 'identity-with-metadata-column', '--i-ints', self.input_artifact, + '--m-metadata-column', 'a', '--o-out', self.output_artifact) + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn("Missing option '--m-metadata-file'", result.output) + + # md file param & value provided, md column param missing + def test_required_missing_column(self): + result = self._run_command( + 'identity-with-metadata-column', '--i-ints', self.input_artifact, + '--m-metadata-file', self.metadata_file1, + '--o-out', self.output_artifact) + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn("Missing option '--m-metadata-column'", result.output) + + def test_optional_metadata_missing(self): + result = self._run_command( + 'identity-with-optional-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, '--verbose') + + self._assertMetadataOutput(result, exp_tsv=None, + exp_yaml='metadata: null') + + def test_optional_metadata_without_column(self): + result = self._run_command( + 'identity-with-optional-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-file', self.metadata_file1) + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn("Missing option '--m-metadata-column'", result.output) + + def test_optional_column_without_metadata(self): + result = self._run_command( + 'identity-with-optional-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-column', 'col1') + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn("Missing option '--m-metadata-file'", result.output) + + def test_single_metadata(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-column', 'col1', '--verbose') + + exp_tsv = 'id\tcol1\n#q2:types\tcategorical\n0\tfoo\nid1\tbar\n' + if result.exit_code != 0: + raise ValueError(result.exception) + self._assertMetadataOutput( + result, exp_tsv=exp_tsv, + exp_yaml="metadata: !metadata 'metadata.tsv'") + + def test_multiple_metadata(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_file2, '--m-metadata-file', + self.metadata_artifact, '--m-metadata-column', 'col2', + '--verbose') + + 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( + 'identity-with-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-file', self.metadata_file1, '--m-metadata-file', + self.metadata_file2, '--m-metadata-column', 'col1', + '--m-metadata-column', 'col2') + + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertIn('\'--m-metadata-file\' was specified multiple times', + result.output) + + 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 COLUMN MetadataColumn[Categorical]', + help_text) + + result = self._run_command( + 'identity-with-categorical-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-file', self.metadata_file_mixed_types, + '--m-metadata-column', 'strings', '--verbose') + + exp_tsv = 'id\tstrings\n#q2:types\tcategorical\nid1\tabc\nid2\tdef\n' + self._assertMetadataOutput( + result, exp_tsv=exp_tsv, + exp_yaml="metadata: !metadata 'metadata.tsv'") + + def test_categorical_metadata_column_type_mismatch(self): + result = self._run_command( + 'identity-with-categorical-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-file', self.metadata_file_mixed_types, + '--m-metadata-column', 'numbers') + + self.assertEqual(result.exit_code, 1) + 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 COLUMN MetadataColumn[Numeric]', + help_text) + + result = self._run_command( + 'identity-with-numeric-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-file', self.metadata_file_mixed_types, + '--m-metadata-column', 'numbers', '--verbose') + + exp_tsv = 'id\tnumbers\n#q2:types\tnumeric\nid1\t42\nid2\t-1.5\n' + self._assertMetadataOutput( + result, exp_tsv=exp_tsv, + exp_yaml="metadata: !metadata 'metadata.tsv'") + + def test_numeric_metadata_column_type_mismatch(self): + result = self._run_command( + 'identity-with-numeric-metadata-column', '--i-ints', + self.input_artifact, '--o-out', self.output_artifact, + '--m-metadata-file', self.metadata_file_mixed_types, + '--m-metadata-column', 'strings') + + self.assertEqual(result.exit_code, 1) + self.assertIn("Metadata column", result.output) + self.assertIn("categorical", result.output) + self.assertIn("expected Numeric", result.output) + + +class TestCollectionSupport(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + self.runner = CliRunner() + self.plugin_command = RootCommand().get_command( + ctx=None, name='dummy-plugin') + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + self.art1_path = os.path.join(self.tempdir, 'art1.qza') + self.art2_path = os.path.join(self.tempdir, 'art2.qza') + self.art1 = Artifact.import_data(SingleInt, 0) + self.art2 = Artifact.import_data(SingleInt, 1) + + self.output = os.path.join(self.tempdir, 'out') + self.output2 = os.path.join(self.tempdir, 'out2') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def _run_command(self, *args): + return self.runner.invoke(self.plugin_command, args) + + def test_collection_roundtrip_list(self): + result = self._run_command( + 'list-params', '--p-ints', '0', '--p-ints', '1', '--o-output', + self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['0'].view(int), 0) + self.assertEqual(collection['1'].view(int), 1) + self.assertEqual(list(collection.keys()), ['0', '1']) + + result = self._run_command( + 'list-of-ints', '--i-ints', self.output, '--o-output', + self.output2, '--verbose' + ) + + self.assertEqual(collection['0'].view(int), 0) + self.assertEqual(collection['1'].view(int), 1) + self.assertEqual(list(collection.keys()), ['0', '1']) + + def test_collection_roundtrip_dict_keyed(self): + result = self._run_command( + 'dict-params', '--p-ints', 'foo:0', '--p-ints', 'bar:1', + '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['foo'].view(int), 0) + self.assertEqual(collection['bar'].view(int), 1) + self.assertEqual(list(collection.keys()), ['foo', 'bar']) + + result = self._run_command( + 'dict-of-ints', '--i-ints', self.output, '--o-output', + self.output2, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['foo'].view(int), 0) + self.assertEqual(collection['bar'].view(int), 1) + self.assertEqual(list(collection.keys()), ['foo', 'bar']) + + def test_collection_roundtrip_dict_unkeyed(self): + result = self._run_command( + 'dict-params', '--p-ints', '0', '--p-ints', '1', + '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['0'].view(int), 0) + self.assertEqual(collection['1'].view(int), 1) + self.assertEqual(list(collection.keys()), ['0', '1']) + + result = self._run_command( + 'dict-of-ints', '--i-ints', self.output, '--o-output', + self.output2, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['0'].view(int), 0) + self.assertEqual(collection['1'].view(int), 1) + self.assertEqual(list(collection.keys()), ['0', '1']) + + def test_de_facto_list(self): + self.art1.save(self.art1_path) + self.art2.save(self.art2_path) + + result = self._run_command( + 'list-of-ints', '--i-ints', self.art1_path, '--i-ints', + self.art2_path, '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['0'].view(int), 0) + self.assertEqual(collection['1'].view(int), 1) + self.assertEqual(list(collection.keys()), ['0', '1']) + + def test_de_facto_dict_keyed(self): + self.art1.save(self.art1_path) + self.art2.save(self.art2_path) + + result = self._run_command( + 'dict-of-ints', '--i-ints', f'foo:{self.art1_path}', '--i-ints', + f'bar:{self.art2_path}', '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['foo'].view(int), 0) + self.assertEqual(collection['bar'].view(int), 1) + self.assertEqual(list(collection.keys()), ['foo', 'bar']) + + def test_de_facto_dict_unkeyed(self): + self.art1.save(self.art1_path) + self.art2.save(self.art2_path) + + result = self._run_command( + 'dict-of-ints', '--i-ints', self.art1_path, '--i-ints', + self.art2_path, '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['0'].view(int), 0) + self.assertEqual(collection['1'].view(int), 1) + self.assertEqual(list(collection.keys()), ['0', '1']) + + def test_mixed_keyed_unkeyed_inputs(self): + self.art1.save(self.art1_path) + self.art2.save(self.art2_path) + + result = self._run_command( + 'dict-of-ints', '--i-ints', f'foo:{self.art1_path}', '--i-ints', + self.art2_path, '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn('Keyed values cannot be mixed with unkeyed values.', + str(result.exception)) + + result = self._run_command( + 'dict-of-ints', '--i-ints', self.art1_path, '--i-ints', + f'bar:{self.art2_path}', '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn('Keyed values cannot be mixed with unkeyed values.', + str(result.exception)) + + def test_mixed_keyed_unkeyed_params(self): + result = self._run_command( + 'dict-params', '--p-ints', 'foo:0', '--p-ints', '1', + '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn('The unkeyed value <1> has been mixed with keyed values.' + ' All values must be keyed or unkeyed', + str(result.exception)) + + result = self._run_command( + 'dict-params', '--p-ints', '0', '--p-ints', 'bar:1', + '--o-output', self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn('The keyed value has been mixed with unkeyed' + ' values. All values must be keyed or unkeyed', + str(result.exception)) + + def test_keyed_path_with_tilde(self): + self.art1.save(self.art1_path) + self.art2.save(self.art2_path) + + tmp = tempfile.gettempdir() + tempdir = os.path.basename(self.tempdir) + + with modified_environ(HOME=tmp): + result = self._run_command( + 'dict-of-ints', + '--i-ints', f'foo:{os.path.join("~", tempdir, "art1.qza")}', + '--i-ints', f'bar:{os.path.join("~", tempdir, "art2.qza")}', + '--o-output', self.output, '--verbose') + + self.assertEqual(result.exit_code, 0) + collection = ResultCollection.load(self.output) + + self.assertEqual(collection['foo'].view(int), 0) + self.assertEqual(collection['bar'].view(int), 1) + self.assertEqual(list(collection.keys()), ['foo', 'bar']) + + def test_directory_with_non_artifacts(self): + input_dir = os.path.join(self.tempdir, 'in') + os.mkdir(input_dir) + + artifact_path = os.path.join(input_dir, 'a.qza') + artifact = Artifact.import_data(IntSequence1, [0, 42, 43]) + artifact.save(artifact_path) + + with open(os.path.join(input_dir, 'bad.txt'), 'w') as fh: + fh.write('This file is not an artifact') + + result = self._run_command( + 'list-of-ints', '--i-ints', input_dir, '--o-output', + self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("Invalid value for '--i-ints':", result.output) + + def test_empty_directory(self): + result = self._run_command( + 'list-of-ints', '--i-ints', self.tempdir, '--o-output', + self.output, '--verbose' + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn(f"Provided directory '{self.tempdir}' is empty.", + result.output) + + +@contextlib.contextmanager +def modified_environ(*remove, **update): + """ + Taken from: https://stackoverflow.com/a/34333710. + + Updating the os.environ dict only modifies the environment variables from + the perspective of the current Python process, so this isn't dangerous at + all. + + Temporarily updates the ``os.environ`` dictionary in-place. + + The ``os.environ`` dictionary is updated in-place so that the modification + is sure to work in all situations. + + :param remove: Environment variables to remove. + :param update: Dictionary of environment variables and values to + add/update. + """ + env = os.environ + update = update or {} + remove = remove or [] + + # List of environment variables being updated or removed. + stomped = (set(update.keys()) | set(remove)) & set(env.keys()) + # Environment variables and values to restore on exit. + update_after = {k: env[k] for k in stomped} + # Environment variables and values to remove on exit. + remove_after = frozenset(k for k in update if k not in env) + + try: + env.update(update) + [env.pop(k, None) for k in remove] + yield + finally: + env.update(update_after) + [env.pop(k) for k in remove_after] + + +if __name__ == "__main__": + unittest.main() diff --git a/rachis_cli/tests/test_core.py b/rachis_cli/tests/test_core.py new file mode 100644 index 00000000..645c3ebc --- /dev/null +++ b/rachis_cli/tests/test_core.py @@ -0,0 +1,458 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os.path +import pathlib +import shutil +import tempfile +import unittest +import configparser +import zipfile + +import pandas as pd + +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 +from qiime2.sdk.util import camel_to_snake +from qiime2.sdk.usage import UsageVariable +from qiime2.sdk import PluginManager +from qiime2.core.archive.provenance_lib import DummyArtifacts, ProvDAG +from qiime2.core.archive.provenance_lib.replay import ( + ReplayConfig, param_is_metadata_column, dump_recorded_md_file, + ReplayNamespaces, build_import_usage, build_action_usage, + ActionCollections, replay_provenance, replay_supplement +) +from qiime2.core.archive.provenance_lib.usage_drivers import ReplayPythonUsage + +import rachis_cli +import rachis_cli.util +import rachis_cli.builtin.info +import rachis_cli.builtin.tools +from rachis_cli.commands import RootCommand +from rachis_cli.core.config import CLIConfig +from rachis_cli.core.usage import ReplayCLIUsage, CLIUsageVariable + + +class TestOption(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + self.parser = configparser.ConfigParser() + self.path = os.path.join(rachis_cli.util.get_app_dir(), + 'cli-colors.theme') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def _assertRepeatedOptionError(self, result, option): + self.assertEqual(result.exit_code, 1) + self.assertTrue(result.output.startswith('Usage:')) + self.assertRegex(result.output, '.*%s.* was specified multiple times' + % option) + + def test_repeated_eager_option_with_callback(self): + result = self.runner.invoke( + rachis_cli.builtin.tools.tools, + ['list-types', '--tsv', '--tsv']) + + self._assertRepeatedOptionError(result, '--tsv') + + def test_repeated_builtin_flag(self): + result = self.runner.invoke( + rachis_cli.builtin.tools.tools, + ['import', '--input-path', 'a', '--input-path', 'b']) + + self._assertRepeatedOptionError(result, '--input-path') + + def test_repeated_action_flag(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + out_path = os.path.join(self.tempdir, 'out.qza') + + result = self.runner.invoke( + command, ['no-input-method', '--o-out', out_path, + '--verbose', '--verbose']) + + self._assertRepeatedOptionError(result, '--verbose') + + def test_repeated_builtin_option(self): + input_path = os.path.join(self.tempdir, 'ints.txt') + with open(input_path, 'w') as f: + f.write('42\n43\n44\n') + output_path = os.path.join(self.tempdir, 'out.qza') + + result = self.runner.invoke( + rachis_cli.builtin.tools.tools, + ['import', '--input-path', input_path, + '--output-path', output_path, '--type', 'IntSequence1', + '--type', 'IntSequence1']) + + self._assertRepeatedOptionError(result, '--type') + + def test_repeated_action_option(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + out_path = os.path.join(self.tempdir, 'out.qza') + + result = self.runner.invoke( + command, ['no-input-method', '--o-out', out_path, + '--o-out', out_path]) + + self._assertRepeatedOptionError(result, '--o-out') + + def test_repeated_multiple_option(self): + input_path = os.path.join(self.tempdir, 'ints.qza') + artifact = Artifact.import_data(IntSequence1, [0, 42, 43], list) + artifact.save(input_path) + + metadata_path1 = os.path.join(self.tempdir, 'metadata1.tsv') + with open(metadata_path1, 'w') as f: + f.write('id\tcol1\nid1\tfoo\nid2\tbar\n') + metadata_path2 = os.path.join(self.tempdir, 'metadata2.tsv') + with open(metadata_path2, 'w') as f: + f.write('id\tcol2\nid1\tbaz\nid2\tbaa\n') + + output_path = os.path.join(self.tempdir, 'out.qza') + + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + + result = self.runner.invoke( + command, ['identity-with-metadata', '--i-ints', input_path, + '--o-out', output_path, '--m-metadata-file', + metadata_path1, '--m-metadata-file', metadata_path2, + '--verbose']) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.exists(output_path)) + self.assertEqual(Artifact.load(output_path).view(list), [0, 42, 43]) + + def test_config_expected(self): + self.parser['type'] = {'underline': 't'} + with open(self.path, 'w') as fh: + self.parser.write(fh) + + config = CLIConfig() + config.parse_file(self.path) + + self.assertEqual( + config.styles['type'], {'underline': True}) + + def test_config_bad_selector(self): + self.parser['tye'] = {'underline': 't'} + with open(self.path, 'w') as fh: + self.parser.write(fh) + + config = CLIConfig() + with self.assertRaisesRegex( + configparser.Error, 'tye.*valid selector.*valid selectors'): + config.parse_file(self.path) + + def test_config_bad_styling(self): + self.parser['type'] = {'underlined': 't'} + with open(self.path, 'w') as fh: + self.parser.write(fh) + + config = CLIConfig() + with self.assertRaisesRegex( + configparser.Error, 'underlined.*valid styling.*valid ' + 'stylings'): + config.parse_file(self.path) + + def test_config_bad_color(self): + self.parser['type'] = {'fg': 'purple'} + with open(self.path, 'w') as fh: + self.parser.write(fh) + + config = CLIConfig() + with self.assertRaisesRegex( + configparser.Error, 'purple.*valid color.*valid colors'): + config.parse_file(self.path) + + def test_config_bad_boolean(self): + self.parser['type'] = {'underline': 'g'} + with open(self.path, 'w') as fh: + self.parser.write(fh) + + config = CLIConfig() + with self.assertRaisesRegex( + configparser.Error, 'g.*valid boolean.*valid booleans'): + config.parse_file(self.path) + + def test_no_file(self): + config = CLIConfig() + with self.assertRaisesRegex( + configparser.Error, "'Path' is not a valid filepath."): + config.parse_file('Path') + + +class ReplayCLIUsageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.das = DummyArtifacts() + cls.tempdir = cls.das.tempdir + cls.pm = PluginManager() + + @classmethod + def tearDownClass(cls): + cls.das.free() + + def test_init_metadata(self): + use = ReplayCLIUsage() + var = use.init_metadata(name='testing', factory=lambda: None) + self.assertEqual(var.name, '') + self.assertEqual(var.var_type, 'metadata') + + def test_init_metadata_with_dumped_md_fn(self): + use = ReplayCLIUsage() + var = use.init_metadata( + name='testing', factory=lambda: None, dumped_md_fn='some_md') + self.assertEqual(var.var_type, 'metadata') + self.assertEqual(var.name, '"some_md.tsv"') + + def test_param_is_metadata_col(self): + cfg = ReplayConfig(use=ReplayCLIUsage(), + use_recorded_metadata=False) + + actual = param_is_metadata_column( + cfg, 'metadata', 'dummy_plugin', 'identity_with_metadata_column' + ) + self.assertTrue(actual) + + actual = param_is_metadata_column( + cfg, 'int1', 'dummy_plugin', 'concatenate_ints' + ) + self.assertFalse(actual) + + with self.assertRaisesRegex(KeyError, "No action.*registered.*"): + param_is_metadata_column( + cfg, 'ints', 'dummy_plugin', 'young' + ) + + with self.assertRaisesRegex(KeyError, "No param.*registered.*"): + param_is_metadata_column( + cfg, 'thugger', 'dummy_plugin', 'split_ints' + ) + + with self.assertRaisesRegex(KeyError, "No plugin.*registered.*"): + param_is_metadata_column( + cfg, 'fake_param', 'dummy_hard', 'split_ints' + ) + + def test_dump_recorded_md_file_to_custom_dir(self): + dag = self.das.int_seq_with_md.dag + uuid = self.das.int_seq_with_md.uuid + + out_dir = 'custom_dir' + provnode = dag.get_node_data(uuid) + og_md = provnode.metadata['metadata'] + action_name = 'concatenate_ints_0' + md_id = 'metadata' + fn = 'metadata.tsv' + + with tempfile.TemporaryDirectory() as tempdir: + cfg = ReplayConfig(use=ReplayCLIUsage(), + md_out_dir=(tempdir + '/' + out_dir)) + dump_recorded_md_file(cfg, provnode, action_name, md_id, fn) + out_path = pathlib.Path(tempdir) / out_dir / action_name / fn + + self.assertTrue(out_path.is_file()) + + dumped_df = pd.read_csv(out_path, sep='\t') + pd.testing.assert_frame_equal(dumped_df, og_md) + + # If we run it again, it shouldn't overwrite 'recorded_metadata', + # so we should have two files + action_name_2 = 'concatenate_ints_1' + md_id2 = 'metadata' + fn2 = 'metadata_1.tsv' + dump_recorded_md_file(cfg, provnode, action_name_2, md_id2, fn2) + out_path2 = pathlib.Path(tempdir) / out_dir / action_name_2 / fn2 + + # are both files where expected? + self.assertTrue(out_path.is_file()) + self.assertTrue(out_path2.is_file()) + + def test_build_import_usage_cli(self): + ns = ReplayNamespaces() + cfg = ReplayConfig(use=ReplayCLIUsage(), + use_recorded_metadata=False) + dag = self.das.concated_ints_v6.dag + import_uuid = '8dea2f1a-2164-4a85-9f7d-e0641b1db22b' + import_node = dag.get_node_data(import_uuid) + c_to_s_type = camel_to_snake(import_node.type) + unq_var_nm = c_to_s_type + '_0' + build_import_usage(import_node, ns, cfg) + rendered = cfg.use.render() + usg_var = ns.get_usg_var_record(import_uuid).variable + out_name = usg_var.to_interface_name() + + self.assertIsInstance(usg_var, UsageVariable) + self.assertEqual(usg_var.var_type, 'artifact') + self.assertEqual(usg_var.name, unq_var_nm) + self.assertRegex(rendered, r'rachis tools import \\') + self.assertRegex(rendered, f" --type '{import_node.type}'") + self.assertRegex(rendered, " --input-path ") + self.assertRegex(rendered, f" --output-path {out_name}") + + def test_build_action_usage_cli(self): + plugin = 'dummy-plugin' + action = 'concatenate-ints' + cfg = ReplayConfig(use=ReplayCLIUsage(), + use_recorded_metadata=False) + + ns = ReplayNamespaces() + import_var_1 = CLIUsageVariable( + 'imported_ints_0', lambda: None, 'artifact', cfg.use + ) + import_var_2 = CLIUsageVariable( + 'imported_ints_1', lambda: None, 'artifact', cfg.use + ) + import_uuid_1 = '8dea2f1a-2164-4a85-9f7d-e0641b1db22b' + import_uuid_2 = '7727c060-5384-445d-b007-b64b41a090ee' + ns.add_usg_var_record(import_uuid_1, 'imported_ints_0') + ns.update_usg_var_record(import_uuid_1, import_var_1) + ns.add_usg_var_record(import_uuid_2, 'imported_ints_1') + ns.update_usg_var_record(import_uuid_2, import_var_2) + + dag = self.das.concated_ints_v6.dag + action_uuid = '5035a60e-6f9a-40d4-b412-48ae52255bb5' + node_uuid = '6facaf61-1676-45eb-ada0-d530be678b27' + node = dag.get_node_data(node_uuid) + actions = ActionCollections( + std_actions={action_uuid: {node_uuid: 'concatenated_ints'}} + ) + unique_var_name = node.action.output_name + '_0' + build_action_usage(node, ns, actions.std_actions, action_uuid, cfg) + rendered = cfg.use.render() + + usg_var = ns.get_usg_var_record(node_uuid).variable + out_name = usg_var.to_interface_name() + + self.assertIsInstance(usg_var, UsageVariable) + self.assertEqual(usg_var.var_type, 'artifact') + self.assertEqual(usg_var.name, unique_var_name) + + self.assertIn(f'rachis {plugin} {action}', rendered) + self.assertIn('--i-ints1 imported-ints-0.qza', rendered) + self.assertIn('--i-ints3 imported-ints-1.qza', rendered) + self.assertIn('--p-int1 7', rendered) + self.assertIn(f'--o-concatenated-ints {out_name}', rendered) + + def test_replay_optional_param_is_none(self): + dag = self.das.int_seq_optional_input.dag + with tempfile.TemporaryDirectory() as tempdir: + out_path = pathlib.Path(tempdir) / 'ns_coll.txt' + replay_provenance(ReplayCLIUsage, dag, out_path, + md_out_dir=tempdir) + + with open(out_path, 'r') as fp: + rendered = fp.read() + self.assertIn('--i-ints int-sequence1-0.qza', rendered) + self.assertIn('--p-num1', rendered) + self.assertNotIn('--i-optional1', rendered) + self.assertNotIn('--p-num2', rendered) + + def test_replay_from_provdag_ns_collision(self): + """ + This artifact's dag contains a few results with the output-name + filtered-table, so is a good check for namespace collisions if + we're not uniquifying variable names properly. + """ + with tempfile.TemporaryDirectory() as tempdir: + self.das.concated_ints.artifact.save( + os.path.join(tempdir, 'c1.qza') + ) + self.das.other_concated_ints.artifact.save( + os.path.join(tempdir, 'c2.qza') + ) + dag = ProvDAG(tempdir) + + exp = ['concatenated-ints-0', 'concatenated-ints-1'] + with tempfile.TemporaryDirectory() as tempdir: + out_path = pathlib.Path(tempdir) / 'ns_coll.txt' + replay_provenance(ReplayCLIUsage, dag, out_path, + md_out_dir=tempdir) + + with open(out_path, 'r') as fp: + rendered = fp.read() + for name in exp: + self.assertIn(name, rendered) + + +class WriteReproducibilitySupplementTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.das = DummyArtifacts() + cls.tempdir = cls.das.tempdir + cls.pm = PluginManager() + + @classmethod + def tearDownClass(cls): + cls.das.free() + + def test_replay_supplement_from_fp(self): + fp = self.das.concated_ints_with_md.filepath + with tempfile.TemporaryDirectory() as tempdir: + out_fp = os.path.join(tempdir, 'supplement.zip') + replay_supplement( + usage_drivers=[ReplayPythonUsage, ReplayCLIUsage], + payload=fp, + out_fp=out_fp + ) + + self.assertTrue(zipfile.is_zipfile(out_fp)) + + exp = { + 'supplement/', + 'supplement/python3_replay.py', + 'supplement/cli_replay.sh', + 'supplement/citations.bib', + 'supplement/recorded_metadata/', + 'supplement/recorded_metadata/' + 'dummy_plugin_identity_with_metadata_0/metadata_0.tsv', + } + with zipfile.ZipFile(out_fp, 'r') as myzip: + namelist_set = set(myzip.namelist()) + for item in exp: + self.assertIn(item, namelist_set) + + def test_replay_supplement_from_provdag(self): + dag = self.das.concated_ints_with_md.dag + + with tempfile.TemporaryDirectory() as tempdir: + out_fp = os.path.join(tempdir, 'supplement.zip') + replay_supplement( + usage_drivers=[ReplayPythonUsage, ReplayCLIUsage], + payload=dag, + out_fp=out_fp + ) + + self.assertTrue(zipfile.is_zipfile(out_fp)) + + exp = { + 'supplement/', + 'supplement/python3_replay.py', + 'supplement/cli_replay.sh', + 'supplement/citations.bib', + 'supplement/recorded_metadata/', + 'supplement/recorded_metadata/' + 'dummy_plugin_identity_with_metadata_0/metadata_0.tsv', + } + with zipfile.ZipFile(out_fp, 'r') as myzip: + namelist_set = set(myzip.namelist()) + for item in exp: + self.assertIn(item, namelist_set) + + +if __name__ == "__main__": + unittest.main() diff --git a/rachis_cli/tests/test_dev.py b/rachis_cli/tests/test_dev.py new file mode 100644 index 00000000..0eef894c --- /dev/null +++ b/rachis_cli/tests/test_dev.py @@ -0,0 +1,190 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os +import shutil +import unittest +import tempfile +import configparser + +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 rachis_cli.util +from rachis_cli.builtin.dev import dev + + +class TestDev(unittest.TestCase): + path = os.path.join(rachis_cli.util.get_app_dir(), 'cli-colors.theme') + old_settings = None + if os.path.exists(path): + old_settings = configparser.ConfigParser() + old_settings.read(path) + + def setUp(self): + get_dummy_plugin() + self.parser = configparser.ConfigParser() + self.runner = CliRunner(mix_stderr=False) + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + self.generated_config = os.path.join(self.tempdir, 'generated-theme') + + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + self.artifact1_path = os.path.join(self.tempdir, 'a1.qza') + self.mapping_path = os.path.join(self.tempdir, 'mapping.qza') + + artifact1 = Artifact.import_data(IntSequence1, [0, 42, 43]) + artifact1.save(self.artifact1_path) + self.artifact1_root_dir = str(artifact1.uuid) + + mapping = Artifact.import_data('Mapping', {'foo': '42'}) + mapping.save(self.mapping_path) + + self.config = os.path.join(self.tempdir, 'good-config.ini') + self.parser['type'] = {'underline': 't'} + with open(self.config, 'w') as fh: + self.parser.write(fh) + + def tearDown(self): + if self.old_settings is not None: + with open(self.path, 'w') as fh: + self.old_settings.write(fh) + shutil.rmtree(self.tempdir) + + def test_import_theme(self): + result = self.runner.invoke( + dev, ['import-theme', '--theme', self.config]) + self.assertEqual(result.exit_code, 0) + + def test_export_default_theme(self): + result = self.runner.invoke( + dev, ['export-default-theme', '--output-path', + self.generated_config]) + self.assertEqual(result.exit_code, 0) + + def test_reset_theme(self): + result = self.runner.invoke( + dev, ['reset-theme', '--yes']) + self.assertEqual(result.exit_code, 0) + + def test_reset_theme_no_yes(self): + result = self.runner.invoke( + dev, ['reset-theme']) + self.assertNotEqual(result.exit_code, 0) + + # result_type & result_data tests + def test_assert_result_type_artifact_success(self): + result = self.runner.invoke(dev, + ['assert-result-type', + self.mapping_path, + '--qiime-type', 'Mapping']) + + # single regex to account for tempdir path + + expected_regex = r'The input file \(.*mapping.qza\) type and the'\ + r' expected type \(Mapping\) match' + + self.assertEqual(result.exit_code, 0) + self.assertRegex(result.stdout, expected_regex) + + def test_assert_result_type_visualization_success(self): + dummy_plugin = get_dummy_plugin() + + self.viz_path = os.path.join(self.tempdir, 'viz.qzv') + most_common_viz = dummy_plugin.actions['most_common_viz'] + viz = most_common_viz(Artifact.load(self.artifact1_path)) + viz.visualization.save(self.viz_path) + + result = self.runner.invoke(dev, + ['assert-result-type', + self.viz_path, + '--qiime-type', + 'Visualization']) + + expected_regex = r'The input file \(.*viz\.qzv\) type and the'\ + r' expected type \(Visualization\) match' + + self.assertEqual(result.exit_code, 0) + self.assertRegex(result.stdout, expected_regex) + + def test_assert_result_type_load_failure(self): + result = self.runner.invoke(dev, + ['assert-result-type', + 'turkey_sandwhere.qza', + '--qiime-type', 'Mapping']) + + self.assertEqual(result.exit_code, 1) + self.assertRegex(result.stderr, + r'Path\s*\'turkey_sandwhere\.qza\'\s*does not exist') + + def test_assert_result_type_invalid_rachis_type(self): + result = self.runner.invoke(dev, + ['assert-result-type', + self.mapping_path, + '--qiime-type', 'Squid']) + self.assertEqual(result.exit_code, 1) + self.assertIn('Expected Squid, observed Mapping', result.stderr) + + def test_assert_result_data_success(self): + result = self.runner.invoke(dev, + ['assert-result-data', + self.mapping_path, + '--zip-data-path', 'mapping.tsv', + '--expression', '42']) + self.assertEqual(result.exit_code, 0) + self.assertIn(r'"42" was found in mapping.tsv', result.stdout) + + def test_assert_result_data_load_failure(self): + result = self.runner.invoke(dev, + ['assert-result-data', + 'turkey_sandwhen.qza', + '--zip-data-path', 'mapping.tsv', + '--expression', '42']) + + self.assertEqual(result.exit_code, 1) + self.assertRegex(result.stderr, + r'File\s*\'turkey_sandwhen\.qza\'\s*does not exist') + + def test_assert_result_data_zip_data_path_zero_matches(self): + result = self.runner.invoke(dev, + ['assert-result-data', + self.mapping_path, + '--zip-data-path', 'turkey_sandwhy.tsv', + '--expression', '42']) + + self.assertEqual(result.exit_code, 1) + self.assertRegex(result.stderr, + r'did not produce exactly one match.\n' + r'Matches: \[\]\n') + + def test_assert_result_data_zip_data_path_multiple_matches(self): + self.double_path = os.path.join(self.tempdir, 'double.qza') + double_artifact = Artifact.import_data('SingleInt', 3) + double_artifact.save(self.double_path) + result = self.runner.invoke(dev, ['assert-result-data', + self.double_path, + '--zip-data-path', + 'file*.txt', + '--expression', + '3']) + self.assertEqual(result.exit_code, 1) + self.assertRegex(result.stderr, r'Value provided for zip_data_path' + r' \(file\*\.txt\) did not produce' + r' exactly one match\.') + + def test_assert_result_data_match_expression_not_found(self): + result = self.runner.invoke(dev, ['assert-result-data', + self.mapping_path, + '--zip-data-path', 'mapping.tsv', + '--expression', 'foobar']) + + self.assertEqual(result.exit_code, 1) + self.assertRegex(result.stderr, + r'Expression \'foobar\'' + r' not found in .*\/data\/mapping\.tsv\.') diff --git a/rachis_cli/tests/test_mystery_stew.py b/rachis_cli/tests/test_mystery_stew.py new file mode 100644 index 00000000..84c26558 --- /dev/null +++ b/rachis_cli/tests/test_mystery_stew.py @@ -0,0 +1,51 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os +import subprocess +import tempfile + +from rachis_cli.core.usage import CLIUsage +from rachis_cli.util import get_plugin_manager + +import pytest + + +def _labeler(val): + if hasattr(val, 'id'): + return val.id + return val + + +def get_tests(): + tests = [] + pm = get_plugin_manager() + try: + plugin = pm.plugins['mystery-stew'] + except KeyError: + return tests + for action in plugin.actions.values(): + for name in action.examples: + tests.append((action, name)) + return tests + + +@pytest.mark.parametrize('action,example', get_tests(), ids=_labeler) +def test_mystery_stew(action, example): + example_f = action.examples[example] + use = CLIUsage(enable_assertions=True) + example_f(use) + rendered = '\n'.join(use.recorder) + with tempfile.TemporaryDirectory() as tmpdir: + for ref, data in use.get_example_data(): + data.save(os.path.join(tmpdir, ref)) + subprocess.run([rendered], + shell=True, + check=True, + cwd=tmpdir, + env={**os.environ}) diff --git a/rachis_cli/tests/test_tools.py b/rachis_cli/tests/test_tools.py new file mode 100644 index 00000000..733ebd7a --- /dev/null +++ b/rachis_cli/tests/test_tools.py @@ -0,0 +1,2127 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os +import gc +import re +import yaml +import pytest +import shutil +import unittest +from unittest.mock import patch +import tempfile +import zipfile +import bibtexparser as bp + +from click.testing import CliRunner +from qiime2 import Artifact, Metadata +from qiime2.core.testing.util import get_dummy_plugin +from qiime2.core.testing.type import IntSequence1, IntSequence2, SingleInt +from qiime2.metadata.base import SUPPORTED_COLUMN_TYPES +from qiime2.core.cache import Cache +from qiime2.sdk.result import Result +from qiime2.sdk.plugin_manager import PluginManager +from qiime2.core.annotate import Note + +from rachis_cli.util import load_metadata +from rachis_cli.builtin.tools import tools +from rachis_cli.commands import RootCommand +from rachis_cli.core.usage import ReplayCLIUsage + + +class TestCastMetadata(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + self.metadata_file = os.path.join( + self.tempdir, 'metadata.tsv') + with open(self.metadata_file, 'w') as f: + f.write('id\tnumbers\tstrings\n0\t42\tabc\n1\t-1.5\tdef') + + self.cast_metadata_dump = \ + ('id\tnumbers\tstrings\n#q2:types\tcategorical\tcategorical\n0\t42' + '\tabc\n1\t-1.5\tdef\n\n') + + self.output_file = os.path.join( + self.tempdir, 'test_output.tsv') + + def test_input_invalid_column_type(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'numbers:foo', '--output-file', self.output_file]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn('Unknown column type provided.', result.output) + + def test_input_duplicate_columns(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'numbers:numerical', '--cast', 'numbers:categorical', + '--output-file', self.output_file]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn( + '"numbers" appears in cast more than once.', result.output) + + def test_input_invalid_cast_format_missing_colon(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', 'numbers', + '--output-file', self.output_file]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn('Missing `:` in --cast numbers', result.output) + + def test_input_invalid_cast_format_extra_colon(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', 'numbers::', + '--output-file', self.output_file]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn('Incorrect number of fields in --cast numbers::', + result.output) + self.assertIn('Observed 3', result.output) + + def test_error_on_extra(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'extra:numeric', '--output-file', self.output_file]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn( + "The following cast columns were not found within the" + " metadata: extra", result.output) + + def test_error_on_missing(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'numbers:categorical', '--error-on-missing', + '--output-file', self.output_file]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn( + "The following columns within the metadata" + " were not provided in the cast: strings", + result.output) + + def test_extra_columns_removed(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'numbers:categorical', '--cast', 'extra:numeric', + '--ignore-extra', '--output-file', self.output_file]) + + self.assertEqual(result.exit_code, 0) + casted_metadata = load_metadata(self.output_file) + self.assertNotIn('extra', casted_metadata.columns.keys()) + + def test_complete_successful_run(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'numbers:categorical', '--output-file', self.output_file]) + + self.assertEqual(result.exit_code, 0) + input_metadata = load_metadata(self.metadata_file) + self.assertEqual('numeric', input_metadata.columns['numbers'].type) + + casted_metadata = load_metadata(self.output_file) + self.assertEqual('categorical', + casted_metadata.columns['numbers'].type) + + def test_write_to_stdout(self): + result = self.runner.invoke( + tools, ['cast-metadata', self.metadata_file, '--cast', + 'numbers:categorical']) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(self.cast_metadata_dump, result.output) + + def test_valid_column_types(self): + result = self.runner.invoke(tools, ['cast-metadata', '--help']) + for col_type in SUPPORTED_COLUMN_TYPES: + self.assertIn(col_type, result.output) + + +class TestInspectMetadata(unittest.TestCase): + def setUp(self): + dummy_plugin = get_dummy_plugin() + + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + self.metadata_file_mixed_types = os.path.join( + self.tempdir, 'metadata-mixed-types.tsv') + with open(self.metadata_file_mixed_types, 'w') as f: + f.write('id\tnumbers\tstrings\n0\t42\tabc\n1\t-1.5\tdef\n') + + self.bad_metadata_file = os.path.join( + self.tempdir, 'bad-metadata.tsv') + with open(self.bad_metadata_file, 'w') as f: + f.write('wrong\tnumbers\tstrings\nid1\t42\tabc\nid2\t-1.5\tdef\n') + + self.metadata_artifact = os.path.join(self.tempdir, 'metadata.qza') + Artifact.import_data( + 'Mapping', {'a': 'dog', 'b': 'cat'}).save(self.metadata_artifact) + + self.ints1 = os.path.join(self.tempdir, 'ints1.qza') + ints1 = Artifact.import_data( + 'IntSequence1', [0, 42, 43], list) + ints1.save(self.ints1) + + self.ints2 = os.path.join(self.tempdir, 'ints') + ints1.export_data(self.ints2) + + self.viz = os.path.join(self.tempdir, 'viz.qzv') + most_common_viz = dummy_plugin.actions['most_common_viz'] + self.viz = most_common_viz(ints1).visualization.save(self.viz) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_artifact_w_metadata(self): + result = self.runner.invoke( + tools, ['inspect-metadata', self.metadata_artifact]) + + self.assertEqual(result.exit_code, 0) + self.assertIn('COLUMN NAME TYPE', result.output) + self.assertIn("=========== ===========", result.output) + self.assertIn("a categorical", result.output) + self.assertIn("b categorical", result.output) + self.assertIn("IDS: 1", result.output) + self.assertIn("COLUMNS: 2", result.output) + + def test_artifact_no_metadata(self): + result = self.runner.invoke(tools, ['inspect-metadata', self.ints1]) + + self.assertEqual(result.exit_code, 1) + self.assertIn("IntSequence1 cannot be viewed as rachis metadata", + result.output) + + def test_visualization(self): + # make a viz first: + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + # build output parameter arguments and expected output file names + viz_path = os.path.join(self.tempdir, 'viz.qzv') + result = self.runner.invoke( + command, ['most-common-viz', '--i-ints', self.ints1, + '--o-visualization', viz_path, '--verbose']) + + result = self.runner.invoke(tools, ['inspect-metadata', viz_path]) + + self.assertEqual(result.exit_code, 1) + self.assertIn("Visualizations cannot be viewed as rachis metadata", + result.output) + + def test_metadata_file(self): + result = self.runner.invoke( + tools, ['inspect-metadata', self.metadata_file_mixed_types]) + + self.assertEqual(result.exit_code, 0) + self.assertIn('COLUMN NAME TYPE', result.output) + self.assertIn("=========== ===========", result.output) + self.assertIn("numbers numeric", result.output) + self.assertIn("strings categorical", result.output) + self.assertIn("IDS: 2", result.output) + self.assertIn("COLUMNS: 2", result.output) + + def test_bad_metadata_file(self): + result = self.runner.invoke( + tools, ['inspect-metadata', self.bad_metadata_file]) + + self.assertEqual(result.exit_code, 1) + self.assertIn("'wrong'", result.output) + + def test_tsv(self): + result = self.runner.invoke(tools, [ + 'inspect-metadata', self.metadata_file_mixed_types, '--tsv']) + + self.assertEqual(result.exit_code, 0) + self.assertIn('COLUMN NAME\tTYPE', result.output) + self.assertIn("numbers\tnumeric", result.output) + self.assertIn("strings\tcategorical", result.output) + + self.assertNotIn("=", result.output) + self.assertNotIn("IDS:", result.output) + self.assertNotIn("COLUMNS:", result.output) + + def test_merged_metadata(self): + result = self.runner.invoke(tools, [ + 'inspect-metadata', + self.metadata_artifact, + self.metadata_file_mixed_types]) + + self.assertEqual(result.exit_code, 0) + self.assertIn('COLUMN NAME TYPE', result.output) + self.assertIn("=========== ===========", result.output) + self.assertIn("a categorical", result.output) + self.assertIn("b categorical", result.output) + self.assertIn("numbers numeric", result.output) + self.assertIn("strings categorical", result.output) + self.assertIn("IDS: 1", result.output) # only 1 ID is shared + self.assertIn("COLUMNS: 4", result.output) + + def test_export_to_dir_w_format(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceDirectoryFormat' + ]) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.isdir(output_path)) + + def test_export_to_dir_no_format(self): + output_path = os.path.join(self.tempdir, 'output') + self.runner.invoke(tools, [ + 'export', '--input-path', self.viz, '--output-path', output_path + ]) + + self.assertTrue(os.path.isdir(output_path)) + self.assertIn('index.html', os.listdir(output_path)) + self.assertIn('index.tsv', os.listdir(output_path)) + + def test_export_to_file(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceFormatV2' + ]) + + with open(output_path, 'r') as f: + file = f.read() + self.assertEqual(result.exit_code, 0) + self.assertIn('0', file) + self.assertIn('42', file) + self.assertIn('43', file) + + def test_export_to_file_creates_directories(self): + output_path = os.path.join(self.tempdir, 'somewhere', 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceFormatV2' + ]) + + with open(output_path, 'r') as f: + file = f.read() + self.assertEqual(result.exit_code, 0) + self.assertIn('0', file) + self.assertIn('42', file) + self.assertIn('43', file) + + def test_export_visualization_to_dir(self): + output_path = os.path.join(self.tempdir, 'output') + self.runner.invoke(tools, [ + 'export', '--input-path', self.viz, '--output-path', output_path + ]) + + self.assertIn('index.html', os.listdir(output_path)) + self.assertIn('index.tsv', os.listdir(output_path)) + self.assertTrue(os.path.isdir(output_path)) + + def test_export_visualization_w_format(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.viz, '--output-path', output_path, + '--output-format', 'IntSequenceDirectoryFormat' + ]) + + self.assertEqual(result.exit_code, 1) + self.assertIn('visualization', result.output) + self.assertIn('--output-format', result.output) + + def test_export_path_file_is_replaced(self): + output_path = os.path.join(self.tempdir, 'output') + with open(output_path, 'w') as file: + file.write('HelloWorld') + self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceFormatV2' + ]) + with open(output_path, 'r') as f: + file = f.read() + self.assertNotIn('HelloWorld', file) + + def test_export_to_file_with_format_success_message(self): + output_path = os.path.join(self.tempdir, 'output.int') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceFormatV2' + ]) + success = 'Exported %s as IntSequenceFormatV2 to file %s\n' % ( + self.ints1, output_path) + self.assertEqual(success, result.output) + + def test_export_to_dir_without_format_success_message(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path + ]) + success = 'Exported %s as IntSequenceDirectoryFormat to '\ + 'directory %s\n' % (self.ints1, output_path) + self.assertEqual(success, result.output) + + def test_export_visualization_to_dir_success_message(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.viz, '--output-path', output_path + ]) + success = 'Exported %s as Visualization to '\ + 'directory %s\n' % (self.viz, output_path) + self.assertEqual(success, result.output) + + def test_extract_to_dir_success_message(self): + result = self.runner.invoke(tools, [ + 'extract', '--input-path', self.ints1, + '--output-path', self.tempdir + ]) + success = 'Extracted %s to directory %s' % (self.ints1, self.tempdir) + self.assertIn(success, result.output) + + def test_import_from_directory_without_format_success_message(self): + output_path = os.path.join(self.tempdir, 'output.qza') + result = self.runner.invoke(tools, [ + 'import', '--input-path', self.ints2, '--type', 'IntSequence1', + '--output-path', output_path + ]) + success = 'Imported %s as IntSequenceDirectoryFormat to '\ + '%s\n' % (self.ints2, output_path) + self.assertEqual(success, result.output) + + def test_import_from_file_with_format_success_message(self): + output_path = os.path.join(self.tempdir, 'output.qza') + result = self.runner.invoke(tools, [ + 'import', '--input-path', os.path.join(self.ints2, 'ints.txt'), + '--type', 'IntSequence1', + '--output-path', output_path, + '--input-format', 'IntSequenceFormat' + ]) + success = 'Imported %s as IntSequenceFormat to '\ + '%s\n' % (os.path.join(self.ints2, 'ints.txt'), output_path) + self.assertEqual(success, result.output) + + +class TestExportToFileFormat(TestInspectMetadata): + def setUp(self): + super().setUp() + # Working directory is changed to temp directory to prevent cluttering + # the repo directory with test files + self.current_dir = os.getcwd() + os.chdir(self.tempdir) + + def tearDown(self): + super().tearDown() + os.chdir(self.current_dir) + + def test_export_file_format(self): + output_path = os.path.join(os.getcwd(), 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceFormat' + ]) + success = 'Exported %s as IntSequenceFormat to file %s\n' % \ + (self.ints1, output_path) + self.assertEqual(success, result.output) + + def test_export_dir_format(self): + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', os.getcwd(), + '--output-format', 'IntSequenceDirectoryFormat' + ]) + + success = 'Exported %s as IntSequenceDirectoryFormat to directory ' \ + '%s\n' % (self.ints1, os.getcwd()) + self.assertEqual(success, result.output) + + def test_export_dir_format_nested(self): + output_path = os.path.join(os.getcwd(), 'output') + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.ints1, '--output-path', output_path, + '--output-format', 'IntSequenceDirectoryFormat' + ]) + + success = 'Exported %s as IntSequenceDirectoryFormat to directory ' \ + '%s\n' % (self.ints1, output_path) + self.assertEqual(success, result.output) + + def test_export_to_filename_without_path(self): + output_path = 'output' + result = self.runner.invoke(tools, [ + 'export', '--input-path', self.viz, '--output-path', output_path + ]) + success = 'Exported %s as Visualization to '\ + 'directory %s\n' % (self.viz, output_path) + self.assertEqual(success, result.output) + + +class TestImport(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.runner = CliRunner() + + cls.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + cls.in_dir1 = os.path.join(cls.tempdir, 'input1') + os.mkdir(cls.in_dir1) + with open(os.path.join(cls.in_dir1, 'ints.txt'), 'w') as fh: + for i in range(5): + fh.write(f'{i}\n') + fh.write('a\n') + + cls.in_dir2 = os.path.join(cls.tempdir, 'input2') + os.mkdir(cls.in_dir2) + with open(os.path.join(cls.in_dir2, 'ints.txt'), 'w') as fh: + fh.write('1\n') + fh.write('a\n') + fh.write('3\n') + + cls.cache = Cache(os.path.join(cls.tempdir, 'new_cache')) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tempdir) + + def test_import_min_validate(self): + out_fp = os.path.join(self.tempdir, 'out1.qza') + + # import with min allows format error outside of min purview + # (validate level min checks only first 5 items) + result = self.runner.invoke(tools, [ + 'import', '--type', 'IntSequence1', '--input-path', self.in_dir1, + '--output-path', out_fp, '--validate-level', 'min' + ]) + self.assertEqual(result.exit_code, 0) + + # import with max should catch all format errors, max is default + result = self.runner.invoke(tools, [ + 'import', '--type', 'IntSequence1', '--input-path', + self.in_dir1, '--output-path', out_fp + ]) + self.assertEqual(result.exit_code, 1) + self.assertIn('Line 6 is not an integer', result.output) + + out_fp = os.path.join(self.tempdir, 'out2.qza') + + # import with min catches format errors within its purview + result = self.runner.invoke(tools, [ + 'import', '--type', 'IntSequence1', '--input-path', + self.in_dir2, '--output-path', out_fp, '--validate-level', 'min' + ]) + self.assertEqual(result.exit_code, 1) + self.assertIn('Line 2 is not an integer', result.output) + + def test_cache_import_min_validate(self): + # import with min allows format error outside of min purview + # (validate level min checks only first 5 items) + result = self.runner.invoke(tools, [ + 'cache-import', '--type', 'IntSequence1', '--input-path', + self.in_dir1, '--cache', str(self.cache.path), '--key', 'foo', + '--validate-level', 'min' + ]) + self.assertEqual(result.exit_code, 0) + + # import with max should catch all format errors, max is default + result = self.runner.invoke(tools, [ + 'cache-import', '--type', 'IntSequence1', '--input-path', + self.in_dir1, '--cache', str(self.cache.path), '--key', 'foo' + ]) + self.assertEqual(result.exit_code, 1) + self.assertIn('Line 6 is not an integer', result.output) + + # import with min catches format errors within its purview + result = self.runner.invoke(tools, [ + 'cache-import', '--type', 'IntSequence1', '--input-path', + self.in_dir2, '--cache', str(self.cache.path), '--key', 'foo', + '--validate-level', 'min' + ]) + self.assertEqual(result.exit_code, 1) + self.assertIn('Line 2 is not an integer', result.output) + + +class TestCacheTools(unittest.TestCase): + def setUp(self): + get_dummy_plugin() + + self.runner = CliRunner() + self.plugin_command = RootCommand().get_command( + ctx=None, name='dummy-plugin') + self.tempdir = \ + tempfile.TemporaryDirectory(prefix='rachis-cli-test-temp-') + + self.art1 = Artifact.import_data('IntSequence1', [0, 1, 2]) + self.art2 = Artifact.import_data('IntSequence1', [3, 4, 5]) + self.art3 = Artifact.import_data('IntSequence1', [6, 7, 8]) + self.art4 = Artifact.import_data('IntSequence2', [9, 10, 11]) + self.to_import = os.path.join(self.tempdir.name, 'to_import') + self.art1.export_data(self.to_import) + self.cache = Cache(os.path.join(self.tempdir.name, 'new_cache')) + + def tearDown(self): + self.tempdir.cleanup() + + def test_cache_create(self): + cache_path = os.path.join(self.tempdir.name, 'created_cache') + + result = self.runner.invoke( + tools, ['cache-create', '--cache', cache_path]) + + success = "Created cache at '%s'\n" % cache_path + self.assertEqual(success, result.output) + self.assertTrue(Cache.is_cache(cache_path)) + + def test_cache_remove(self): + self.cache.save(self.art1, 'key') + self.assertTrue('key' in self.cache.get_keys()) + + result = self.runner.invoke( + tools, + ['cache-remove', '--cache', str(self.cache.path), '--key', 'key']) + + success = \ + f"Removed key 'key' from cache '{str(self.cache.path)}'\n" \ + f"Removed key(s) '('key',)' from cache '{str(self.cache.path)}'\n" + self.assertEqual(success, result.output) + self.assertFalse('key' in self.cache.get_keys()) + + def test_cache_remove_multiple(self): + self.cache.save(self.art1, 'key3') + self.cache.save(self.art1, 'key2') + self.cache.save(self.art1, 'key1') + + keys = self.cache.get_keys() + self.assertEqual(['key1', 'key2', 'key3'], keys) + + result = self.runner.invoke( + tools, + ['cache-remove', '--cache', str(self.cache.path), '--key', 'key1', + '--key', 'key2'] + ) + + success = \ + f"Removed key 'key1' from cache '{str(self.cache.path)}'\n" \ + f"Removed key 'key2' from cache '{str(self.cache.path)}'\n" \ + "Removed key(s) '('key1', 'key2')' from cache " \ + f"'{str(self.cache.path)}'\n" + + new_keys = self.cache.get_keys() + + self.assertEqual(success, result.output) + self.assertFalse('key1' in new_keys) + self.assertFalse('key2' in new_keys) + self.assertTrue('key3' in new_keys) + + def test_cache_garbage_collection(self): + # Data referenced directly by key + self.cache.save(self.art1, 'foo') + # Data referenced by pool that is referenced by key + pool = self.cache.create_pool(key='bar') + pool.save(self.art2) + # We will be manually deleting the keys that back these two + self.cache.save(self.art3, 'baz') + pool = self.cache.create_pool(key='qux') + pool.save(self.art4) + + # What we expect to see before and after gc + expected_pre_gc_contents = \ + set(('./VERSION', 'keys/foo', 'keys/bar', + 'keys/baz', 'keys/qux', + f'pools/bar/{self.art2.uuid}', + f'pools/qux/{self.art4.uuid}', + f'data/{self.art1.uuid}', f'data/{self.art2.uuid}', + f'data/{self.art3.uuid}', f'data/{self.art4.uuid}')) + + expected_post_gc_contents = \ + set(('./VERSION', 'keys/foo', 'keys/bar', + f'pools/bar/{self.art2.uuid}', + f'data/{self.art1.uuid}', f'data/{self.art2.uuid}')) + + # Assert cache looks how we want pre gc + pre_gc_contents = _get_cache_contents(self.cache) + self.assertEqual(expected_pre_gc_contents, pre_gc_contents) + + # Delete keys + self.cache.remove(self.cache.keys / 'baz') + self.cache.remove(self.cache.keys / 'qux') + + # Make sure Python's garbage collector gets the process pool symlinks + # to the artifact that was keyed on baz and the one in the qux pool + gc.collect() + result = self.runner.invoke( + tools, + ['cache-garbage-collection', '--cache', str(self.cache.path)]) + + success = "Ran garbage collection on cache at '%s'\n" % self.cache.path + self.assertEqual(success, result.output) + + # Assert cache looks how we want post gc + post_gc_contents = _get_cache_contents(self.cache) + self.assertEqual(expected_post_gc_contents, post_gc_contents) + + def test_cache_store(self): + artifact = os.path.join(self.tempdir.name, 'artifact.qza') + self.art1.save(artifact) + + result = self.runner.invoke( + tools, ['cache-store', '--cache', str(self.cache.path), + '--artifact-path', artifact, '--key', 'key']) + + success = "Saved the artifact '%s' to the cache '%s' under the key " \ + "'key'\n" % (artifact, self.cache.path) + self.assertEqual(success, result.output) + + def test_cache_fetch(self): + artifact = os.path.join(self.tempdir.name, 'artifact.qza') + self.cache.save(self.art1, 'key') + + result = self.runner.invoke( + tools, ['cache-fetch', '--cache', str(self.cache.path), + '--key', 'key', '--output-path', artifact]) + + success = "Loaded artifact with the key 'key' from the cache '%s' " \ + "and saved it to the file '%s'\n" % (self.cache.path, artifact) + self.assertEqual(success, result.output) + + def test_cache_roundtrip(self): + in_artifact = os.path.join(self.tempdir.name, 'in_artifact.qza') + out_artifact = os.path.join(self.tempdir.name, 'out_artifact.qza') + + self.art1.save(in_artifact) + + result = self.runner.invoke( + tools, ['cache-store', '--cache', str(self.cache.path), + '--artifact-path', in_artifact, '--key', 'key']) + + success = "Saved the artifact '%s' to the cache '%s' under the key " \ + "'key'\n" % (in_artifact, self.cache.path) + self.assertEqual(success, result.output) + + result = self.runner.invoke( + tools, ['cache-fetch', '--cache', str(self.cache.path), + '--key', 'key', '--output-path', out_artifact]) + + success = "Loaded artifact with the key 'key' from the cache '%s' " \ + "and saved it to the file '%s'\n" % (self.cache.path, out_artifact) + self.assertEqual(success, result.output) + + artifact = Artifact.load(out_artifact) + self.assertEqual([0, 1, 2], artifact.view(list)) + + def test_cache_status(self): + success_template = \ + "Status of the cache at the path '%s':\n\n%s\n\n%s\n" + + # Empty cache + result = self.runner.invoke( + tools, ['cache-status', '--cache', str(self.cache.path)]) + success = \ + success_template % (str(self.cache.path), 'No data keys in cache', + 'No pool keys in cache') + self.assertEqual(success, result.output) + + # Cache with only data + in_artifact = os.path.join(self.tempdir.name, 'in_artifact.qza') + self.art1.save(in_artifact) + self.runner.invoke( + tools, ['cache-store', '--cache', str(self.cache.path), + '--artifact-path', in_artifact, '--key', 'key']) + + result = self.runner.invoke( + tools, ['cache-status', '--cache', str(self.cache.path)]) + data_output = 'Data keys in cache:\ndata: key -> %s' % \ + str(Result.peek(self.cache.data / str(self.art1.uuid))) + success = \ + success_template % (str(self.cache.path), data_output, + 'No pool keys in cache') + self.assertEqual(success, result.output) + + # Cache with data and pool + pool = self.cache.create_pool(key='pool') + pool.save(self.art2) + + result = self.runner.invoke( + tools, ['cache-status', '--cache', str(self.cache.path)]) + pool_output = 'Pool keys in cache:\npool: pool -> size = 1' + success = \ + success_template % (str(self.cache.path), data_output, + pool_output) + self.assertEqual(success, result.output) + + def test_cache_import(self): + self.max_diff = None + result = self.runner.invoke( + tools, ['cache-import', '--type', 'IntSequence1', '--input-path', + self.to_import, '--cache', f'{self.cache.path}', '--key', + 'foo']) + success = 'Imported %s as IntSequenceDirectoryFormat to %s:foo\n' % \ + (self.to_import, self.cache.path) + self.assertEqual(success, result.output) + + +def _get_cache_contents(cache): + """Gets contents of cache not including contents of the artifacts + themselves relative to the root of the cache + """ + cache_contents = set() + + rel_keys = os.path.relpath(cache.keys, cache.path) + rel_data = os.path.relpath(cache.data, cache.path) + rel_pools = os.path.relpath(cache.pools, cache.path) + rel_cache = os.path.relpath(cache.path, cache.path) + + for key in os.listdir(cache.keys): + cache_contents.add(os.path.join(rel_keys, key)) + + for art in os.listdir(cache.data): + cache_contents.add(os.path.join(rel_data, art)) + + for pool in os.listdir(cache.pools): + for link in os.listdir(os.path.join(cache.pools, pool)): + cache_contents.add(os.path.join(rel_pools, pool, link)) + + for elem in os.listdir(cache.path): + if os.path.isfile(os.path.join(cache.path, elem)): + cache_contents.add(os.path.join(rel_cache, elem)) + + return cache_contents + + +class TestPeek(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + # create artifact + self.artifact = os.path.join(self.tempdir, 'artifact.qza') + Artifact.import_data( + 'Mapping', {'foo': 'bar'}).save(self.artifact) + + # create visualization + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + self.viz = os.path.join(self.tempdir, 'viz.qzv') + + self.ints = os.path.join(self.tempdir, 'ints.qza') + ints = Artifact.import_data( + 'IntSequence1', [0, 42, 43], list) + ints.save(self.ints) + + self.runner.invoke( + command, ['most-common-viz', '--i-ints', self.ints, + '--o-visualization', self.viz, '--verbose']) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_single_artifact(self): + result = self.runner.invoke(tools, ['peek', self.artifact]) + self.assertEqual(result.exit_code, 0) + self.assertIn("UUID:", result.output) + self.assertIn("Type:", result.output) + self.assertIn("Data format:", result.output) + self.assertEqual(result.output.count('\n'), 3) + + def test_single_visualization(self): + result = self.runner.invoke(tools, ['peek', self.viz]) + self.assertEqual(result.exit_code, 0) + self.assertIn("UUID:", result.output) + self.assertIn("Type:", result.output) + self.assertNotIn("Data format:", result.output) + self.assertEqual(result.output.count('\n'), 2) + + def test_artifact_and_visualization(self): + result = self.runner.invoke(tools, ['peek', self.artifact, self.viz]) + self.assertEqual(result.exit_code, 0) + self.assertIn("UUID", result.output) + self.assertIn("Type", result.output) + self.assertIn("Data Format", result.output) + self.assertIn("N/A", result.output) + self.assertEqual(result.output.count('\n'), 3) + + def test_single_file_tsv(self): + result = self.runner.invoke(tools, ['peek', '--tsv', self.artifact]) + self.assertIn("Filename\tType\tUUID\tData Format\n", result.output) + self.assertIn("artifact.qza", result.output) + self.assertEqual(result.output.count('\t'), 6) + self.assertEqual(result.output.count('\n'), 2) + + def test_multiple_file_tsv(self): + result = self.runner.invoke(tools, ['peek', '--tsv', self.artifact, + self.viz]) + self.assertIn("Filename\tType\tUUID\tData Format\n", result.output) + self.assertIn("artifact.qza", result.output) + self.assertIn("viz.qzv", result.output) + self.assertEqual(result.output.count('\t'), 9) + self.assertEqual(result.output.count('\n'), 3) + + +class TestListTypes(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.pm = PluginManager() + + def tearDown(self): + pass + + def test_list_all_types(self): + result = self.runner.invoke(tools, ['list-types']) + self.assertEqual(result.exit_code, 0) + + for name, artifact_class_record in self.pm.artifact_classes.items(): + self.assertIn(name, result.output) + self.assertIn(artifact_class_record.description, result.output) + + def test_list_types_fuzzy(self): + types = list(self.pm.artifact_classes)[:5] + result = self.runner.invoke(tools, ['list-types', *types]) + self.assertEqual(result.exit_code, 0) + + # split on \n\n because types and their description are separated + # by two newlines + # len - 1 because split includes '' for the last \n\n split + self.assertGreaterEqual(len(result.output.split('\n\n')) - 1, + len(types)) + + def test_list_types_strict(self): + types = list(self.pm.artifact_classes)[:5] + result = self.runner.invoke(tools, ['list-types', '--strict', *types]) + self.assertEqual(result.exit_code, 0) + self.assertEqual(len(result.output.split('\n\n')) - 1, len(types)) + + result = self.runner.invoke(tools, ['list-types', '--strict', + types[0] + 'x']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(len(result.output), 0) + + result = self.runner.invoke(tools, ['list-types', '--strict', *types, + types[0] + 'x']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(len(result.output.split('\n\n')) - 1, len(types)) + + def test_list_types_tsv(self): + result = self.runner.invoke(tools, ['list-types', '--tsv']) + self.assertEqual(result.exit_code, 0) + + # len - 1 because \n split produces a final '' + self.assertEqual(len(result.output.split('\n')) - 1, + len(self.pm.artifact_classes)) + + no_description_count = 0 + for name, artifact_class_record in self.pm.artifact_classes.items(): + self.assertIn(name, result.output) + self.assertIn(artifact_class_record.description, result.output) + if artifact_class_record.description == '': + no_description_count += 1 + + self.assertEqual(no_description_count, result.output.count('\t\n')) + + +class TestListFormats(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.pm = PluginManager() + + def tearDown(self): + pass + + def test_list_all_importable_formats(self): + result = self.runner.invoke(tools, ['list-formats', '--importable']) + self.assertEqual(result.exit_code, 0) + + for name, format_record in self.pm.importable_formats.items(): + self.assertIn(name, result.output) + docstring = format_record.format.__doc__ + if docstring: + description = docstring.split('\n\n')[0].strip() + for word in description: + self.assertIn(word.strip(), result.output) + + def test_list_all_exportable_formats(self): + result = self.runner.invoke(tools, ['list-formats', '--exportable']) + self.assertEqual(result.exit_code, 0) + + for name, format_record in self.pm.exportable_formats.items(): + self.assertIn(name, result.output) + docstring = format_record.format.__doc__ + if docstring: + description = docstring.split('\n\n')[0].strip() + for word in description: + self.assertIn(word.strip(), result.output) + + def test_list_formats_fuzzy(self): + formats = list(self.pm.importable_formats)[:5] + result = self.runner.invoke(tools, ['list-formats', '--importable', + *formats]) + self.assertEqual(result.exit_code, 0) + + # see TestListTypes.test_list_types_fuzzy + self.assertGreaterEqual(len(result.output.split('\n\n')) - 1, + len(formats)) + + def test_list_formats_strict(self): + formats = list(self.pm.exportable_formats)[:5] + result = self.runner.invoke(tools, ['list-formats', '--exportable', + '--strict', *formats]) + self.assertEqual(result.exit_code, 0) + self.assertEqual(len(result.output.split('\n\n')) - 1, len(formats)) + + result = self.runner.invoke(tools, ['list-formats', '--exportable', + '--strict', formats[0] + 'x']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(len(result.output), 0) + + result = self.runner.invoke(tools, ['list-formats', '--exportable', + '--strict', *formats, + formats[0] + 'x']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(len(result.output.split('\n\n')) - 1, len(formats)) + + def test_list_formats_tsv(self): + result = self.runner.invoke(tools, ['list-formats', '--importable', + '--tsv']) + self.assertEqual(result.exit_code, 0) + + # len - 1 because \n split produces a final '' + self.assertEqual(len(result.output.split('\n')) - 1, + len(self.pm.importable_formats)) + + no_description_count = 0 + for name, format_record in self.pm.importable_formats.items(): + self.assertIn(name, result.output) + docstring = format_record.format.__doc__ + if docstring: + description = docstring.split('\n\n')[0].strip() + for word in description: + self.assertIn(word.strip(), result.output) + + if format_record.format.__doc__ is None: + no_description_count += 1 + self.assertEqual(no_description_count, result.output.count('\t\n')) + + +class TestReplay(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.pm = PluginManager() + self.dp = self.pm.plugins['dummy-plugin'] + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-replay-temp-') + + # contrive artifacts with different sorts of provenance + int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) + int_seq2 = Artifact.import_data('IntSequence1', [4, 5, 6]) + int_seq3 = Artifact.import_data('IntSequence2', [7, 8]) + concat_ints = self.dp.actions['concatenate_ints'] + concated_ints, = concat_ints(int_seq1, int_seq2, int_seq3, 9, 0) + concated_ints.save(os.path.join(self.tempdir, 'concated_ints.qza')) + + outer_dir = os.path.join(self.tempdir, 'outer_dir') + inner_dir = os.path.join(self.tempdir, 'outer_dir', 'inner_dir') + os.mkdir(outer_dir) + os.mkdir(inner_dir) + shutil.copy(os.path.join(self.tempdir, 'concated_ints.qza'), outer_dir) + int_seq = Artifact.import_data('IntSequence1', [1, 2, 3, 4]) + left_ints, _ = self.dp.actions['split_ints'](int_seq) + left_ints.save(os.path.join(inner_dir, 'left_ints.qza')) + + mapping = Artifact.import_data('Mapping', {'rachis': 2, 'triangle': 3}) + int_seq_with_md, = self.dp.actions['identity_with_metadata']( + int_seq1, + mapping.view(Metadata)) + int_seq_with_md.save(os.path.join(self.tempdir, 'int_seq_with_md.qza')) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_replay_provenance_cache(self): + cache = Cache(os.path.join(self.tempdir, 'cache')) + int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) + cache.save(int_seq1, 'int_seq1') + cache_fp = os.path.join(self.tempdir, 'cache:int_seq1') + + self.runner.invoke( + tools, + ['replay-provenance', 'in-fp', cache_fp, '--out-fp', self.tempdir] + ) + + def test_replay_provenance(self): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp, 'r') as fh: + rendered = fh.read() + + self.assertIn('rachis tools import', rendered) + self.assertIn('--type \'IntSequence1\'', rendered) + self.assertIn('--type \'IntSequence2\'', rendered) + self.assertIn('--input-path ', rendered) + self.assertIn('--output-path int-sequence1-0.qza', rendered) + self.assertIn('--output-path int-sequence1-1.qza', rendered) + self.assertIn('--output-path int-sequence2-0.qza', rendered) + + self.assertIn('rachis dummy-plugin concatenate-ints', rendered) + self.assertRegex(rendered, '--i-ints[12] int-sequence1-0.qza') + self.assertRegex(rendered, '--i-ints[12] int-sequence1-1.qza') + self.assertIn('--i-ints3 int-sequence2-0.qza', rendered) + self.assertIn('--p-int1 9', rendered) + self.assertIn('--p-int2 0', rendered) + self.assertIn('--o-concatenated-ints concatenated-ints-0.qza', + rendered) + + def test_replay_provenance_python(self): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, + '--usage-driver', 'python3'] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp, 'r') as fh: + rendered = fh.read() + + self.assertIn('from rachis import Artifact', rendered) + self.assertIn('Artifact.import_data', rendered) + self.assertIn('dummy_plugin_actions.concatenate_ints', rendered) + + def test_replay_provenance_recurse(self): + """ + If the directory is parsed recursively, both the concated_ints.qza and + left_ints.qza will be captured. + """ + in_fp = os.path.join(self.tempdir, 'outer_dir') + out_fp = os.path.join(self.tempdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, + '--usage-driver', 'python3', '--recurse'] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp, 'r') as fh: + rendered = fh.read() + + self.assertIn('dummy_plugin_actions.concatenate_ints', rendered) + self.assertIn('dummy_plugin_actions.split_ints', rendered) + + def test_replay_provenance_use_md_without_parse(self): + in_fp = os.path.join(self.tempdir, 'outer_dir') + out_fp = os.path.join(self.tempdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, + '--no-parse-metadata', '--use-recorded-metadata'] + ) + self.assertEqual(result.exit_code, 1) + self.assertIsInstance(result.exception, ValueError) + self.assertRegex(str(result.exception), + 'Metadata not parsed for replay') + + @patch('qiime2.sdk.util.get_available_usage_drivers', + return_value={'cli': ReplayCLIUsage}) + def test_replay_provenance_usage_driver_not_available(self, patch): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, + '--usage-driver', 'python3'] + ) + self.assertEqual(result.exit_code, 1) + self.assertIsInstance(result.exception, ValueError) + self.assertIn( + 'python3 usage driver is not available', str(result.exception) + ) + + def test_replay_citations(self): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'citations.bib') + result = self.runner.invoke( + tools, + ['replay-citations', '--in-fp', in_fp, '--out-fp', out_fp] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp) as fh: + bib_database = bp.load(fh) + + # use .*? to non-greedily match version strings + exp = [ + r'action\|dummy-plugin:.*?\|method:concatenate_ints\|0', + r'framework\|rachis:.*?\|0', + r'plugin\|dummy-plugin:.*?\|0', + r'plugin\|dummy-plugin:.*?\|1', + r'transformer\|dummy-plugin:.*?\|builtins:list->' + r'IntSequenceDirectoryFormat\|0', + r'transformer\|dummy-plugin:.*?\|builtins:list->' + r'IntSequenceV2DirectoryFormat\|4', + r'transformer\|dummy-plugin:.*?\|builtins:list->' + r'IntSequenceV2DirectoryFormat\|5', + r'transformer\|dummy-plugin:.*?\|builtins:list->' + r'IntSequenceV2DirectoryFormat|6', + r'transformer\|dummy-plugin:.*?\|builtins:list->' + r'IntSequenceV2DirectoryFormat\|8', + r'view\|dummy-plugin:.*?\|IntSequenceDirectoryFormat\|0' + ] + + self.assertEqual(len(exp), len(bib_database.entries)) + + all_records_str = '' + for record in bib_database.entries_dict.keys(): + all_records_str += f' {record}' + for record in exp: + self.assertRegex(all_records_str, record) + + def test_replay_citations_no_deduplicate(self): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'citations.bib') + result = self.runner.invoke( + tools, + ['replay-citations', '--in-fp', in_fp, '--out-fp', out_fp, + '--no-deduplicate'] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp) as fh: + bib_database = bp.load(fh) + self.assertEqual(28, len(bib_database.entries)) + + with open(out_fp) as fh: + file_contents = fh.read() + framework_citations = \ + re.compile(r'framework\|rachis:.*?\|0.*' * 4, re.DOTALL) + self.assertRegex(file_contents, framework_citations) + + def test_replay_citations_cache(self): + cache = Cache(os.path.join(self.tempdir, 'cache')) + int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) + cache.save(int_seq1, 'int_seq1') + cache_fp = os.path.join(self.tempdir, 'cache:int_seq1') + + self.runner.invoke( + tools, + ['replay-citations', 'in-fp', cache_fp, '--out-fp', self.tempdir] + ) + + def test_replay_supplement(self): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'supplement.zip') + result = self.runner.invoke( + tools, + ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] + ) + self.assertEqual(result.exit_code, 0) + self.assertTrue(zipfile.is_zipfile(out_fp)) + + exp = { + 'supplement/', + 'supplement/python3_replay.py', + 'supplement/cli_replay.sh', + 'supplement/citations.bib' + } + with zipfile.ZipFile(out_fp, 'r') as zfh: + self.assertEqual(exp, set(zfh.namelist())) + + def test_replay_supplement_with_metadata(self): + in_fp = os.path.join(self.tempdir, 'int_seq_with_md.qza') + out_fp = os.path.join(self.tempdir, 'supplement.zip') + result = self.runner.invoke( + tools, + ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] + ) + self.assertEqual(result.exit_code, 0) + self.assertTrue(zipfile.is_zipfile(out_fp)) + + exp = { + 'supplement/', + 'supplement/python3_replay.py', + 'supplement/cli_replay.sh', + 'supplement/citations.bib', + 'supplement/recorded_metadata/', + 'supplement/recorded_metadata/' + 'dummy_plugin_identity_with_metadata_0/', + 'supplement/recorded_metadata/' + 'dummy_plugin_identity_with_metadata_0/metadata_0.tsv', + } + with zipfile.ZipFile(out_fp, 'r') as zfh: + self.assertEqual(exp, set(zfh.namelist())) + + def test_replay_supplement_no_metadata_dump(self): + in_fp = os.path.join(self.tempdir, 'int_seq_with_md.qza') + out_fp = os.path.join(self.tempdir, 'supplement.zip') + result = self.runner.invoke( + tools, + ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp, + '--no-dump-recorded-metadata'] + ) + self.assertEqual(result.exit_code, 0) + self.assertTrue(zipfile.is_zipfile(out_fp)) + + not_exp = 'recorded_metadata/' + with zipfile.ZipFile(out_fp, 'r') as zfh: + self.assertNotIn(not_exp, set(zfh.namelist())) + + @patch('qiime2.sdk.util.get_available_usage_drivers', return_value={}) + def test_replay_supplement_usage_driver_not_available(self, patch): + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(self.tempdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] + ) + self.assertEqual(result.exit_code, 1) + self.assertIsInstance(result.exception, ValueError) + self.assertIn( + 'no available usage drivers', str(result.exception) + ) + + def test_replay_supplement_zipfile(self): + with tempfile.TemporaryDirectory() as tempdir: + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + out_fp = os.path.join(tempdir, 'supplement.zip') + + result = self.runner.invoke( + tools, + ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] + ) + self.assertEqual(result.exit_code, 0) + self.assertTrue(zipfile.is_zipfile(out_fp)) + + unzipped_path = os.path.join(tempdir, 'extracted') + os.makedirs(unzipped_path) + with zipfile.ZipFile(out_fp, 'r') as zfh: + zfh.extractall(unzipped_path) + + self.assertEqual(os.listdir(unzipped_path), ['supplement']) + + def test_replay_supplement_cache(self): + cache = Cache(os.path.join(self.tempdir, 'cache')) + int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) + cache.save(int_seq1, 'int_seq1') + cache_fp = os.path.join(self.tempdir, 'cache:int_seq1') + + self.runner.invoke( + tools, + ['replay-supplement', 'in-fp', cache_fp, '--out-fp', self.tempdir] + ) + + # Leave me alone I know the checksums don't match + @pytest.mark.filterwarnings('ignore::UserWarning') + def test_replay_param_not_found(self): + rachis_cli = RootCommand() + command = rachis_cli.get_command(ctx=None, name='dummy-plugin') + + in_fp = os.path.join(self.tempdir, 'concated_ints.qza') + left_path = os.path.join(self.tempdir, 'left.qza') + right_path = os.path.join(self.tempdir, 'right.qza') + + result = self.runner.invoke( + command, ['split-ints', '--i-ints', in_fp, + '--o-left', left_path, '--o-right', right_path, + '--verbose']) + + self.assertEqual(result.exit_code, 0) + + left = Artifact.load(left_path) + action_fp = os.path.join( + left._archiver.provenance_dir, 'action', 'action.yaml' + ) + with open(action_fp, 'r+') as action_fh: + action_string = action_fh.read() + action_yaml = yaml.safe_load(action_string) + action_yaml['action']['parameters'].append({'not': 'real'}) + + action_fh.write(yaml.dump(action_yaml)) + + with tempfile.TemporaryDirectory() as tmpdir: + out_fp = os.path.join(tmpdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', left_path, '--out-fp', + out_fp, '--no-dump-recorded-metadata'] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp, 'r') as fh: + rendered = fh.read() + + MISSING_PARAM = \ +""" + # FIXME: The following parameter name was not found in your current + # rachis environment. This may occur when the plugin version you have + # installed does not match the version used in the original analysis. + # Please see the docs and correct the parameter name before running. + --?-not real \\ +""" # noqa: E128 + self.assertIn(MISSING_PARAM, rendered) + + def test_replay_action_not_found(self): + datadir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'data' + ) + artifact_fp = os.path.join(datadir, 'rarefied_table.qza') + + with tempfile.TemporaryDirectory() as tmpdir: + out_fp = os.path.join(tmpdir, 'rendered.txt') + result = self.runner.invoke( + tools, + ['replay-provenance', '--in-fp', artifact_fp, '--out-fp', + out_fp, '--no-dump-recorded-metadata'] + ) + self.assertEqual(result.exit_code, 0) + + with open(out_fp, 'r') as fh: + rendered = fh.read() + + import1 = \ +""" +rachis tools import \\ + --type 'Phylogeny[Rooted]' \\ + --input-path \\ + --output-path phylogeny-rooted-0.qza +""" # noqa: E128 + + import2 = \ +""" +rachis tools import \\ + --type 'FeatureTable[Frequency]' \\ + --input-path \\ + --output-path feature-table-frequency-0.qza +""" # noqa: E128 + + self.assertIn(import1, rendered) + self.assertIn(import2, rendered) + + FIXME_action = \ +""" +# FIXME: The following action was not found in your current rachis +# environment. Please ensure the action and its parameters are correct before +# running. +rachis diversity core-metrics-phylogenetic \\ + --?-table feature-table-frequency-0.qza \\ + --?-phylogeny phylogeny-rooted-0.qza \\ + --?-sampling-depth 13 \\ + --?-metadata .tsv \\ + --?-with-replacement False \\ + --?-n-jobs-or-threads 1 \\ + --?-ignore-missing-samples False \\ + --output-dir diversity-core-metrics-phylogenetic +""" # noqa: E128 + self.assertIn(FIXME_action, rendered) + + +class TestAnnotations(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + # artifact without any starting annotations + self.art1 = os.path.join(self.tempdir, 'ints1.qza') + Artifact.import_data('IntSequence1', [0, 1, 2]).save(self.art1) + + # artifact that will have one starting annotation + self.art2 = os.path.join(self.tempdir, 'ints2.qza') + Artifact.import_data('IntSequence1', [1, 2, 3]).save(self.art2) + + # add note to art2 + self.note1 = Note(name='mynote', text='my special text') + Artifact.load(self.art2).add_annotation(self.note1) + + # output path for self.art1 after adding annotation + self.output1 = os.path.join(self.tempdir, 'ints1_annotated.qza') + + # annotation path for note from file + self.note_file = os.path.join( + self.tempdir, 'note_file.txt') + with open(self.note_file, 'w') as f: + f.write('my special text') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + # ANNOTATION_CREATE + def test_annotation_create_with_text_success(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Note', + '--name', 'mynote', '--text', 'my special text', + '--output-path', self.output1] + ) + # confirm the command doesn't produce an error + self.assertEqual(create_result.exit_code, 0) + + output_uuid = Result.load(self.output1).uuid + # this will just produce one annotation + for annotation in Result.load(self.output1).iter_annotations(): + annotation_uuid = annotation.id + + exp_namelist = { + f'{output_uuid}/checksums.sha512', + f'{output_uuid}/metadata.yaml', + f'{output_uuid}/VERSION', + f'{output_uuid}/annotations/{annotation_uuid}/checksums.sha512', + f'{output_uuid}/annotations/{annotation_uuid}/metadata.yaml', + f'{output_uuid}/annotations/{annotation_uuid}/note.txt', + f'{output_uuid}/provenance/metadata.yaml', + f'{output_uuid}/provenance/citations.bib', + f'{output_uuid}/provenance/VERSION', + f'{output_uuid}/provenance/conda-env.yaml', + f'{output_uuid}/provenance/action/action.yaml', + f'{output_uuid}/data/ints.txt' + } + + exp_contents = 'my special text' + + with zipfile.ZipFile(self.output1, 'r') as zfh: + # confirm file structure is what we expect + self.assertEqual(exp_namelist, set(zfh.namelist())) + + annotation = zfh.read( + f'{output_uuid}/annotations/{annotation_uuid}/note.txt' + ).decode('utf-8') + + # confirm the annotation file contains the text we expect + self.assertEqual(exp_contents, annotation) + + # make sure we see same results as w/inline text for input + def test_annotation_create_with_file_success(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Note', + '--name', 'mynote', '--file', self.note_file, + '--output-path', self.output1] + ) + # confirm the command doesn't produce an error + self.assertEqual(create_result.exit_code, 0) + + output_uuid = Result.load(self.output1).uuid + # this will just produce one annotation + for annotation in Result.load(self.output1).iter_annotations(): + annotation_uuid = annotation.id + + exp_namelist = { + f'{output_uuid}/checksums.sha512', + f'{output_uuid}/metadata.yaml', + f'{output_uuid}/VERSION', + f'{output_uuid}/annotations/{annotation_uuid}/checksums.sha512', + f'{output_uuid}/annotations/{annotation_uuid}/metadata.yaml', + f'{output_uuid}/annotations/{annotation_uuid}/note.txt', + f'{output_uuid}/provenance/metadata.yaml', + f'{output_uuid}/provenance/citations.bib', + f'{output_uuid}/provenance/VERSION', + f'{output_uuid}/provenance/conda-env.yaml', + f'{output_uuid}/provenance/action/action.yaml', + f'{output_uuid}/data/ints.txt' + } + + exp_contents = 'my special text' + + with zipfile.ZipFile(self.output1, 'r') as zfh: + # confirm file structure is what we expect + self.assertEqual(exp_namelist, set(zfh.namelist())) + + annotation = zfh.read( + f'{output_uuid}/annotations/{annotation_uuid}/note.txt' + ).decode('utf-8') + + # confirm the annotation file contains the text we expect + self.assertEqual(exp_contents, annotation) + + # make sure we don't run into any errors & see same result + # when overwriting the original artifact fp + def test_annotation_create_overwriting_input_file_success(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Note', + '--name', 'mynote', '--text', 'my special text', + '--output-path', self.art1] + ) + # confirm the command doesn't produce an error + self.assertEqual(create_result.exit_code, 0) + + output_uuid = Result.load(self.art1).uuid + # this will just produce one annotation + for annotation in Result.load(self.art1).iter_annotations(): + annotation_uuid = annotation.id + + exp_namelist = { + f'{output_uuid}/checksums.sha512', + f'{output_uuid}/metadata.yaml', + f'{output_uuid}/VERSION', + f'{output_uuid}/annotations/{annotation_uuid}/checksums.sha512', + f'{output_uuid}/annotations/{annotation_uuid}/metadata.yaml', + f'{output_uuid}/annotations/{annotation_uuid}/note.txt', + f'{output_uuid}/provenance/metadata.yaml', + f'{output_uuid}/provenance/citations.bib', + f'{output_uuid}/provenance/VERSION', + f'{output_uuid}/provenance/conda-env.yaml', + f'{output_uuid}/provenance/action/action.yaml', + f'{output_uuid}/data/ints.txt' + } + + exp_contents = 'my special text' + + with zipfile.ZipFile(self.art1, 'r') as zfh: + # confirm file structure is what we expect + self.assertEqual(exp_namelist, set(zfh.namelist())) + + annotation = zfh.read( + f'{output_uuid}/annotations/{annotation_uuid}/note.txt' + ).decode('utf-8') + + # confirm the annotation file contains the text we expect + self.assertEqual(exp_contents, annotation) + + def test_annotation_create_invalid_annotation_type_failure(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Foo', + '--name', 'mynote', '--text', 'my special text', + '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(create_result.exit_code, 1) + self.assertIn("Invalid value for '--annotation-type': 'Foo'", + create_result.output) + + def test_annotation_create_text_and_filepath_provided_failure(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Note', + '--name', 'mynote', '--text', 'my special text', + '--file', self.note_file, '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(create_result.exit_code, 2) + self.assertIn("Exactly one of `--text` or `--file` must be provided", + create_result.output) + + def test_annotation_create_no_text_or_filepath_provided_failure(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Note', + '--name', 'mynote', '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(create_result.exit_code, 2) + self.assertIn("Exactly one of `--text` or `--file` must be provided", + create_result.output) + + def test_annotation_create_invalid_annotation_name_failure(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art1, + '--annotation-type', 'Note', + '--name', '@#$%^&*', '--text', 'my special text', + '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(create_result.exit_code, 1) + self.assertIn('Name "@#$%^&*" is not a valid Python identifier', + str(create_result.exception)) + + def test_annotation_create_existing_annotation_name_failure(self): + create_result = self.runner.invoke( + tools, + ['annotation-create', '--input-path', self.art2, + '--annotation-type', 'Note', + '--name', 'mynote', '--file', self.note_file, + '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(create_result.exit_code, 1) + self.assertIn('Duplicate name detected when attempting to add ' + 'Annotation with name: "mynote"', create_result.output) + + # ANNOTATION_REMOVE + def test_annotation_remove_new_filepath_success(self): + remove_result = self.runner.invoke( + tools, + ['annotation-remove', '--input-path', self.art2, + '--name', 'mynote', '--output-path', self.output1] + ) + # confirm the command doesn't produce an error + self.assertEqual(remove_result.exit_code, 0) + + with zipfile.ZipFile(self.output1, 'r') as zfh: + # confirm annotations dir has been removed + self.assertNotIn('annotations', set(zfh.namelist())) + + def test_annotation_remove_existing_filepath_success(self): + remove_result = self.runner.invoke( + tools, + ['annotation-remove', '--input-path', self.art2, + '--name', 'mynote', '--output-path', self.art2] + ) + # confirm the command doesn't produce an error + self.assertEqual(remove_result.exit_code, 0) + + with zipfile.ZipFile(self.art2, 'r') as zfh: + # confirm annotations dir has been removed + self.assertNotIn('annotations', set(zfh.namelist())) + + def test_annotation_remove_no_annotations_failure(self): + remove_result = self.runner.invoke( + tools, + ['annotation-remove', '--input-path', self.art1, + '--name', 'mynote', '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(remove_result.exit_code, 1) + self.assertIn('No Annotation found with name: "mynote"', + remove_result.output) + + def test_annotation_remove_wrong_annotation_name_failure(self): + remove_result = self.runner.invoke( + tools, + ['annotation-remove', '--input-path', self.art2, + '--name', 'myspecialnote', '--output-path', self.output1] + ) + # confirm the command does produce an error + self.assertEqual(remove_result.exit_code, 1) + self.assertIn('No Annotation found with name: "myspecialnote"', + remove_result.output) + + # ANNOTATION_FETCH + def test_annotation_fetch_no_verbose_success(self): + fetch_result = self.runner.invoke( + tools, + ['annotation-fetch', '--input-path', self.art2, '--name', 'mynote'] + ) + # confirm the command doesn't produce an error + self.assertEqual(fetch_result.exit_code, 0) + # ensure the annotation type & name we expect is present in the output + self.assertRegex(fetch_result.output, + r'name:\s*mynote\s*type:\s*Note\b') + + def test_annotation_fetch_verbose_success(self): + fetch_result = self.runner.invoke( + tools, + ['annotation-fetch', '--input-path', self.art2, + '--name', 'mynote', '--verbose'] + ) + # confirm the command doesn't produce an error + self.assertEqual(fetch_result.exit_code, 0) + # ensure the annotation type & name we expect is present in the output + self.assertRegex( + fetch_result.output, + r'name:\s*mynote\s*type:\s*Note\s*contents:\s*my special text\b' + ) + + def test_annotation_fetch_wrong_name_failure(self): + fetch_result = self.runner.invoke( + tools, + ['annotation-fetch', '--input-path', self.art2, + '--name', 'mynote2'] + ) + # confirm the command does produce an error + self.assertEqual(fetch_result.exit_code, 1) + self.assertIn('No Annotation with name: "mynote2"', + fetch_result.output) + + # ANNOTATION_LIST + def test_annotation_list_multiple_annotations_success(self): + self.note2 = Note(name='myothernote', text='my extra special text') + + self.arty = Artifact.load(self.art2) + self.arty.add_annotation(self.note2) + self.arty.save(self.art2) + + list_result = self.runner.invoke( + tools, + ['annotation-list', '--input-path', self.art2] + ) + # confirm the command doesn't produce an error + self.assertEqual(list_result.exit_code, 0) + self.assertRegex(list_result.output, + r'name:\s*mynote\s*type:\s*Note\b') + self.assertRegex(list_result.output, + r'name:\s*myothernote\s*type:\s*Note\b') + + def test_annotation_list_no_annotations_failure(self): + list_result = self.runner.invoke( + tools, + ['annotation-list', '--input-path', self.art1] + ) + # confirm the command does produce an error + self.assertEqual(list_result.exit_code, 1) + self.assertIn('No Annotations found.', list_result.output) + + +class TestCacheExport(unittest.TestCase): + def setUp(self): + dummy_plugin = get_dummy_plugin() + + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + self.cache = Cache(os.path.join(self.tempdir, 'cache')) + + ints1 = Artifact.import_data( + 'IntSequence1', [0, 42, 43], list) + self.ints1 = self.cache.save(ints1, 'ints1') + + most_common_viz = dummy_plugin.actions['most_common_viz'] + viz = most_common_viz(ints1).visualization + self.viz = self.cache.save(viz, 'viz') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_cache_export_to_dir_w_format(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceDirectoryFormat' + ]) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(os.path.isdir(output_path)) + + def test_cache_export_to_dir_no_format(self): + output_path = os.path.join(self.tempdir, 'output') + self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', + '--output-path', output_path + ]) + + self.assertTrue(os.path.isdir(output_path)) + self.assertIn('index.html', os.listdir(output_path)) + self.assertIn('index.tsv', os.listdir(output_path)) + + def test_cache_export_to_file(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceFormatV2' + ]) + + with open(output_path, 'r') as f: + file = f.read() + self.assertEqual(result.exit_code, 0) + self.assertIn('0', file) + self.assertIn('42', file) + self.assertIn('43', file) + + def test_cache_export_to_file_creates_directories(self): + output_path = os.path.join(self.tempdir, 'somewhere', 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceFormatV2' + ]) + + with open(output_path, 'r') as f: + file = f.read() + self.assertEqual(result.exit_code, 0) + self.assertIn('0', file) + self.assertIn('42', file) + self.assertIn('43', file) + + def test_cache_export_visualization_to_dir(self): + output_path = os.path.join(self.tempdir, 'output') + self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', + '--output-path', output_path + ]) + + self.assertIn('index.html', os.listdir(output_path)) + self.assertIn('index.tsv', os.listdir(output_path)) + self.assertTrue(os.path.isdir(output_path)) + + def test_cache_export_visualization_w_format(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', + '--output-path', output_path, '--output-format', + 'IntSequenceDirectoryFormat' + ]) + + self.assertEqual(result.exit_code, 1) + self.assertIn('visualization', result.output) + self.assertIn('--output-format', result.output) + + def test_cache_export_path_file_is_replaced(self): + output_path = os.path.join(self.tempdir, 'output') + with open(output_path, 'w') as file: + file.write('HelloWorld') + self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceFormatV2' + ]) + + with open(output_path, 'r') as f: + file = f.read() + self.assertNotIn('HelloWorld', file) + + def test_cache_export_to_file_with_format_success_message(self): + output_path = os.path.join(self.tempdir, 'output.int') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceFormatV2' + ]) + success = f'Exported {self.cache.path}:ints1 as IntSequenceFormatV2 ' \ + f'to file {output_path}\n' + self.assertEqual(success, result.output) + + def test_cache_export_to_dir_without_format_success_message(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path + ]) + success = f'Exported {self.cache.path}:ints1 as ' \ + f'IntSequenceDirectoryFormat to directory {output_path}\n' + self.assertEqual(success, result.output) + + def test_cache_export_visualization_to_dir_success_message(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', + '--output-path', output_path + ]) + success = f'Exported {self.cache.path}:viz as Visualization to ' \ + f'directory {output_path}\n' + self.assertEqual(success, result.output) + + def test_cache_export_bad_cache(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.tempdir), '--key', 'viz', + '--output-path', output_path + ]) + + failure = f"Path: '{self.tempdir}' already exists and is not a cache." + self.assertIn(failure, result.output) + + def test_cache_export_bad_key(self): + output_path = os.path.join(self.tempdir, 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'bad', + '--output-path', output_path + ]) + + failure = f"The cache '{self.cache.path}' does not contain the key " \ + "'bad'" + self.assertIn(failure, result.output) + + +class TestCacheExportToFileFormat(unittest.TestCase): + def setUp(self): + dummy_plugin = get_dummy_plugin() + + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + self.cache = Cache(os.path.join(self.tempdir, 'cache')) + + ints1 = Artifact.import_data( + 'IntSequence1', [0, 42, 43], list) + self.ints1 = self.cache.save(ints1, 'ints1') + + most_common_viz = dummy_plugin.actions['most_common_viz'] + viz = most_common_viz(ints1).visualization + self.viz = self.cache.save(viz, 'viz') + + # Working directory is changed to temp directory to prevent cluttering + # the repo directory with test files + self.current_dir = os.getcwd() + os.chdir(self.tempdir) + + def tearDown(self): + shutil.rmtree(self.tempdir) + os.chdir(self.current_dir) + + def test_cache_export_file_format(self): + output_path = os.path.join(os.getcwd(), 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceFormat' + ]) + + success = f'Exported {self.cache.path}:ints1 as IntSequenceFormat ' \ + f'to file {output_path}\n' + self.assertEqual(success, result.output) + + def test_cache_export_to_filename_without_path(self): + output_path = 'output' + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', + '--output-path', output_path + ]) + success = f'Exported {self.cache.path}:viz as Visualization to ' \ + f'directory {output_path}\n' + self.assertEqual(success, result.output) + + def test_cache_export_dir_format(self): + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(str(self.cache.path)), '--key', + 'ints1', '--output-path', os.getcwd(), '--output-format', + 'IntSequenceDirectoryFormat' + ]) + + success = f'Exported {self.cache.path}:ints1 as ' \ + f'IntSequenceDirectoryFormat to directory {os.getcwd()}\n' + self.assertEqual(success, result.output) + + def test_cache_export_dir_format_nested(self): + output_path = os.path.join(os.getcwd(), 'output') + result = self.runner.invoke(tools, [ + 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', + '--output-path', output_path, '--output-format', + 'IntSequenceDirectoryFormat' + ]) + + success = f'Exported {self.cache.path}:ints1 as ' \ + f'IntSequenceDirectoryFormat to directory {output_path}\n' + self.assertEqual(success, result.output) + + +class TestMakeReport(unittest.TestCase): + def setUp(self): + # ensure dummy plugin is registered + self.dp = get_dummy_plugin() + self.runner = CliRunner() + self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + # create an artifact to drive visualizations + self.ints = Artifact.import_data('IntSequence1', [0, 1, 2]) + + # create two visualizations with unique basenames + self.viz1 = os.path.join(self.tempdir, 'viz1.qzv') + self.dp.actions['most_common_viz'](self.ints).visualization.save( + self.viz1) + + self.viz2 = os.path.join(self.tempdir, 'viz2.qzv') + self.dp.actions['most_common_viz'](self.ints).visualization.save( + self.viz2) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_make_report_success(self): + report = os.path.join(self.tempdir, 'report.qzv') + result = self.runner.invoke( + tools, ['make-report', self.viz1, self.viz2, '--report-path', + report]) + + self.assertEqual(result.exit_code, 0) + self.assertIn('Report saved to', result.output) + # ensure the report file was written and is a zip archive + self.assertTrue(zipfile.is_zipfile(report)) + + def test_make_report_duplicate_names(self): + # create two visualizations that share the same filename but live in + # different directories to trigger the duplicate-name error + a_dir = os.path.join(self.tempdir, 'a') + b_dir = os.path.join(self.tempdir, 'b') + os.mkdir(a_dir) + os.mkdir(b_dir) + + dup1 = os.path.join(a_dir, 'viz.qzv') + dup2 = os.path.join(b_dir, 'viz.qzv') + self.dp.actions['most_common_viz'](self.ints).visualization.save(dup1) + self.dp.actions['most_common_viz'](self.ints).visualization.save(dup2) + + report = os.path.join(self.tempdir, 'report.qzv') + result = self.runner.invoke( + tools, ['make-report', dup1, dup2, '--report-path', report]) + self.assertNotEqual(result.exit_code, 0) + self.assertIn('Multiple files share the same name', result.output) + + +class TestRedactMetadata(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') + + metadata_path = os.path.join(cls.tempdir, 'metadata.tsv') + with open(metadata_path, 'w') as fh: + fh.write('sample-id\tbarcode-sequence\n') + fh.write('1\tACT') + dummy_md = Metadata.load(metadata_path) + + pm = PluginManager() + identity_with_metadata = pm.plugins['dummy-plugin'].actions[ + 'identity_with_metadata' + ] + + cls.artifact1_fp = os.path.join(cls.tempdir, 'a1.qza') + cls.artifact2_fp = os.path.join(cls.tempdir, 'a2.qza') + cls.artifact3_fp = os.path.join(cls.tempdir, 'a3.qza') + + artifact1 = Artifact.import_data(IntSequence1, [0, 6, 7]) + artifact1, = identity_with_metadata(artifact1, dummy_md) + artifact1.save(cls.artifact1_fp) + artifact2 = Artifact.import_data(IntSequence2, [3, 4, 5]) + artifact2, = identity_with_metadata(artifact2, dummy_md) + artifact2.save(cls.artifact2_fp) + artifact3 = Artifact.import_data(SingleInt, 9) + artifact3.save(cls.artifact3_fp) + + cls.runner = CliRunner() + cls.redacted_fp = os.path.join(cls.tempdir, 'redacted') + + def test_with_metadata(self): + redacted_fp = self.redacted_fp + '.qza' + result = self.runner.invoke(tools, [ + 'redact-metadata', '--input-path', self.artifact1_fp, + '--output-path', redacted_fp + ]) + + self.assertEqual(result.exit_code, 0) + success = f'Succesfully redacted metadata from {self.artifact1_fp}, ' \ + f'and saved to {redacted_fp}.\n' + self.assertEqual(success, result.output) + + def test_fails_already_redacted(self): + ''' + Redacting metadata from an Artifact twice should fail. + ''' + redacted_fp = self.redacted_fp + '.qza' + self.runner.invoke(tools, [ + 'redact-metadata', '--input-path', self.artifact2_fp, + '--output-path', redacted_fp + ]) + + result = self.runner.invoke(tools, [ + 'redact-metadata', '--input-path', redacted_fp, + '--output-path', os.path.join(self.tempdir, 'bad') + ]) + + self.assertEqual(result.exit_code, 1) + self.assertIn('only redacted metadata', result.output) + + def test_fails_no_metadata(self): + ''' + Redacting metadata from an Artifact with no metadata should fail. + ''' + redacted_fp = self.redacted_fp + '.qza' + result = self.runner.invoke(tools, [ + 'redact-metadata', '--input-path', self.artifact3_fp, + '--output-path', redacted_fp + ]) + + self.assertEqual(result.exit_code, 1) + self.assertIn('Result without metadata', result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/rachis_cli/tests/test_usage.py b/rachis_cli/tests/test_usage.py new file mode 100644 index 00000000..dfec8854 --- /dev/null +++ b/rachis_cli/tests/test_usage.py @@ -0,0 +1,264 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 os +import sys +import subprocess +import tempfile +import unittest + +from rachis_cli.core.usage import CLIUsage + +from qiime2.core.testing.util import get_dummy_plugin +import pytest + + +def _rt_labeler(val): + if hasattr(val, 'id'): + return val.id + return val + + +@pytest.fixture +def dummy_plugin(monkeypatch): + monkeypatch.setenv('QIIMETEST', '') + return get_dummy_plugin() + + +def get_templated_tests(): + return [ + ('concatenate_ints', + """\ +# This example demonstrates basic usage. +rachis dummy-plugin concatenate-ints \\ + --i-ints1 ints-a.qza \\ + --i-ints2 ints-b.qza \\ + --i-ints3 ints-c.qza \\ + --p-int1 4 \\ + --p-int2 2 \\ + --o-concatenated-ints ints-d.qza +# This example demonstrates chained usage (pt 1). +rachis dummy-plugin concatenate-ints \\ + --i-ints1 ints-a.qza \\ + --i-ints2 ints-b.qza \\ + --i-ints3 ints-c.qza \\ + --p-int1 4 \\ + --p-int2 2 \\ + --o-concatenated-ints ints-d.qza +# This example demonstrates chained usage (pt 2). +rachis dummy-plugin concatenate-ints \\ + --i-ints1 ints-d.qza \\ + --i-ints2 ints-b.qza \\ + --i-ints3 ints-c.qza \\ + --p-int1 41 \\ + --p-int2 0 \\ + --o-concatenated-ints concatenated-ints.qza +# comment 1 +# comment 2 +# comment 1 +# comment 2"""), + ('identity_with_metadata', + """\ +rachis dummy-plugin identity-with-metadata \\ + --i-ints ints.qza \\ + --m-metadata-file md.tsv \\ + --o-out out.qza +rachis dummy-plugin identity-with-metadata \\ + --i-ints ints.qza \\ + --m-metadata-file md1.tsv md2.tsv \\ + --o-out out.qza"""), + ('identity_with_metadata_column', + """\ +rachis dummy-plugin identity-with-metadata-column \\ + --i-ints ints.qza \\ + --m-metadata-file md.tsv \\ + --m-metadata-column a \\ + --o-out out.qza"""), + ('typical_pipeline', + """\ +rachis dummy-plugin typical-pipeline \\ + --i-int-sequence ints.qza \\ + --i-mapping mapper.qza \\ + --p-do-extra-thing \\ + --o-out-map out-map.qza \\ + --o-left left.qza \\ + --o-right right.qza \\ + --o-left-viz left-viz.qzv \\ + --o-right-viz right-viz.qzv +rachis dummy-plugin typical-pipeline \\ + --i-int-sequence ints1.qza \\ + --i-mapping mapper1.qza \\ + --p-do-extra-thing \\ + --o-out-map out-map1.qza \\ + --o-left left1.qza \\ + --o-right right1.qza \\ + --o-left-viz left-viz1.qzv \\ + --o-right-viz right-viz1.qzv +rachis dummy-plugin typical-pipeline \\ + --i-int-sequence left1.qza \\ + --i-mapping out-map1.qza \\ + --p-no-do-extra-thing \\ + --o-out-map out-map2.qza \\ + --o-left left2.qza \\ + --o-right right2.qza \\ + --o-left-viz left-viz2.qzv \\ + --o-right-viz right-viz2.qzv +rachis dev assert-result-data right2.qza \\ + --zip-data-path ints.txt \\ + --expression 1 +rachis dev assert-result-type right2.qza \\ + --qiime-type IntSequence1 +rachis dev assert-result-type out-map1.qza \\ + --qiime-type Mapping"""), + ('optional_artifacts_method', + """\ +rachis dummy-plugin optional-artifacts-method \\ + --i-ints ints.qza \\ + --p-num1 1 \\ + --o-output output1.qza +rachis dummy-plugin optional-artifacts-method \\ + --i-ints ints.qza \\ + --p-num1 1 \\ + --p-num2 2 \\ + --o-output output2.qza +rachis dummy-plugin optional-artifacts-method \\ + --i-ints ints.qza \\ + --p-num1 1 \\ + --o-output output3.qza +rachis dummy-plugin optional-artifacts-method \\ + --i-ints ints.qza \\ + --i-optional1 output3.qza \\ + --p-num1 3 \\ + --p-num2 4 \\ + --o-output output4.qza"""), + ('variadic_input_method', + """\ +rachis dummy-plugin variadic-input-method \\ + --i-ints ints-a.qza ints-b.qza \\ + --i-int-set single-int1.qza single-int2.qza \\ + --p-nums 7 8 9 \\ + --o-output out.qza"""), + ('list_of_ints', + """\ +rachis dummy-plugin list-of-ints \\ + --i-ints ints/ \\ + --o-output out/"""), + ('dict_of_ints', + """\ +rachis dummy-plugin dict-of-ints \\ + --i-ints ints/ \\ + --o-output out/ +rachis dev assert-result-type out/Foo.qza \\ + --qiime-type SingleInt +rachis dev assert-result-data out/Foo.qza \\ + --zip-data-path file1.txt \\ + --expression 1 +## constructing result collection ## +rc_name=rc-in/ +ext=.qza +keys=( a b ) +names=( ints-a.qza ints-b.qza ) +construct_result_collection +## +rachis dummy-plugin dict-of-ints \\ + --i-ints rc-in/ \\ + --o-output rc-out/ +## accessing result collection member ## +ln -s rc-out/b.qza ints-b-from-collection.qza +##"""), + ('viz_collection_pipeline', + """\ +rachis dummy-plugin viz-collection-pipeline \\ + --i-ints ints.qza \\ + --o-visualizations visualizations/ +rachis dev assert-result-type visualizations/ \\ + --qiime-type Collection[Visualization]""") + ] + + +_templ_ids = [x[0] for x in get_templated_tests()] + + +@pytest.mark.parametrize('action,exp', get_templated_tests(), ids=_templ_ids) +def test_templated(dummy_plugin, action, exp): + action = dummy_plugin.actions[action] + + obs = '' + for example_f in action.examples.values(): + use = CLIUsage(enable_assertions=True) + example_f(use) + obs += use.render() + obs += '\n' + + # trim final newline + obs = obs[:-1] + + assert exp == obs + + +def get_rt_tests(): + tests = [] + try: + plugin = get_dummy_plugin() + except RuntimeError: + return tests + + for action in plugin.actions.values(): + for name in action.examples: + tests.append((action, name)) + + return tests + + +@pytest.mark.parametrize('action,example', get_rt_tests(), ids=_rt_labeler) +def test_round_trip(action, example): + example_f = action.examples[example] + use = CLIUsage(enable_assertions=True) + example_f(use) + rendered = use.render() + if sys.platform.startswith('linux'): + # TODO: remove me when arrays are not used in shell + extra = dict(executable='/bin/bash') + else: + extra = dict() + with tempfile.TemporaryDirectory() as tmpdir: + for ref, data in use.get_example_data(): + data.save(os.path.join(tmpdir, ref)) + subprocess.run(rendered, + shell=True, + check=True, + cwd=tmpdir, + env={**os.environ}, + **extra) + + +class ReplayResultCollectionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.plugin = get_dummy_plugin() + + def test_construct_and_access_collection(self): + action = self.plugin.actions['dict_of_ints'] + use = CLIUsage() + action.examples['construct_and_access_collection'](use) + exp = """\ +## constructing result collection ## +rc_name=rc-in/ +ext=.qza +keys=( a b ) +names=( ints-a.qza ints-b.qza ) +construct_result_collection +## +rachis dummy-plugin dict-of-ints \\ + --i-ints rc-in/ \\ + --o-output rc-out/ +## accessing result collection member ## +ln -s rc-out/b.qza ints-b-from-collection.qza +##""" + + self.assertEqual(exp, use.render()) diff --git a/rachis_cli/util.py b/rachis_cli/util.py new file mode 100644 index 00000000..a47c74f6 --- /dev/null +++ b/rachis_cli/util.py @@ -0,0 +1,588 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, 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 contextlib + + +class OutOfDisk(Exception): + pass + + +def get_app_dir(): + import os + conda_prefix = os.environ.get('CONDA_PREFIX', '') + environment_cache = os.path.join(conda_prefix, 'var', 'rachis_cli') + if conda_prefix != '' and (os.access(conda_prefix, os.W_OK | os.X_OK) + or os.path.exists(environment_cache)): + return environment_cache + else: + import click + return click.get_app_dir('rachis_cli', roaming=False) + + +# NOTE: `get_cache_dir` and `get_completion_path` live here instead of +# `rachis_cli.cache` because `rachis_cli.cache` can be slow to import. +# `get_completion_path` (which relies on `get_cache_dir`) is imported and +# executed by the Bash completion function each time the user hits , so it +# must be quick to import. +def get_cache_dir(): + import os.path + return os.path.join(get_app_dir(), 'cache') + + +def get_completion_path(): + import os.path + return os.path.join(get_cache_dir(), 'completion.sh') + + +def get_cli_command_names(): + """Names of the CLI front-ends installed in this deployment. + + The shell completion script registers one completion function against + each of these: `rachis` & `qiime`; `mosh` in a moshpit environment + """ + import importlib.metadata + return sorted(entrypoint.name for entrypoint in + importlib.metadata.entry_points(group='rachis.cli_commands')) + + +def hidden_to_cli_name(name): + # Safety first + if not name.startswith('_'): + raise ValueError(f"The name '{name}' does not start with '_' meaning" + " it is not a hidden action and this method should" + " not have been called on it.") + + name = to_cli_name(name) + + # Retain the leading _ + return name.replace('-', '_', 1) + + +def to_cli_name(name): + return name.replace('_', '-') + + +def to_snake_case(name): + return name.replace('-', '_') + + +def exit_with_error(e, header='An error has been encountered:', + traceback='stderr', status=1): + import sys + import traceback as tb + import textwrap + import click + from rachis_cli.core.config import CONFIG + + footer = [] # footer only exists if traceback is set + tb_file = None + if traceback == 'stderr': + tb_file = sys.stderr + footer = ['See above for debug info.'] + elif traceback is not None: + tb_file = traceback + footer = ['Debug info has been saved to %s' % tb_file.name] + + error = textwrap.indent(str(e), ' ') + segments = [header, error] + footer + + if traceback is not None: + tb.print_exception(type(e), e, e.__traceback__, file=tb_file) + + tb_file.write('\n') + + click.echo(CONFIG.cfg_style('error', '\n\n'.join(segments)), err=True) + + if not footer: + click.echo(err=True) # extra newline to look normal + + try: + click.get_current_context().exit(status) + except RuntimeError: + sys.exit(status) + + +def output_in_cache(fp): + """Determines if an output path follows the format + /path_to_extant_cache:key + """ + from qiime2.core.cache import Cache + + # Tells us right away this isn't in a cache + if ':' not in fp: + return False + + cache_path, key = _get_cache_path_and_key(fp) + + try: + if Cache.is_cache(cache_path): + Cache.validate_key(key) + return True + except FileNotFoundError as e: + # If cache_path doesn't exist, don't treat this as a cache output + if 'No such file or directory' in str(e): + pass + else: + raise e + + # We don't have a cache at all + return False + + +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): + self.header = header + self.traceback = traceback + self.status = status + + def __call__(self, function): + def wrapped(*args, **kwargs): + with self: + return function(*args, **kwargs, failure=self) + # not using functools.wraps to keep import overhead low + # click only seems to need the __name__ + wrapped.__name__ = function.__name__ + return wrapped + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # if exit_with_error is called twice, then click.exit(1) or sys.exit(1) + # will happen, no need to exit_with_error again in that case. + if exc_val is not None and str(exc_val) != '1': + exit_with_error(exc_val, self.header, self.traceback, self.status) + + return False + + +def convert_primitive(ast): + import click + + mapping = { + 'Int': int, + 'Str': str, + 'Float': float, + 'Color': str, + 'Bool': bool + } + # TODO: it would be a good idea to refactor this someday, but until then + # just handle the few predicates we know about. + predicate = ast['predicate'] + if predicate: + if predicate['name'] == 'Choices' and ast['name'] == 'Str': + return click.Choice(predicate['choices']) + elif predicate['name'] == 'Range' and ast['name'] == 'Int': + start = predicate['range'][0] + end = predicate['range'][1] + # click.IntRange is always inclusive + if start is not None and not predicate['inclusive'][0]: + start += 1 + 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': + # click.FloatRange will be in click 7.0, so for now the + # range handling will just fallback to qiime2. + return mapping['Float'] + else: + raise NotImplementedError() + else: + return mapping[ast['name']] + + +def citations_option(get_citation_records): + import click + from rachis_cli.core.config import CONFIG + + def callback(ctx, param, value): + if not value or ctx.resilient_parsing: + return + + records = get_citation_records() + if records: + import io + import qiime2.sdk + + citations = qiime2.sdk.Citations( + [('key%d' % i, r) for i, r in enumerate(records)]) + with io.StringIO() as fh: + fh.write('% use `rachis tools citations` on a rachis result' + ' for complete list\n\n') + citations.save(fh) + click.echo(fh.getvalue(), nl=False) + ctx.exit() + else: + click.echo( + CONFIG.cfg_style('problem', 'No citations found.'), err=True) + ctx.exit(1) + + return click.Option(['--citations'], is_flag=True, expose_value=False, + is_eager=True, callback=callback, + help='Show citations and exit.') + + +def example_data_option(get_plugin, action_name=None): + import click + from rachis_cli.click.type import OutDirType + + def callback(ctx, param, value): + if not value or ctx.resilient_parsing: + return + else: + import rachis_cli.core.usage as usage + + plugin = get_plugin() + if action_name is not None: + action = plugin.actions[action_name] + generator = usage.write_example_data(action, value) + else: + generator = usage.write_plugin_example_data(plugin, value) + + ran = False + for hint, path in generator: + click.secho('Saved %s to: %s' % (hint, path), fg='green') + ran = True + + if ran: + ctx.exit() + else: + click.secho('No example data found.', fg='yellow', err=True) + ctx.exit(1) + + return click.Option(['--example-data'], type=OutDirType(), is_eager=True, + expose_value=False, callback=callback, + help='Write example data and exit.') + + +def get_plugin_manager(): + import qiime2.sdk + + try: + return qiime2.sdk.PluginManager.reuse_existing() + except qiime2.sdk.UninitializedPluginManagerError: + import os + + if 'MYSTERY_STEW' in os.environ: + from q2_mystery_stew.plugin_setup import create_plugin + + the_stew = create_plugin() + pm = qiime2.sdk.PluginManager(add_plugins=False) + pm.add_plugin(the_stew) + return pm + + return qiime2.sdk.PluginManager() + + +def load_metadata(fp): + import qiime2 + import sys + + metadata, error = _load_metadata_artifact(fp) + if metadata is None: + try: + metadata = qiime2.Metadata.load(fp) + except Exception as e: + if error and ':' in fp: + e = error + header = ("There was an issue with loading the file %s as " + "metadata:" % fp) + tb = 'stderr' if '--verbose' in sys.argv else None + exit_with_error(e, header=header, traceback=tb) + + return metadata + + +def _load_metadata_artifact(fp): + import qiime2 + import sys + + artifact, error = _load_input(fp) + artifact = artifact[1] + if isinstance(error, OutOfDisk): + raise error + + default_tb = 'stderr' + # if that worked, we have an artifact or we've + # already raised a critical error + # otherwise, any normal errors can be ignored as its + # most likely actually metadata not a qza + if artifact: + try: + default_tb = None + if isinstance(artifact, qiime2.Visualization): + raise Exception( + 'Visualizations cannot be viewed as rachis metadata.') + if not artifact.has_metadata(): + raise Exception( + f"Artifacts with type {artifact.type!r} cannot be viewed" + " as rachis metadata.") + + default_tb = 'stderr' + return artifact.view(qiime2.Metadata), None + + except Exception as e: + header = ("There was an issue with viewing the artifact " + f"{fp!r} as rachis Metadata:") + tb = 'stderr' if '--verbose' in sys.argv else default_tb + exit_with_error(e, header=header, traceback=tb) + + else: + return None, error + + +def _load_input(fp, view=False): + # Just initialize the plugin manager. This is slow and not necessary if we + # called this from rachis tools view. + import os + + key = None + + if not view: + _ = get_plugin_manager() + + # We are loading a collection from outside of a cache. This cannot be keyed + if os.path.isdir(fp): + if len(os.listdir(fp)) == 0: + raise ValueError(f"Provided directory '{fp}' is empty.") + + artifact, error = _load_collection(fp) + # We may be loading something from a cache with or without and additional + # key, or we may be loading a piece of data from outside of a cache with a + # key. We could also be loading a normal unkeyed artifact with a : in its + # path + elif ':' in fp: + # First we assume this is just a weird filepath + artifact, _ = _load_input_file(fp) + # Then we check if it is a key:path + if artifact is None: + key, new_fp = _get_path_and_collection_key(fp) + artifact, _ = _load_input_file(new_fp) + + # If we still have nothing + if artifact is None: + key = None + # We assume this is a cache:key. We keep this error because we + # assume if they had a : in their path they were trying to load + # something from a cache + artifact, error = _load_input_cache(fp) + if error: + # Then we check if it is a key:cache:key + key, new_fp = _get_path_and_collection_key(fp) + artifact, _ = _load_input_cache(new_fp) + + # If we ended up with an artifact, we disregard our error + if artifact is not None: + error = None + # We are just loading a normal artifact on disk without silly colons in the + # filepath + else: + artifact, error = _load_input_file(fp) + + if isinstance(error, OSError) and error.errno == 28: + # abort as there's nothing anyone can do about this + from qiime2.core.cache import get_cache + + path = str(get_cache().path) + return None, OutOfDisk(f'There was not enough space left on {path!r} ' + f'to use the artifact {fp!r}. (Try ' + f'setting $TMPDIR to a directory with more ' + f'space, or increasing the size of {path!r})') + + return (key, artifact), error + + +# NOTE: These load collection functions are now virtually identical to class +# methods on qiime2.sdk.Result +def _load_collection(fp): + import os + import warnings + + order_fp = os.path.join(fp, '.order') + + if os.path.isfile(order_fp): + artifacts, error = _load_ordered_collection(fp, order_fp) + else: + warnings.warn(f'The directory {fp} does not contain a .order file. ' + 'The files will be read into the collection in the ' + 'order the filesystem provides them in.') + artifacts, error = _load_unordered_collection(fp) + + return artifacts, error + + +def _load_ordered_collection(fp, order_fp): + import os + + artifacts = {} + + with open(order_fp) as order_fh: + for key in order_fh.read().splitlines(): + artifact_path = os.path.join(fp, f'{key}.qza') + artifacts[key], error = _load_input_file(artifact_path) + + if error: + return None, error + + return artifacts, None + + +def _load_unordered_collection(fp): + import os + + artifacts = {} + + for artifact in os.listdir(fp): + artifact_fp = os.path.join(fp, artifact) + artifacts[artifact], error = _load_input_file(artifact_fp) + + if error: + return None, error + + return artifacts, None + + +def _load_input_cache(fp): + artifact = error = None + try: + artifact = try_as_cache_input(fp) + except Exception as e: + error = e + + return artifact, error + + +def _load_input_file(fp): + import qiime2.sdk + from os.path import expanduser + + # If there is a leading ~ we expand it to be the path to home + fp = expanduser(fp) + + # test if valid + peek = None + try: + peek = qiime2.sdk.Result.peek(fp) + except Exception as error: + if isinstance(error, SyntaxError): + raise error + # ideally ValueError: X is not a rachis archive. + # but sometimes SyntaxError or worse + return None, error + + # try to actually load + try: + artifact = qiime2.sdk.Result.load(fp) + return artifact, None + + except Exception as e: + if peek: + # abort early as there's nothing else to do + raise ValueError( + "It looks like you have an Artifact but are missing the" + " plugin(s) necessary to load it. Artifact has type" + f" {peek.type!r} and format {peek.format!r}") from e + else: + error = e + + return None, error + + +def try_as_cache_input(fp): + """ Determine if an input is in a cache and load it from the cache if it is + """ + import os + from qiime2 import Cache + + cache_path, key = _get_cache_path_and_key(fp) + + # We don't want to invent a new cache on disk here because if their input + # exists their cache must also already exist + if not os.path.exists(cache_path) or not Cache.is_cache(cache_path): + raise ValueError(f"The path {cache_path!r} is not a valid cache.") + + cache = Cache(cache_path) + return cache.load(key) + + +def _get_cache_path_and_key(fp): + return fp.rsplit(':', 1) + + +def _get_path_and_collection_key(fp): + return fp.split(':', 1) + + +def get_default_recycle_pool(plugin_action): + from hashlib import sha1 + + return f'recycle_{plugin_action}_' \ + f'{sha1(plugin_action.encode("utf-8")).hexdigest()}' + + +@contextlib.contextmanager +def capture_rachis_warnings(): + ''' + Intercepts `RachisWarning` warnings and delays writing them to standard + error until the context is exited. This is useful if the standard error + stream is temporarily redirected at some point within this context manager. + ''' + import sys + import warnings + from qiime2.core.exceptions import RachisWarning + + captured_warnings = [] + + warnings_showwarning = warnings.showwarning + + def _capture_showwarning( + message, category, filename, lineno, file=None, line=None + ): + if issubclass(category, RachisWarning): + captured_warnings.append((str(message), filename, lineno)) + else: + warnings_showwarning( + message, category, filename, lineno, file, line + ) + + warnings.showwarning = _capture_showwarning + + yield + + if captured_warnings: + sys.stderr.write('\033[33m') # yellow text + sys.stderr.write('\nRACHIS WARNING(S):\n\n') + for warning, file, line in captured_warnings: + sys.stderr.write(f'{warning}\n') + sys.stderr.write(f'[{file}: {line}]\n\n') + + sys.stderr.write('\033[0m') # reset text color + sys.stderr.flush() + + warnings.showwarning = warnings_showwarning From 628d2cc69fe8aa64325d10508822f88a7714debc Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Mon, 31 Aug 2026 17:16:28 +0200 Subject: [PATCH 04/10] finish up name changes --- rachis_cli/builtin/dev.py | 10 +-- rachis_cli/builtin/info.py | 8 +- rachis_cli/builtin/tools.py | 104 +++++++++++------------ rachis_cli/click/option.py | 8 +- rachis_cli/click/type.py | 26 +++--- rachis_cli/commands.py | 18 ++-- rachis_cli/core/artifact_cache_global.py | 4 +- rachis_cli/core/cache.py | 12 ++- rachis_cli/core/state.py | 22 ++--- rachis_cli/core/usage.py | 10 +-- rachis_cli/tests/test_cache_cli.py | 12 +-- rachis_cli/tests/test_cli.py | 16 ++-- rachis_cli/tests/test_core.py | 18 ++-- rachis_cli/tests/test_dev.py | 6 +- rachis_cli/tests/test_tools.py | 20 ++--- rachis_cli/tests/test_usage.py | 2 +- rachis_cli/util.py | 38 ++++----- 17 files changed, 172 insertions(+), 162 deletions(-) diff --git a/rachis_cli/builtin/dev.py b/rachis_cli/builtin/dev.py index 19496297..0b98e298 100644 --- a/rachis_cli/builtin/dev.py +++ b/rachis_cli/builtin/dev.py @@ -156,16 +156,16 @@ def reset_theme(): help='rachis data type.') def assert_result_type(input_path, qiime_type): import rachis_cli.util - import qiime2.sdk + import rachis.sdk from os.path import isdir from rachis_cli.core.config import CONFIG rachis_cli.util.get_plugin_manager() try: if isdir(input_path): - result = qiime2.sdk.ResultCollection.load(input_path) + result = rachis.sdk.ResultCollection.load(input_path) else: - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) except Exception as e: header = 'There was a problem loading %s as a rachis Result:' % \ input_path @@ -200,13 +200,13 @@ def assert_result_type(input_path, qiime_type): def assert_result_data(input_path, zip_data_path, expression): import re import rachis_cli.util - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG rachis_cli.util.get_plugin_manager() try: - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) except Exception as e: header = 'There was a problem loading %s as a rachis result:' % \ input_path diff --git a/rachis_cli/builtin/info.py b/rachis_cli/builtin/info.py index 0a544ef8..509734c8 100644 --- a/rachis_cli/builtin/info.py +++ b/rachis_cli/builtin/info.py @@ -13,15 +13,15 @@ def _echo_version(): import sys - import qiime2 + import rachis import rachis_cli pyver = sys.version_info click.echo('Python version: %d.%d.%d' % (pyver.major, pyver.minor, pyver.micro)) click.echo('Parsl version: %s' % _get_parsl_ver()) - click.echo('rachis release: %s' % qiime2.__release__) - click.echo('rachis version: %s' % qiime2.__version__) + click.echo('rachis release: %s' % rachis.__release__) + click.echo('rachis version: %s' % rachis.__version__) click.echo('rachis-cli version: %s' % rachis_cli.__version__) @@ -79,7 +79,7 @@ def info(config_level): import rachis_cli.util # This import improves performance for repeated _echo_plugins import rachis_cli.core.cache - from qiime2.sdk.parallel_config import (get_vendored_config, + from rachis.sdk.parallel_config import (get_vendored_config, load_config_from_dict) from tomlkit import dumps diff --git a/rachis_cli/builtin/tools.py b/rachis_cli/builtin/tools.py index c6b51a53..a415a4cc 100644 --- a/rachis_cli/builtin/tools.py +++ b/rachis_cli/builtin/tools.py @@ -11,7 +11,7 @@ import click -from qiime2.core.annotate import ANNOTATION_TYPE_DICT +from rachis.core.annotate import ANNOTATION_TYPE_DICT import rachis_cli.util from rachis_cli.click.command import ToolCommand, ToolGroupCommand @@ -20,14 +20,14 @@ def _import(type, input_path, input_format, validate_level): - import qiime2.sdk - import qiime2.plugin + import rachis.sdk + import rachis.plugin try: - artifact = qiime2.sdk.Artifact.import_data( + artifact = rachis.sdk.Artifact.import_data( type, input_path, view_type=input_format, validate_level=validate_level) - except qiime2.plugin.ValidationError as e: + except rachis.plugin.ValidationError as e: header = 'There was a problem importing %s:' % input_path rachis_cli.util.exit_with_error(e, header=header, traceback=None) except Exception as e: @@ -40,23 +40,23 @@ def _import(type, input_path, input_format, validate_level): def _export(result, output_format, output_path): import distutils - import qiime2.sdk - import qiime2.util + import rachis.sdk + import rachis.util from rachis_cli.core.config import CONFIG if output_format is None: - if isinstance(result, qiime2.sdk.Artifact): + if isinstance(result, rachis.sdk.Artifact): output_format = result.format.__name__ else: output_format = 'Visualization' result.export_data(output_path) else: - if isinstance(result, qiime2.sdk.Visualization): + if isinstance(result, rachis.sdk.Visualization): error = '--output-format cannot be used with visualizations' click.echo(CONFIG.cfg_style('error', error), err=True) click.get_current_context().exit(1) else: - source = result.view(qiime2.sdk.parse_format(output_format)) + source = result.view(rachis.sdk.parse_format(output_format)) if os.path.isfile(str(source)): if os.path.isfile(output_path): os.remove(output_path) @@ -67,7 +67,7 @@ def _export(result, output_format, output_path): if os.path.dirname(output_path) != '': # create directory (recursively) if it doesn't exist yet os.makedirs(os.path.dirname(output_path), exist_ok=True) - qiime2.util.duplicate(str(source), output_path) + rachis.util.duplicate(str(source), output_path) else: distutils.dir_util.copy_tree(str(source), output_path) @@ -100,10 +100,10 @@ def tools(): 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): - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) output_format = _export(result, output_format, output_path) @@ -298,11 +298,11 @@ def import_data(type, input_path, output_path, input_format, validate_level): @click.option('--tsv/--no-tsv', default=False, help='Print as machine-readable tab-separated values.') def peek(paths, tsv): - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG metadatas = {os.path.basename(path): - qiime2.sdk.Result.peek(path) for path in paths} + rachis.sdk.Result.peek(path) for path in paths} if tsv: click.echo("Filename\tType\tUUID\tData Format") @@ -393,7 +393,7 @@ def peek(paths, tsv): def cast_metadata(paths, cast, output_file, ignore_extra, error_on_missing): import tempfile - from qiime2 import Metadata, metadata + from rachis import Metadata, metadata md = _merge_metadata(paths) @@ -555,7 +555,7 @@ def _merge_metadata(paths): writable=True), help='Path where output report should be written.') def make_report(visualization_paths, report_path): - from qiime2 import Visualization + from rachis import Visualization from q2templates.reports import matryoshka_template from rachis_cli.core.config import CONFIG @@ -609,7 +609,7 @@ def view(result_path, port, verbose): import urllib.parse import mimetypes - from qiime2.sdk import Result + from rachis.sdk import Result from rachis_cli.core.config import CONFIG @@ -777,11 +777,11 @@ def stop(): default=os.getcwd()) def extract(input_path, output_path): import zipfile - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG try: - extracted_dir = qiime2.sdk.Result.extract(input_path, output_path) + extracted_dir = rachis.sdk.Result.extract(input_path, output_path) except (zipfile.BadZipFile, ValueError): raise click.BadParameter( '%s is not a valid rachis Result. Only rachis Artifacts and ' @@ -809,18 +809,18 @@ def extract(input_path, output_path): 'the potential cost of runtime).', default='max', show_default=True) def validate(path, level): - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG try: - result = qiime2.sdk.Result.load(path) + result = rachis.sdk.Result.load(path) except Exception as e: header = 'There was a problem loading %s as a rachis Result:' % path rachis_cli.util.exit_with_error(e, header=header) try: result.validate(level) - except qiime2.plugin.ValidationError as e: + except rachis.plugin.ValidationError as e: header = 'Result %s does not appear to be valid at level=%s:' % ( path, level) rachis_cli.util.exit_with_error(e, header=header, traceback=None) @@ -841,13 +841,13 @@ def validate(path, level): dir_okay=False, readable=True), metavar=_COMBO_METAVAR) def citations(path): - import qiime2.sdk + import rachis.sdk import io from rachis_cli.core.config import CONFIG ctx = click.get_current_context() try: - result = qiime2.sdk.Result.load(path) + result = rachis.sdk.Result.load(path) except Exception as e: header = 'There was a problem loading %s as a rachis result:' % path rachis_cli.util.exit_with_error(e, header=header) @@ -871,7 +871,7 @@ def citations(path): type=click.Path(exists=False, readable=True), help='Path to a nonexistent directory to be created as a cache.') def cache_create(cache): - from qiime2.core.cache import Cache + from rachis.core.cache import Cache from rachis_cli.core.config import CONFIG try: @@ -897,7 +897,7 @@ def cache_create(cache): help='The key to remove from the cache. Pass `key` parameter ' 'multiple times to remove multiple keys.') def cache_remove(cache, key): - from qiime2.core.cache import Cache + from rachis.core.cache import Cache from rachis_cli.core.config import CONFIG # I don't want to instantiate this every time in the loop @@ -935,7 +935,7 @@ def cache_remove(cache, key): readable=True), help='Path to an existing cache to run garbage collection on.') def cache_garbage_collection(cache): - from qiime2.core.cache import Cache + from rachis.core.cache import Cache from rachis_cli.core.config import CONFIG try: @@ -966,8 +966,8 @@ def cache_garbage_collection(cache): help='The key to save the artifact under (must be a valid ' 'Python identifier).') def cache_store(cache, artifact_path, key): - from qiime2.sdk.result import Result - from qiime2.core.cache import Cache + from rachis.sdk.result import Result + from rachis.core.cache import Cache from rachis_cli.core.config import CONFIG from rachis.sdk.util import validate_result_collection_keys @@ -1025,7 +1025,7 @@ def cache_store(cache, artifact_path, key): ' file or directory, whereas "min" will not usually do so.' ' [default: "max"]') def cache_import(type, input_path, cache, key, input_format, validate_level): - from qiime2 import Cache + from rachis import Cache from rachis_cli.core.config import CONFIG from rachis.sdk.util import validate_result_collection_keys @@ -1071,7 +1071,7 @@ def cache_import(type, input_path, cache, key, input_format, validate_level): help='Format which the data should be exported as. ' 'This option cannot be used with Visualizations') def export_cache(cache, key, output_path, output_format): - from qiime2 import Cache + from rachis import Cache from rachis_cli.core.config import CONFIG try: @@ -1105,7 +1105,7 @@ def export_cache(cache, key, output_path, output_format): type=click.Path(exists=False, readable=True), help='Path to put the .qza we are loading the artifact into.') def cache_fetch(cache, key, output_path): - from qiime2.core.cache import Cache + from rachis.core.cache import Cache from rachis_cli.core.config import CONFIG try: @@ -1134,8 +1134,8 @@ def cache_fetch(cache, key, output_path): readable=True), help='Path to an existing cache to check the status of.') def cache_status(cache): - from qiime2.core.cache import Cache - from qiime2.sdk.result import Result + from rachis.core.cache import Cache + from rachis.sdk.result import Result from rachis_cli.core.config import CONFIG @@ -1272,8 +1272,8 @@ def provenance_replay( """ Replay provenance from a rachis Artifact filepath to a written executable """ - from qiime2.core.archive.provenance_lib.replay import replay_provenance - from qiime2.sdk.util import get_available_usage_drivers + from rachis.core.archive.provenance_lib.replay import replay_provenance + from rachis.sdk.util import get_available_usage_drivers from rachis_cli.util import get_plugin_manager get_plugin_manager() @@ -1350,8 +1350,8 @@ def citations_replay( Not for use in reporting e.g. software versions used in an analysis, as deduplication removes duplicate references with different plugin versions. """ - from qiime2.core.archive.provenance_lib.parse import ProvDAG - from qiime2.core.archive.provenance_lib.replay import replay_citations + from rachis.core.archive.provenance_lib.parse import ProvDAG + from rachis.core.archive.provenance_lib.replay import replay_citations try: in_fp = rachis_cli.util.try_as_cache_input(in_fp) @@ -1435,8 +1435,8 @@ def supplement_replay( - replay scripts for all supported interfaces - a bibtex-formatted collection of all citations """ - from qiime2.core.archive.provenance_lib.replay import replay_supplement - from qiime2.sdk.util import get_available_usage_drivers + from rachis.core.archive.provenance_lib.replay import replay_supplement + from rachis.sdk.util import get_available_usage_drivers from rachis_cli.util import get_plugin_manager get_plugin_manager() @@ -1531,11 +1531,11 @@ def annotation_create(input_path, annotation_type, name, fingerprint, output_path): """Load a Result, add an Annotation and save.""" - import qiime2.sdk - from qiime2.core.annotate import Note, Signature + import rachis.sdk + from rachis.core.annotate import Note, Signature from rachis_cli.core.config import CONFIG - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) if annotation_type == 'Note': # enforce exactly one of `--text` or `--file` @@ -1604,10 +1604,10 @@ def annotation_create(input_path, annotation_type, name, ) def annotation_remove(input_path, name, output_path): """Load a Result, remove an Annotation by name and save.""" - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) try: result.remove_annotation(name) @@ -1651,10 +1651,10 @@ def annotation_fetch(input_path, name, verbose): """ Load a Result, retrieve an Annotation by name and display the contents. """ - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) try: annotation = result.get_annotation(name) @@ -1701,10 +1701,10 @@ def annotation_list(input_path): Load a Result, retrieve all Annotations and display their ids, names & types. """ - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) try: annotations_iter = result.iter_annotations() @@ -1750,10 +1750,10 @@ def annotation_list(input_path): help='The name of the Signature to verify.' ) def signature_verify(input_path, name): - import qiime2.sdk + import rachis.sdk from rachis_cli.core.config import CONFIG - result = qiime2.sdk.Result.load(input_path) + result = rachis.sdk.Result.load(input_path) try: result.verify(name) diff --git a/rachis_cli/click/option.py b/rachis_cli/click/option.py index 17f627e2..982692e1 100644 --- a/rachis_cli/click/option.py +++ b/rachis_cli/click/option.py @@ -187,7 +187,7 @@ def process_value(self, ctx, value): def type_cast_value(self, ctx, value): import sys import rachis_cli.util - import qiime2.sdk.util + import rachis.sdk.util if self.multiple: if value == () or value is None: @@ -221,7 +221,7 @@ def type_cast_value(self, ctx, value): else: value = self.q2_multiple(value) - type_expr = qiime2.sdk.util.type_from_ast(self.q2_ast) + type_expr = rachis.sdk.util.type_from_ast(self.q2_ast) args = ', '.join(map(repr, (x.type for x in value))) if value not in type_expr: @@ -284,10 +284,10 @@ def type_cast_value(self, ctx, value): _values = value value = \ - qiime2.sdk.util.parse_primitive(self.q2_ast, _values) + rachis.sdk.util.parse_primitive(self.q2_ast, _values) except ValueError: args = ', '.join(map(repr, value)) - expr = qiime2.sdk.util.type_from_ast(self.q2_ast) + expr = rachis.sdk.util.type_from_ast(self.q2_ast) raise click.BadParameter( 'received <%s> as an argument, which is incompatible' ' with parameter type: %r' % (args, expr), diff --git a/rachis_cli/click/type.py b/rachis_cli/click/type.py index 30a9aef1..3ede69da 100644 --- a/rachis_cli/click/type.py +++ b/rachis_cli/click/type.py @@ -55,14 +55,14 @@ def __init__(self, type_ast, type_repr, is_output=False): @property def type_expr(self): - import qiime2.sdk.util + import rachis.sdk.util if self._type_expr is None: - self._type_expr = qiime2.sdk.util.type_from_ast(self.type_ast) + self._type_expr = rachis.sdk.util.type_from_ast(self.type_ast) return self._type_expr def convert(self, value, param, ctx): - import qiime2.sdk.util + import rachis.sdk.util from rachis_cli.core.artifact_cache_global import ( get_used_artifact_cache) @@ -73,10 +73,10 @@ def convert(self, value, param, ctx): if self.is_output: return self._convert_output(value, param, ctx) - if qiime2.sdk.util.is_semantic_type(self.type_expr): + if rachis.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): + if rachis.sdk.util.is_metadata_type(self.type_expr): return self._convert_metadata(value, param, ctx) return self._convert_primitive(value, param, ctx) @@ -84,7 +84,7 @@ def convert(self, value, param, ctx): def _convert_output(self, value, param, ctx): import os from rachis_cli.util import output_in_cache - from qiime2.core.type.util import is_collection_type + from rachis.core.type.util import is_collection_type # Click path fails to validate writability on new paths # Check if our output path is actually in a cache and if it is skip our @@ -110,8 +110,8 @@ def _convert_output(self, value, param, ctx): def _convert_input(self, value, param, ctx): import os - import qiime2.sdk - import qiime2.sdk.util + import rachis.sdk + import rachis.sdk.util import rachis_cli.util try: @@ -128,7 +128,7 @@ def _convert_input(self, value, param, ctx): # We want to use click's fail to pretty print whatever error we got # from get_input - if isinstance(result_value, qiime2.sdk.Visualization): + if isinstance(result_value, rachis.sdk.Visualization): maybe = value[:-1] + 'a' hint = '' if os.path.exists(maybe): @@ -139,7 +139,7 @@ def _convert_input(self, value, param, ctx): self.fail('%r is a rachis visualization (.qzv), not an' ' Artifact (.qza)%s' % (value, hint), param, ctx) - style = qiime2.sdk.util.interrogate_collection_type(self.type_expr) + style = rachis.sdk.util.interrogate_collection_type(self.type_expr) if style.style is None and result_value not in self.type_expr: # collections need to be handled above this self.fail("Expected an artifact of at least type %r." @@ -171,12 +171,12 @@ def _convert_metadata(self, value, param, ctx): return metadata_column def _convert_primitive(self, value, param, ctx): - import qiime2.sdk.util + import rachis.sdk.util try: - return qiime2.sdk.util.parse_primitive(self.type_expr, value) + return rachis.sdk.util.parse_primitive(self.type_expr, value) except ValueError: - expr = qiime2.sdk.util.type_from_ast(self.type_ast) + expr = rachis.sdk.util.type_from_ast(self.type_ast) raise click.BadParameter( 'received <%s> as an argument, which is incompatible' ' with parameter type: %r' % (value, expr), diff --git a/rachis_cli/commands.py b/rachis_cli/commands.py index 49ad2eda..54e2784d 100644 --- a/rachis_cli/commands.py +++ b/rachis_cli/commands.py @@ -132,7 +132,8 @@ def __init__(self, plugin, name, *args, **kwargs): # Hide actions that start with _ by default for id, a in plugin['actions'].items(): if id.startswith('_'): - self._hidden_actions[rachis_cli.util.hidden_to_cli_name(id)] = a + self._hidden_actions[ + rachis_cli.util.hidden_to_cli_name(id)] = a else: self._action_lookup[rachis_cli.util.to_cli_name(id)] = a @@ -373,7 +374,8 @@ def _build_generated_options(self): else: storage = outputs - opt = rachis_cli.click.option.GeneratedOption(prefix=type[0], **item) + opt = rachis_cli.click.option.GeneratedOption( + prefix=type[0], **item) storage.append(opt) return inputs, params, outputs @@ -408,9 +410,9 @@ def __call__(self, **kwargs): import click from contextlib import nullcontext - import qiime2.util - from qiime2.core.cache import Cache - from qiime2.sdk import ResultCollection + import rachis.util + from rachis.core.cache import Cache + from rachis.sdk import ResultCollection from rachis_cli.util import ( output_in_cache, _get_cache_path_and_key, get_default_recycle_pool, @@ -524,7 +526,7 @@ def __call__(self, **kwargs): (recycle_pool, cache.path)) click.echo(CONFIG.cfg_style('warning', msg)) - # `qiime2.util.redirected_stdio` defaults to stdout/stderr when + # `rachis.util.redirected_stdio` defaults to stdout/stderr when # supplied `None`. log = None @@ -554,10 +556,10 @@ def __call__(self, **kwargs): try: with ( capture_rachis_warnings() if not quiet else nullcontext(), - qiime2.util.redirected_stdio(stdout=log, stderr=log) + rachis.util.redirected_stdio(stdout=log, stderr=log) ): if parallel: - from qiime2.sdk.parallel_config import \ + from rachis.sdk.parallel_config import \ (load_config_from_file, ParallelConfig) action = action.parallel diff --git a/rachis_cli/core/artifact_cache_global.py b/rachis_cli/core/artifact_cache_global.py index 8ff28256..3af40b2a 100644 --- a/rachis_cli/core/artifact_cache_global.py +++ b/rachis_cli/core/artifact_cache_global.py @@ -30,7 +30,7 @@ def set_used_artifact_cache(args): Should only be called once to init the used cache for this invocation. """ - from qiime2.core.cache import Cache + from rachis.core.cache import Cache from rachis_cli.util import exit_with_error global _USED_ARTIFACT_CACHE @@ -76,6 +76,6 @@ def get_used_artifact_cache(): The default cache if the user didn't set a cache or the cache they set if they did set one. """ - from qiime2.core.cache import Cache + from rachis.core.cache import Cache return Cache() if _USED_ARTIFACT_CACHE is None else _USED_ARTIFACT_CACHE diff --git a/rachis_cli/core/cache.py b/rachis_cli/core/cache.py index 84d18697..732a8cae 100644 --- a/rachis_cli/core/cache.py +++ b/rachis_cli/core/cache.py @@ -136,6 +136,7 @@ def decoder(obj): def _get_current_requirements(self): """Includes installed versions of rachis_cli and rachis plugins.""" import os + import itertools import importlib.metadata import rachis_cli @@ -148,8 +149,15 @@ def _get_current_requirements(self): # containing one or more plugins. It is not necessary to track # individual plugin names and versions in order to determine if the # cache is outdated. - for entry_point in importlib.metadata.entry_points( - group='qiime2.plugins'): + # Plugins are migrating from the `qiime2.plugins` entry point group + # to `rachis.plugins`. Read both, so that the cache still invalidates + # for plugins which have not migrated yet. This mirrors + # `rachis.sdk.PluginManager.iter_entry_points`. A distribution that + # declares both groups yields duplicate entry points, which the `set` + # above collapses. + for entry_point in itertools.chain( + importlib.metadata.entry_points(group='rachis.plugins'), + importlib.metadata.entry_points(group='qiime2.plugins')): if 'QIIMETEST' in os.environ: if entry_point.name in ('dummy-plugin', 'other-plugin'): reqs.add(f'{entry_point.name}=={entry_point.dist.version}') diff --git a/rachis_cli/core/state.py b/rachis_cli/core/state.py index 1d3ce727..e3219db1 100644 --- a/rachis_cli/core/state.py +++ b/rachis_cli/core/state.py @@ -73,14 +73,14 @@ def get_action_state(action): def _special_option_flags(type): - import qiime2.sdk.util + import rachis.sdk.util import itertools multiple = None is_bool_flag = False metadata = None - style = qiime2.sdk.util.interrogate_collection_type(type) + style = rachis.sdk.util.interrogate_collection_type(type) if style.style is not None: multiple = style.view.__name__ @@ -114,13 +114,13 @@ def _is_bool(type_expr): def _get_type_repr(type): - import qiime2.sdk.util + import rachis.sdk.util type_repr = repr(type) - style = qiime2.sdk.util.interrogate_collection_type(type) + style = rachis.sdk.util.interrogate_collection_type(type) - if not qiime2.sdk.util.is_semantic_type(type) and \ - not qiime2.sdk.util.is_union(type): + if not rachis.sdk.util.is_semantic_type(type) and \ + not rachis.sdk.util.is_union(type): if style.style is None: if style.expr.predicate is not None: type_repr = repr(style.expr.predicate) @@ -134,7 +134,7 @@ def _get_type_repr(type): def _get_metavar(type): - import qiime2.sdk.util + import rachis.sdk.util name_to_var = { 'Visualization': 'VISUALIZATION', @@ -146,7 +146,7 @@ def _get_metavar(type): 'Threads': 'NTHREADS', } - style = qiime2.sdk.util.interrogate_collection_type(type) + style = rachis.sdk.util.interrogate_collection_type(type) multiple = style.style is not None if style.style == 'simple': @@ -156,15 +156,15 @@ def _get_metavar(type): else: inner_type = None - if qiime2.sdk.util.is_semantic_type(type): + if rachis.sdk.util.is_semantic_type(type): metavar = 'ARTIFACT' - elif qiime2.sdk.util.is_metadata_type(type): + elif rachis.sdk.util.is_metadata_type(type): metavar = 'METADATA' elif style.style is not None and style.style != 'simple': metavar = 'VALUE' elif _is_bool(type): metavar = '' - elif qiime2.sdk.util.is_union(type): + elif rachis.sdk.util.is_union(type): metavar = 'VALUE' else: metavar = name_to_var[inner_type.name] diff --git a/rachis_cli/core/usage.py b/rachis_cli/core/usage.py index 0c0f2253..7fbdf46e 100644 --- a/rachis_cli/core/usage.py +++ b/rachis_cli/core/usage.py @@ -14,15 +14,15 @@ import textwrap from typing import Any, Callable, Dict, List, Tuple, Type -from qiime2 import ResultCollection -import qiime2.sdk.usage as usage -from qiime2.sdk.usage import ( +from rachis import ResultCollection +import rachis.sdk.usage as usage +from rachis.sdk.usage import ( UsageVariable, Usage, UsageInputs, UsageOutputs ) -from qiime2.core.archive.provenance_lib.usage_drivers import ( +from rachis.core.archive.provenance_lib.usage_drivers import ( build_header, build_footer, ReplayUsageAction ) -from qiime2.core.archive.provenance_lib import ProvDAG +from rachis.core.archive.provenance_lib import ProvDAG import rachis_cli.util as util from rachis_cli.core.state import get_action_state diff --git a/rachis_cli/tests/test_cache_cli.py b/rachis_cli/tests/test_cache_cli.py index 36dd8963..e0d084d7 100644 --- a/rachis_cli/tests/test_cache_cli.py +++ b/rachis_cli/tests/test_cache_cli.py @@ -15,17 +15,17 @@ import importlib.resources from click.testing import CliRunner -from qiime2.core.testing.type import (IntSequence1, IntSequence2, Mapping, +from rachis.core.testing.type import (IntSequence1, IntSequence2, Mapping, SingleInt) -from qiime2.core.testing.util import get_dummy_plugin -from qiime2.core.util import load_action_yaml -from qiime2.core.cache import Cache +from rachis.core.testing.util import get_dummy_plugin +from rachis.core.util import load_action_yaml +from rachis.core.cache import Cache from rachis_cli.commands import RootCommand from rachis_cli.builtin.tools import tools from rachis_cli.util import get_default_recycle_pool -from qiime2.sdk import Artifact, Visualization, ResultCollection -from qiime2.sdk.parallel_config import PARALLEL_CONFIG +from rachis.sdk import Artifact, Visualization, ResultCollection +from rachis.sdk.parallel_config import PARALLEL_CONFIG # What to split the errors raised by intentionally failed pipeline on to get diff --git a/rachis_cli/tests/test_cli.py b/rachis_cli/tests/test_cli.py index bb2effaa..c0b22689 100644 --- a/rachis_cli/tests/test_cli.py +++ b/rachis_cli/tests/test_cli.py @@ -18,14 +18,14 @@ import unittest.mock from click.testing import CliRunner -from qiime2.core.cache import get_cache -from qiime2.core.testing.format import EchoFormat -from qiime2.core.testing.type import ( +from rachis.core.cache import get_cache +from rachis.core.testing.format import EchoFormat +from rachis.core.testing.type import ( Bar, C1, Foo, IntSequence1, IntSequence2, SingleInt ) -from qiime2.core.testing.util import get_dummy_plugin -from qiime2.plugin import Bool, Choices, TypeMap -from qiime2.sdk import Artifact, Visualization, ResultCollection +from rachis.core.testing.util import get_dummy_plugin +from rachis.plugin import Bool, Choices, TypeMap +from rachis.sdk import Artifact, Visualization, ResultCollection from rachis_cli.builtin.info import info from rachis_cli.builtin.tools import tools @@ -490,7 +490,7 @@ def test_input_conversion(self): # This is to ensure the temp in the regex matches the temp used in the # method under test in type.py temp = str(get_cache().path) - with unittest.mock.patch('qiime2.sdk.Result.peek', + with unittest.mock.patch('rachis.sdk.Result.peek', side_effect=OSError(errno.ENOSPC, 'No space left on ' 'device')): @@ -506,7 +506,7 @@ def test_syntax_error_in_env(self): viz_path = os.path.join(self.tempdir, 'viz') - with unittest.mock.patch('qiime2.sdk.Result.peek', + with unittest.mock.patch('rachis.sdk.Result.peek', side_effect=SyntaxError): result = self.runner.invoke( command, ['most-common-viz', '--i-ints', self.artifact1_path, diff --git a/rachis_cli/tests/test_core.py b/rachis_cli/tests/test_core.py index 645c3ebc..c83d5666 100644 --- a/rachis_cli/tests/test_core.py +++ b/rachis_cli/tests/test_core.py @@ -17,19 +17,19 @@ import pandas as pd 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 -from qiime2.sdk.util import camel_to_snake -from qiime2.sdk.usage import UsageVariable -from qiime2.sdk import PluginManager -from qiime2.core.archive.provenance_lib import DummyArtifacts, ProvDAG -from qiime2.core.archive.provenance_lib.replay import ( +from rachis import Artifact +from rachis.core.testing.type import IntSequence1 +from rachis.core.testing.util import get_dummy_plugin +from rachis.sdk.util import camel_to_snake +from rachis.sdk.usage import UsageVariable +from rachis.sdk import PluginManager +from rachis.core.archive.provenance_lib import DummyArtifacts, ProvDAG +from rachis.core.archive.provenance_lib.replay import ( ReplayConfig, param_is_metadata_column, dump_recorded_md_file, ReplayNamespaces, build_import_usage, build_action_usage, ActionCollections, replay_provenance, replay_supplement ) -from qiime2.core.archive.provenance_lib.usage_drivers import ReplayPythonUsage +from rachis.core.archive.provenance_lib.usage_drivers import ReplayPythonUsage import rachis_cli import rachis_cli.util diff --git a/rachis_cli/tests/test_dev.py b/rachis_cli/tests/test_dev.py index 0eef894c..7ff36b5b 100644 --- a/rachis_cli/tests/test_dev.py +++ b/rachis_cli/tests/test_dev.py @@ -13,9 +13,9 @@ import configparser 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 +from rachis import Artifact +from rachis.core.testing.type import IntSequence1 +from rachis.core.testing.util import get_dummy_plugin import rachis_cli.util from rachis_cli.builtin.dev import dev diff --git a/rachis_cli/tests/test_tools.py b/rachis_cli/tests/test_tools.py index 733ebd7a..fc273c59 100644 --- a/rachis_cli/tests/test_tools.py +++ b/rachis_cli/tests/test_tools.py @@ -19,14 +19,14 @@ import bibtexparser as bp from click.testing import CliRunner -from qiime2 import Artifact, Metadata -from qiime2.core.testing.util import get_dummy_plugin -from qiime2.core.testing.type import IntSequence1, IntSequence2, SingleInt -from qiime2.metadata.base import SUPPORTED_COLUMN_TYPES -from qiime2.core.cache import Cache -from qiime2.sdk.result import Result -from qiime2.sdk.plugin_manager import PluginManager -from qiime2.core.annotate import Note +from rachis import Artifact, Metadata +from rachis.core.testing.util import get_dummy_plugin +from rachis.core.testing.type import IntSequence1, IntSequence2, SingleInt +from rachis.metadata.base import SUPPORTED_COLUMN_TYPES +from rachis.core.cache import Cache +from rachis.sdk.result import Result +from rachis.sdk.plugin_manager import PluginManager +from rachis.core.annotate import Note from rachis_cli.util import load_metadata from rachis_cli.builtin.tools import tools @@ -1125,7 +1125,7 @@ def test_replay_provenance_use_md_without_parse(self): self.assertRegex(str(result.exception), 'Metadata not parsed for replay') - @patch('qiime2.sdk.util.get_available_usage_drivers', + @patch('rachis.sdk.util.get_available_usage_drivers', return_value={'cli': ReplayCLIUsage}) def test_replay_provenance_usage_driver_not_available(self, patch): in_fp = os.path.join(self.tempdir, 'concated_ints.qza') @@ -1269,7 +1269,7 @@ def test_replay_supplement_no_metadata_dump(self): with zipfile.ZipFile(out_fp, 'r') as zfh: self.assertNotIn(not_exp, set(zfh.namelist())) - @patch('qiime2.sdk.util.get_available_usage_drivers', return_value={}) + @patch('rachis.sdk.util.get_available_usage_drivers', return_value={}) def test_replay_supplement_usage_driver_not_available(self, patch): in_fp = os.path.join(self.tempdir, 'concated_ints.qza') out_fp = os.path.join(self.tempdir, 'rendered.txt') diff --git a/rachis_cli/tests/test_usage.py b/rachis_cli/tests/test_usage.py index dfec8854..622debd1 100644 --- a/rachis_cli/tests/test_usage.py +++ b/rachis_cli/tests/test_usage.py @@ -14,7 +14,7 @@ from rachis_cli.core.usage import CLIUsage -from qiime2.core.testing.util import get_dummy_plugin +from rachis.core.testing.util import get_dummy_plugin import pytest diff --git a/rachis_cli/util.py b/rachis_cli/util.py index a47c74f6..e71b7ca6 100644 --- a/rachis_cli/util.py +++ b/rachis_cli/util.py @@ -112,7 +112,7 @@ def output_in_cache(fp): """Determines if an output path follows the format /path_to_extant_cache:key """ - from qiime2.core.cache import Cache + from rachis.core.cache import Cache # Tells us right away this isn't in a cache if ':' not in fp: @@ -228,9 +228,9 @@ def callback(ctx, param, value): records = get_citation_records() if records: import io - import qiime2.sdk + import rachis.sdk - citations = qiime2.sdk.Citations( + citations = rachis.sdk.Citations( [('key%d' % i, r) for i, r in enumerate(records)]) with io.StringIO() as fh: fh.write('% use `rachis tools citations` on a rachis result' @@ -282,32 +282,32 @@ def callback(ctx, param, value): def get_plugin_manager(): - import qiime2.sdk + import rachis.sdk try: - return qiime2.sdk.PluginManager.reuse_existing() - except qiime2.sdk.UninitializedPluginManagerError: + return rachis.sdk.PluginManager.reuse_existing() + except rachis.sdk.UninitializedPluginManagerError: import os if 'MYSTERY_STEW' in os.environ: from q2_mystery_stew.plugin_setup import create_plugin the_stew = create_plugin() - pm = qiime2.sdk.PluginManager(add_plugins=False) + pm = rachis.sdk.PluginManager(add_plugins=False) pm.add_plugin(the_stew) return pm - return qiime2.sdk.PluginManager() + return rachis.sdk.PluginManager() def load_metadata(fp): - import qiime2 + import rachis import sys metadata, error = _load_metadata_artifact(fp) if metadata is None: try: - metadata = qiime2.Metadata.load(fp) + metadata = rachis.Metadata.load(fp) except Exception as e: if error and ':' in fp: e = error @@ -320,7 +320,7 @@ def load_metadata(fp): def _load_metadata_artifact(fp): - import qiime2 + import rachis import sys artifact, error = _load_input(fp) @@ -336,7 +336,7 @@ def _load_metadata_artifact(fp): if artifact: try: default_tb = None - if isinstance(artifact, qiime2.Visualization): + if isinstance(artifact, rachis.Visualization): raise Exception( 'Visualizations cannot be viewed as rachis metadata.') if not artifact.has_metadata(): @@ -345,7 +345,7 @@ def _load_metadata_artifact(fp): " as rachis metadata.") default_tb = 'stderr' - return artifact.view(qiime2.Metadata), None + return artifact.view(rachis.Metadata), None except Exception as e: header = ("There was an issue with viewing the artifact " @@ -407,7 +407,7 @@ def _load_input(fp, view=False): if isinstance(error, OSError) and error.errno == 28: # abort as there's nothing anyone can do about this - from qiime2.core.cache import get_cache + from rachis.core.cache import get_cache path = str(get_cache().path) return None, OutOfDisk(f'There was not enough space left on {path!r} ' @@ -479,7 +479,7 @@ def _load_input_cache(fp): def _load_input_file(fp): - import qiime2.sdk + import rachis.sdk from os.path import expanduser # If there is a leading ~ we expand it to be the path to home @@ -488,7 +488,7 @@ def _load_input_file(fp): # test if valid peek = None try: - peek = qiime2.sdk.Result.peek(fp) + peek = rachis.sdk.Result.peek(fp) except Exception as error: if isinstance(error, SyntaxError): raise error @@ -498,7 +498,7 @@ def _load_input_file(fp): # try to actually load try: - artifact = qiime2.sdk.Result.load(fp) + artifact = rachis.sdk.Result.load(fp) return artifact, None except Exception as e: @@ -518,7 +518,7 @@ def try_as_cache_input(fp): """ Determine if an input is in a cache and load it from the cache if it is """ import os - from qiime2 import Cache + from rachis import Cache cache_path, key = _get_cache_path_and_key(fp) @@ -555,7 +555,7 @@ def capture_rachis_warnings(): ''' import sys import warnings - from qiime2.core.exceptions import RachisWarning + from rachis.core.exceptions import RachisWarning captured_warnings = [] From 80ebeae8159514f09d751d2effcf5e81a51d7cd0 Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Mon, 31 Aug 2026 17:26:11 +0200 Subject: [PATCH 05/10] cleanup --- .gitignore | 2 +- Makefile | 4 +- README.md | 2 +- bin/tab-rachis | 117 +- conda-recipe/meta.yaml | 10 +- pyproject.toml | 20 +- q2cli/__init__.py | 13 - q2cli/__main__.py | 47 - q2cli/builtin/__init__.py | 7 - q2cli/builtin/dev.py | 241 -- q2cli/builtin/info.py | 123 - q2cli/builtin/tools.py | 1805 -------------- q2cli/click/__init__.py | 7 - q2cli/click/command.py | 365 --- q2cli/click/licenses/click.LICENSE.rst | 40 - q2cli/click/option.py | 350 --- q2cli/click/parser.py | 161 -- q2cli/click/type.py | 193 -- q2cli/commands.py | 675 ------ q2cli/core/__init__.py | 7 - q2cli/core/artifact_cache_global.py | 81 - q2cli/core/assets/cli_howto.txt | 28 - q2cli/core/assets/copyright_note.txt | 4 - q2cli/core/cache.py | 279 --- q2cli/core/completion.py | 169 -- q2cli/core/config.py | 120 - q2cli/core/state.py | 182 -- q2cli/core/usage.py | 818 ------- q2cli/tests/__init__.py | 7 - q2cli/tests/data/concatenated_ints.qza | Bin 19908 -> 0 bytes q2cli/tests/data/mapping_config.toml | 15 - .../data/parse_dir_test/inner/right_ints.qza | Bin 14035 -> 0 bytes q2cli/tests/data/parse_dir_test/left_ints.qza | Bin 14033 -> 0 bytes q2cli/tests/data/rarefied_table.qza | Bin 82989 -> 0 bytes q2cli/tests/test_cache_cli.py | 1047 -------- q2cli/tests/test_cli.py | 1416 ----------- q2cli/tests/test_core.py | 457 ---- q2cli/tests/test_dev.py | 190 -- q2cli/tests/test_mystery_stew.py | 51 - q2cli/tests/test_tools.py | 2127 ----------------- q2cli/tests/test_usage.py | 264 -- q2cli/util.py | 577 ----- 42 files changed, 101 insertions(+), 11920 deletions(-) delete mode 100644 q2cli/__init__.py delete mode 100644 q2cli/__main__.py delete mode 100644 q2cli/builtin/__init__.py delete mode 100644 q2cli/builtin/dev.py delete mode 100644 q2cli/builtin/info.py delete mode 100644 q2cli/builtin/tools.py delete mode 100644 q2cli/click/__init__.py delete mode 100644 q2cli/click/command.py delete mode 100644 q2cli/click/licenses/click.LICENSE.rst delete mode 100644 q2cli/click/option.py delete mode 100644 q2cli/click/parser.py delete mode 100644 q2cli/click/type.py delete mode 100644 q2cli/commands.py delete mode 100644 q2cli/core/__init__.py delete mode 100644 q2cli/core/artifact_cache_global.py delete mode 100644 q2cli/core/assets/cli_howto.txt delete mode 100644 q2cli/core/assets/copyright_note.txt delete mode 100644 q2cli/core/cache.py delete mode 100644 q2cli/core/completion.py delete mode 100644 q2cli/core/config.py delete mode 100644 q2cli/core/state.py delete mode 100644 q2cli/core/usage.py delete mode 100644 q2cli/tests/__init__.py delete mode 100644 q2cli/tests/data/concatenated_ints.qza delete mode 100644 q2cli/tests/data/mapping_config.toml delete mode 100644 q2cli/tests/data/parse_dir_test/inner/right_ints.qza delete mode 100644 q2cli/tests/data/parse_dir_test/left_ints.qza delete mode 100644 q2cli/tests/data/rarefied_table.qza delete mode 100644 q2cli/tests/test_cache_cli.py delete mode 100644 q2cli/tests/test_cli.py delete mode 100644 q2cli/tests/test_core.py delete mode 100644 q2cli/tests/test_dev.py delete mode 100644 q2cli/tests/test_mystery_stew.py delete mode 100644 q2cli/tests/test_tools.py delete mode 100644 q2cli/tests/test_usage.py delete mode 100644 q2cli/util.py diff --git a/.gitignore b/.gitignore index 3a1da336..838d686b 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,4 @@ runinfo _version.py # Built vendored view -q2cli/assets/view +rachis_cli/assets/view diff --git a/Makefile b/Makefile index b3f1a34b..131155e7 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ vendor-view: all git submodule update && \ cd q2view && \ npm install --no-save && \ - npm run vendor --VENDOR_DIR=../q2cli/assets/view + npm run vendor --VENDOR_DIR=../rachis_cli/assets/view # install pytest-xdist plugin for the `-n auto` argument. mystery-stew: all @@ -36,6 +36,6 @@ dev-no-view: all cp hooks/50_activate_rachis_cli_tab_completion.sh $(PREFIX)/etc/conda/activate.d/ clean: distclean - rm -rf ./q2cli/assets + rm -rf ./rachis_cli/assets distclean: ; diff --git a/README.md b/README.md index 354bc13d..dce1b47c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rachis-cli -![](https://github.com/qiime2/q2cli/workflows/ci-dev/badge.svg) +![](https://github.com/rachis-org/rachis-cli/workflows/ci-dev/badge.svg) A [click-based](http://click.pocoo.org/) command line interface for [rachis](https://github.com/rachis-org/rachis). diff --git a/bin/tab-rachis b/bin/tab-rachis index 17a8f075..97b289f0 100644 --- a/bin/tab-rachis +++ b/bin/tab-rachis @@ -1,54 +1,97 @@ #!/usr/bin/env bash -# Bash completion script that defers to a cached completion script representing -# the state of the current rachis deployment. +# Shell completion for every rachis-cli front-end installed in this +# environment (e.g. `rachis`, `qiime`, and `mosh` in a MOSHPIT deployment). # -# This script is intended to be executed on the command-line or in -# .bashrc/.bash_profile: +# Front-ends advertise themselves through the `rachis.cli_commands` entry +# point group; see `rachis_cli.util.get_cli_command_names`. +# +# This script is intended to be sourced by the conda activation hook, or on +# the command-line/in .bashrc/.bash_profile: # # source tab-rachis # +# NOTE: this file is always sourced, never executed, so its contents must be +# valid in both bash and zsh (the shebang above is decorative). -_rachis_completion() -{ - # Attempt to find the cached completion script. If rachis-cli isn't installed, or - # is an incompatible version, don't attempt completion. - local completion_path="$(python -c "import q2cli.util; print(q2cli.util.get_completion_path())" 2> /dev/null)" +# The cached completion script's path and the set of front-end command names +# are both fixed for the life of an activation, so they are resolved here once +# in a single interpreter start - rather than on every . If rachis-cli +# isn't installed, or is an incompatible version, this yields nothing and no +# completion is registered. +_rachis_meta="$(python -c "import rachis_cli.util as u +print(u.get_completion_path()) +print('\n'.join(u.get_cli_command_names()))" 2> /dev/null)" - if [[ $? != 0 ]]; then - unset COMPREPLY - return 0 +if [ -n "${_rachis_meta}" ]; then + _RACHIS_COMPLETION_PATH="${_rachis_meta%%$'\n'*}" + _rachis_names="${_rachis_meta#*$'\n'}" + + # `${var#*}` yields the string unchanged when it holds no newline, + # which is what a deployment reporting no front-ends looks like: the empty + # trailing line is stripped by the command substitution above. + if [ "${_rachis_names}" = "${_rachis_meta}" ]; then + _rachis_names="" fi - # If the completion script exists, attempt completion by invoking the script - # in a subshell, supplying COMP_WORDS and COMP_CWORD. Capture the output as - # the completion reply. If the completion script failed, don't attempt - # completion. - if [[ -f "$completion_path" ]] ; then - COMPREPLY=( $(COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD="${COMP_CWORD}" "$completion_path" 2> /dev/null) ) + # Fall back to the front-ends provided by rachis-cli itself if the entry + # point metadata is missing (e.g. an uninstalled source checkout). + if [ -z "${_rachis_names}" ]; then + _rachis_names="rachis qiime" + fi + + # NOTE: `_RACHIS_COMPLETION_PATH` is intentionally left set in the shell - + # the completion function below reads it every time it runs. + _rachis_completion() + { + # If the completion script exists, attempt completion by invoking the + # script in a subshell, supplying COMP_WORDS and COMP_CWORD. Capture the + # output as the completion reply. If the completion script failed, don't + # attempt completion. + if [[ -f "${_RACHIS_COMPLETION_PATH}" ]] ; then + COMPREPLY=( $(COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD="${COMP_CWORD}" \ + "${_RACHIS_COMPLETION_PATH}" 2> /dev/null) ) - if [[ $? != 0 ]]; then + if [[ $? != 0 ]]; then + unset COMPREPLY + fi + else unset COMPREPLY - return 0 fi - else - unset COMPREPLY + return 0 - fi + } - return 0 -} + # Register the completion function against each installed front-end. + # + # NOTE: the iteration is over a command substitution rather than over + # `${_rachis_names}` directly because zsh does not word-split unquoted + # parameter expansions - passing the list as one argument would register a + # single command whose name is the entire list. + _rachis_first="" + for _rachis_cmd in $(printf '%s\n' "${_rachis_names}"); do + # Enable default readline and bash completion behavior when + # `_rachis_completion` doesn't have a reply. + complete -F _rachis_completion -o default -o bashdefault "${_rachis_cmd}" -# Enable default readline and bash completion behavior when `_rachis_completion` -# doesn't have a reply. -complete -F _rachis_completion -o default -o bashdefault rachis qiime + if [ -z "${_rachis_first}" ]; then + _rachis_first="${_rachis_cmd}" + fi + done -# Execute a `rachis` command (any command will do) so that tab-completion will -# work out-of-the-box (e.g. with a fresh installation of rachis-cli). Running a -# command will create or refresh the cache if necessary, which contains the -# actual completion script. -# -# Ignore stdout to avoid displaying help text to users enabling tab-completion. -# stderr displays the note about cache refreshing, as that can take a few -# moments to complete. -rachis &> /dev/null + # Execute one of the front-ends (any command will do) so that tab-completion + # will work out-of-the-box (e.g. with a fresh installation of rachis-cli). + # Running a command will create or refresh the cache if necessary, which + # contains the actual completion script. + # + # Ignore stdout to avoid displaying help text to users enabling + # tab-completion. stderr displays the note about cache refreshing, as that + # can take a few moments to complete. + if [ -n "${_rachis_first}" ]; then + "${_rachis_first}" &> /dev/null + fi + + unset _rachis_names _rachis_cmd _rachis_first +fi + +unset _rachis_meta diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index 49d9d5af..d520d7f1 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -1,13 +1,13 @@ package: - name: q2cli + name: rachis-cli version: {{ PLUGIN_VERSION }} source: path: .. build: script: make install entry_points: - - qiime=q2cli.__main__:qiime - - rachis=q2cli.__main__:qiime + - qiime=rachis_cli.__main__:qiime + - rachis=rachis_cli.__main__:qiime requirements: host: - python {{ python }} @@ -33,10 +33,10 @@ test: - pytest-xdist - q2-mystery-stew imports: - - q2cli + - rachis_cli commands: - Q2CLIDEV=1 QIIMETEST= rachis --help - - Q2CLIDEV=1 QIIMETEST= py.test --pyargs q2cli + - Q2CLIDEV=1 QIIMETEST= py.test --pyargs rachis_cli about: home: https://qiime2.org license: BSD-3-Clause diff --git a/pyproject.toml b/pyproject.toml index 71710045..26fdbec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "q2cli" +name = "rachis-cli" authors = [ { name = "None", email = "developers@qiime2.org" } ] @@ -13,11 +13,15 @@ Homepage = "https://qiime2.org" Repository = "https://github.com/rachis-org/rachis-cli" [project.scripts] -"qiime" = "q2cli.__main__:qiime" -"rachis" = "q2cli.__main__:qiime" +"qiime" = "rachis_cli.__main__:qiime" +"rachis" = "rachis_cli.__main__:qiime" -[project.entry-points.'qiime2.usage_drivers'] -"cli" = "q2cli.core.usage:ReplayCLIUsage" +[project.entry-points.'rachis.cli_commands'] +"qiime" = "rachis_cli.__main__:qiime" +"rachis" = "rachis_cli.__main__:qiime" + +[project.entry-points.'rachis.usage_drivers'] +"cli" = "rachis_cli.core.usage:ReplayCLIUsage" [build-system] requires = [ @@ -41,7 +45,7 @@ dirty = "{base_version}+{distance}.{vcs}{rev}.dirty" distance-dirty = "{base_version}+{distance}.{vcs}{rev}.dirty" [tool.versioningit.write] -file = "q2cli/_version.py" +file = "rachis_cli/_version.py" [tool.setuptools] include-package-data = true @@ -51,7 +55,7 @@ script-files = [ [tool.setuptools.packages.find] where = ["."] -include = ["q2cli*"] +include = ["rachis_cli*"] [tool.setuptools.package-data] -q2cli = ["**/*"] +rachis_cli = ["**/*"] diff --git a/q2cli/__init__.py b/q2cli/__init__.py deleted file mode 100644 index d19a3179..00000000 --- a/q2cli/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- - - -try: - from ._version import __version__ -except ModuleNotFoundError: - __version__ = '0.0.0+notfound' diff --git a/q2cli/__main__.py b/q2cli/__main__.py deleted file mode 100644 index 2d4e6585..00000000 --- a/q2cli/__main__.py +++ /dev/null @@ -1,47 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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.commands - - -ROOT_COMMAND_HELP = """\ -rachis command-line interface (rachis-cli) ------------------------------------------- - -To get help with rachis, visit https://qiime2.org. - -To enable tab completion in Bash, run the following command or add it to your \ -.bashrc/.bash_profile: - - source tab-rachis - -To enable tab completion in ZSH, run the following commands or add them to \ -your .zshrc: - -\b - autoload -Uz compinit && compinit - autoload bashcompinit && bashcompinit - source tab-rachis - -""" - - -# Entry point for CLI -@click.command(cls=q2cli.commands.RootCommand, invoke_without_command=True, - no_args_is_help=True, help=ROOT_COMMAND_HELP) -@click.version_option(prog_name='q2cli', - message='%(prog)s version %(version)s\nRun `rachis info`' - ' for more version details.') -def qiime(): - pass - - -if __name__ == '__main__': - qiime() diff --git a/q2cli/builtin/__init__.py b/q2cli/builtin/__init__.py deleted file mode 100644 index 5a85cefd..00000000 --- a/q2cli/builtin/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- diff --git a/q2cli/builtin/dev.py b/q2cli/builtin/dev.py deleted file mode 100644 index 9f3cf3fa..00000000 --- a/q2cli/builtin/dev.py +++ /dev/null @@ -1,241 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 q2cli.click.command import ToolCommand, ToolGroupCommand - -_COMBO_METAVAR = 'ARTIFACT/VISUALIZATION' - - -@click.group(help='Utilities for developers and advanced users.', - cls=ToolGroupCommand) -def dev(): - pass - - -@dev.command(name='refresh-cache', - short_help='Refresh CLI cache.', - help="Refresh the CLI cache. Use this command if you are " - "developing a plugin, or q2cli itself, and want your " - "changes to take effect in the CLI. A refresh of the cache " - "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.", - cls=ToolCommand) -def refresh_cache(): - import q2cli.core.cache - q2cli.core.cache.CACHE.refresh() - - -import_theme_help = \ - ("Allows for customization of q2cli's command line styling based on an " - "imported .theme (INI formatted) file. If you are unfamiliar with .ini " - "formatted files look here https://en.wikipedia.org/wiki/INI_file." - "\n" - "\n" - "The .theme file allows you to customize text on the basis of what that " - "text represents with the following supported text types: command, " - "option, type, default_arg, required, emphasis, problem, warning, error, " - "and success. These will be your headers in the '[]' brackets. " - "\n" - "\n" - "`command` refers to the name of the command you issued. `option` refers " - "to the arguments you give to the command when running it. `type` refers " - "to the rachis semantic typing of these arguments (where applicable). " - "`default_arg` refers to the label next to the argument indicating its " - "default value (where applicable), and if it is required (where " - "applicable). `required` refers to any arguments that must be passed to " - "the command for it to work and gives them special formatting on top of " - "your normal `option` formatting. `emphasis` refers to any emphasized " - "pieces of text within help text. `problem` refers to the text informing " - "you there were issues with the command. `warning` refers to the text " - "for non-fatal issues while `error` refers to the text for fatal issues." - "`success` refers to text indicating a process completed as expected." - "\n" - "\n" - "Depending on what your terminal supports, some or all of the following " - "pieces of the text's formatting may be customized: bold, dim (if true " - "the text's brightness is reduced), underline, blink, reverse (if true " - "foreground and background colors are reversed), and finally fg " - "(foreground color) and bg (background color). The first five may each " - "be either true or false, while the colors may be set to any of the " - "following: black, red, green, yellow, blue, magenta, cyan, white, " - "bright_black, bright_red, bright_green, bright_yellow, bright_blue, " - "bright_magenta, bright_cyan, or bright_white.") - - -@dev.command(name='import-theme', - short_help='Install new command line theme.', - help=import_theme_help, - cls=ToolCommand) -@click.option('--theme', required=True, - type=click.Path(exists=True, file_okay=True, - dir_okay=False, readable=True), - help='Path to file containing new theme info') -def import_theme(theme): - import os - import shutil - from configparser import Error - - import q2cli.util - from q2cli.core.config import CONFIG - - try: - CONFIG.parse_file(theme) - except Error as e: - # If they tried to change [error] in a valid manner before we hit our - # parsing error, we don't want to use their imported error settings - CONFIG.styles = CONFIG.get_default_styles() - header = 'Something went wrong while parsing your theme: ' - q2cli.util.exit_with_error(e, header=header, traceback=None) - shutil.copy(theme, os.path.join(q2cli.util.get_app_dir(), - 'cli-colors.theme')) - - -@dev.command(name='export-default-theme', - short_help='Export the default settings.', - help='Create a .theme (INI formatted) file from the default ' - 'settings at the specified filepath.', - cls=ToolCommand) -@click.option('--output-path', required=True, - type=click.Path(exists=False, file_okay=True, - dir_okay=False, readable=True), - help='Path to output the config to') -def export_default_theme(output_path): - import configparser - from q2cli.core.config import CONFIG - - parser = configparser.ConfigParser() - parser.read_dict(CONFIG.get_default_styles()) - with open(output_path, 'w') as fh: - parser.write(fh) - - -def abort_if_false(ctx, param, value): - if not value: - ctx.abort() - - -@dev.command(name='reset-theme', - short_help='Reset command line theme to default.', - help="Reset command line theme to default. Requres the '--yes' " - "parameter to be passed asserting you do want to reset.", - cls=ToolCommand) -@click.option('--yes', is_flag=True, callback=abort_if_false, - expose_value=False, - prompt='Are you sure you want to reset your theme?') -def reset_theme(): - import os - import q2cli.util - - path = os.path.join(q2cli.util.get_app_dir(), 'cli-colors.theme') - if os.path.exists(path): - os.unlink(path) - click.echo('Theme reset.') - else: - click.echo('Theme was already default.') - - -@dev.command(name='assert-result-type', - short_help='Assert Result is a specific type.', - help='Checks that the type of a Result matches an ' - 'expected type. Intended for developer testing.', - cls=ToolCommand) -@click.argument('input-path', type=click.Path(exists=True, file_okay=True, - dir_okay=True, readable=True), - metavar=_COMBO_METAVAR) -# TODO: we should probably change this to --rachis-type -@click.option('--qiime-type', required=True, - help='rachis data type.') -def assert_result_type(input_path, qiime_type): - import q2cli.util - import qiime2.sdk - from os.path import isdir - from q2cli.core.config import CONFIG - - q2cli.util.get_plugin_manager() - try: - if isdir(input_path): - result = qiime2.sdk.ResultCollection.load(input_path) - else: - result = qiime2.sdk.Result.load(input_path) - except Exception as e: - header = 'There was a problem loading %s as a rachis Result:' % \ - input_path - q2cli.util.exit_with_error(e, header=header) - - if str(result.type) != qiime_type: - try: - msg = 'Expected %s, observed %s' % (qiime_type, result.type) - raise AssertionError(msg) - except Exception as e: - header = 'There was a problem asserting the type:' - q2cli.util.exit_with_error(e, header=header) - else: - msg = 'The input file (%s) type and the expected type (%s)' \ - ' match' % (input_path, qiime_type) - click.echo(CONFIG.cfg_style('success', msg)) - - -@dev.command(name='assert-result-data', - short_help='Assert expression in Result.', - help='Uses regex to check that the provided expression is present' - ' in input file. Intended for developer testing.', - cls=ToolCommand) -@click.argument('input-path', type=click.Path(exists=True, file_okay=True, - dir_okay=False, readable=True), - metavar=_COMBO_METAVAR) -@click.option('--zip-data-path', required=True, - help='The path within the zipped Result\'s data/' - ' directory that should be searched.') -@click.option('--expression', required=True, - help='The Python regular expression to match.') -def assert_result_data(input_path, zip_data_path, expression): - import re - import q2cli.util - import qiime2.sdk - from q2cli.core.config import CONFIG - - q2cli.util.get_plugin_manager() - - try: - result = qiime2.sdk.Result.load(input_path) - except Exception as e: - header = 'There was a problem loading %s as a rachis result:' % \ - input_path - q2cli.util.exit_with_error(e, header=header) - - try: - hits = sorted(result._archiver.data_dir.glob(zip_data_path)) - if len(hits) != 1: - data_dir = result._archiver.data_dir - all_fps = sorted(data_dir.glob('**/*')) - all_fps = [x.relative_to(data_dir).name for x in all_fps] - raise ValueError('Value provided for zip_data_path (%s) did not ' - 'produce exactly one match.\nMatches: %s\n' - 'Paths observed: %s' % - (zip_data_path, hits, all_fps)) - except Exception as e: - header = 'There was a problem locating the zip_data_path (%s)' % \ - zip_data_path - q2cli.util.exit_with_error(e, header=header) - - try: - target = hits[0].read_text() - match = re.search(expression, target, flags=re.MULTILINE) - if match is None: - raise AssertionError('Expression %r not found in %s.' % - (expression, hits[0])) - except Exception as e: - header = 'There was a problem finding the expression.' - q2cli.util.exit_with_error(e, header=header) - - msg = '"%s" was found in %s' % (str(expression), str(zip_data_path)) - click.echo(CONFIG.cfg_style('success', msg)) diff --git a/q2cli/builtin/info.py b/q2cli/builtin/info.py deleted file mode 100644 index 4791e598..00000000 --- a/q2cli/builtin/info.py +++ /dev/null @@ -1,123 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 q2cli.click.command import ToolCommand - - -def _echo_version(): - import sys - import qiime2 - import q2cli - - pyver = sys.version_info - click.echo('Python version: %d.%d.%d' % - (pyver.major, pyver.minor, pyver.micro)) - click.echo('Parsl version: %s' % _get_parsl_ver()) - click.echo('rachis release: %s' % qiime2.__release__) - click.echo('rachis version: %s' % qiime2.__version__) - click.echo('rachis-cli version: %s' % q2cli.__version__) - - -def _get_parsl_ver(): - import os - import pathlib - import importlib.metadata - - conda_env_prefix = os.environ.get('CONDA_PREFIX', '') - conda_meta_path = pathlib.Path(conda_env_prefix) / 'conda-meta' - - parsl_ver = None - - # prefer the parsl version in conda env if available - if conda_meta_path.exists(): - for file in conda_meta_path.iterdir(): - if file.stem.startswith('parsl-'): - # version is in the structure of: parsl-2026.2.23-pyhcf101f3_0 - parsl_ver = file.stem.split('-', 2)[1] - break - - # fall back to any externally installed version if conda env not detectable - # or if parsl not found in existing conda env - if parsl_ver is None: - try: - parsl_ver = importlib.metadata.version('parsl') - except importlib.metadata.PackageNotFoundError: - pass - - return parsl_ver - - -def _echo_plugins(): - import q2cli.core.cache - - plugins = q2cli.core.cache.CACHE.plugins - if plugins: - for name, plugin in sorted(plugins.items()): - click.echo('%s: %s' % (name, plugin['version'])) - else: - click.secho('No plugins are currently installed.\n' - 'Find plugins at https://library.qiime2.org.') - - -@click.command(help='Display information about current deployment.', - cls=ToolCommand) -@click.option('--config-level', - required=False, - default=1, - show_default=True, - type=click.IntRange(0, 3), - help='The level of detail to be used for displaying the ' - 'configuration summary.') -def info(config_level): - import q2cli.util - # This import improves performance for repeated _echo_plugins - import q2cli.core.cache - from qiime2.sdk.parallel_config import (get_vendored_config, - load_config_from_dict) - from tomlkit import dumps - - click.secho('System versions', fg='green') - _echo_version() - click.secho('\nInstalled plugins', fg='green') - _echo_plugins() - - click.secho('\nApplication config directory', fg='green') - click.secho(q2cli.util.get_app_dir()) - - if config_level > 0: - click.secho('\nConfig', fg='green') - - config, action_executor_mapping, vendored_source = \ - get_vendored_config() - - click.secho(f'Config Source: {vendored_source}') - - if action_executor_mapping: - config['parsl.executor_mapping'] = action_executor_mapping - - if config_level > 1: - if config_level == 2: - config = dumps(config) - elif config_level == 3: - config['parsl'], _ = load_config_from_dict(config) - - click.secho(f'\n{config}') - - click.secho('\nGetting help', fg='green') - click.secho('To find help and learning resources, visit ' - 'https://qiime2.org.') - - if config_level: - click.secho('To get help with configuring and/or understanding ' - 'rachis parallelization, visit ' - 'https://use.qiime2.org/en/stable/references/' - 'parallel-configuration.html') - - click.secho('\n') diff --git a/q2cli/builtin/tools.py b/q2cli/builtin/tools.py deleted file mode 100644 index 176ae9fe..00000000 --- a/q2cli/builtin/tools.py +++ /dev/null @@ -1,1805 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os -from typing import Literal - -import click - -from qiime2.core.annotate import ANNOTATION_TYPE_DICT - -import q2cli.util -from q2cli.click.command import ToolCommand, ToolGroupCommand - -_COMBO_METAVAR = 'ARTIFACT/VISUALIZATION' - - -def _import(type, input_path, input_format, validate_level): - import qiime2.sdk - import qiime2.plugin - - try: - artifact = qiime2.sdk.Artifact.import_data( - type, input_path, view_type=input_format, - validate_level=validate_level) - except qiime2.plugin.ValidationError as e: - header = 'There was a problem importing %s:' % input_path - q2cli.util.exit_with_error(e, header=header, traceback=None) - except Exception as e: - header = 'An unexpected error has occurred:' - q2cli.util.exit_with_error(e, header=header) - - return artifact - - -def _export(result, output_format, output_path): - import distutils - - import qiime2.sdk - import qiime2.util - from q2cli.core.config import CONFIG - - if output_format is None: - if isinstance(result, qiime2.sdk.Artifact): - output_format = result.format.__name__ - else: - output_format = 'Visualization' - result.export_data(output_path) - else: - if isinstance(result, qiime2.sdk.Visualization): - error = '--output-format cannot be used with visualizations' - click.echo(CONFIG.cfg_style('error', error), err=True) - click.get_current_context().exit(1) - else: - source = result.view(qiime2.sdk.parse_format(output_format)) - if os.path.isfile(str(source)): - if os.path.isfile(output_path): - os.remove(output_path) - elif os.path.dirname(output_path) == '': - # This allows the user to pass a filename as a path if they - # want their output in the current working directory - output_path = os.path.join('.', output_path) - if os.path.dirname(output_path) != '': - # create directory (recursively) if it doesn't exist yet - os.makedirs(os.path.dirname(output_path), exist_ok=True) - qiime2.util.duplicate(str(source), output_path) - else: - distutils.dir_util.copy_tree(str(source), output_path) - - return output_format - - -@click.group(help='Tools for working with rachis files.', - cls=ToolGroupCommand) -def tools(): - pass - - -@tools.command(name='export', - short_help='Export data from a rachis Artifact ' - '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', - cls=ToolCommand) -@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') -@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') -@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): - import qiime2.sdk - from q2cli.core.config import CONFIG - - result = qiime2.sdk.Result.load(input_path) - - output_format = _export(result, output_format, output_path) - - output_type = 'file' if os.path.isfile(output_path) else 'directory' - success = 'Exported %s as %s to %s %s' % (input_path, output_format, - output_type, output_path) - click.echo(CONFIG.cfg_style('success', success)) - - -def _print_descriptions(descriptions, tsv): - if tsv: - for value, description in descriptions.items(): - click.echo(f"{value}\t", nl=False) - if description: - click.echo(_deformat_description(description)) - else: - click.echo() - else: - import textwrap - tabsize = 8 - for value, description in descriptions.items(): - click.secho(value, bold=True) - if description: - description = _deformat_description(description) - wrapped_description = textwrap.wrap(description, - width=72-tabsize, - initial_indent='\t', - subsequent_indent='\t', - tabsize=tabsize) - for line in wrapped_description: - click.echo(f"{line}") - else: - click.secho("\tNo description", italic=True) - click.echo() - - -def _deformat_description(description): - import re - deformatted = re.sub(r"[\t\n]+", ' ', description) - despaced = re.sub(r" +", ' ', deformatted) - return despaced - - -def _get_matches(words, possibilities, strict=False): - from difflib import get_close_matches - if strict: - cutoff = 1 - else: - cutoff = 0.6 - - matches = set() - num_possibilities = len(possibilities) - for word in words: - matches.update(get_close_matches(word, - possibilities, - n=num_possibilities, - cutoff=cutoff)) - # substring search - if cutoff != 1: - for possibility in possibilities: - if word.lower() in possibility.lower(): - matches.add(possibility) - - return list(matches) - - -@tools.command( - name='list-types', - help='List the available semantic types.', - short_help='', - cls=ToolCommand -) -@click.argument('queries', nargs=-1) -@click.option('--strict', is_flag=True, - help='Show only exact matches for the type argument(s).') -@click.option('--tsv', is_flag=True, - help='Print as machine readable tab-separated values.') -def show_types(queries, strict, tsv): - pm = q2cli.util.get_plugin_manager() - - if len(queries) > 0: - matches = _get_matches(queries, list(pm.artifact_classes), strict) - else: - matches = sorted(list(pm.artifact_classes)) - - descriptions = {} - for match in matches: - description = pm.artifact_classes[match].description - descriptions[match] = description - - _print_descriptions(descriptions, tsv) - - -@tools.command( - name='list-formats', - help='List the available formats.', - short_help='', - cls=ToolCommand -) -@click.argument('queries', nargs=-1) -@click.option('--importable', is_flag=True, - help='List the importable formats.') -@click.option('--exportable', is_flag=True, - help='List the exportable formats.') -@click.option('--strict', is_flag=True, - help='Show only exact matches for the format argument(s).') -@click.option('--tsv', is_flag=True, - help='Print as machine readable tab-separated values.') -def show_formats(queries, importable, exportable, strict, tsv): - if importable and exportable: - raise click.UsageError("'--importable' and '--exportable' flags are " - "mutually exclusive.") - if not importable and not exportable: - raise click.UsageError("One of '--importable' or '--exportable' flags " - "is required.") - - pm = q2cli.util.get_plugin_manager() - portable_formats = pm.importable_formats if importable \ - else pm.exportable_formats - - if len(queries) > 0: - matches = _get_matches(queries, portable_formats.keys(), strict) - else: - matches = sorted(portable_formats.keys()) - - descriptions = {} - for match in matches: - docstring = portable_formats[match].format.__doc__ - first_docstring_line = docstring.split('\n\n')[0].strip() \ - if docstring else '' - descriptions[match] = first_docstring_line - - _print_descriptions(descriptions, tsv) - - -@tools.command(name='import', - short_help='Import data into a new rachis Artifact.', - help="Import data to create a new rachis Artifact. See " - "https://docs.qiime2.org/ for usage examples and details " - "on the file types and associated semantic types that can " - "be imported.", - cls=ToolCommand) -@click.option('--type', required=True, - help='The semantic type of the artifact that will be created ' - 'upon importing. Use `rachis tools list-types` to see what ' - 'importable semantic types are available in the current ' - 'deployment.') -@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.') -@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.') -@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. Use `rachis tools list-formats ' - '--importable` to see which formats of input data are ' - 'importable.') -@click.option('--validate-level', default='max', - type=click.Choice(['min', 'max']), - help='How much to validate the imported data before creating the' - ' artifact. A value of "max" will generally read the entire' - ' file or directory, whereas "min" will not usually do so.' - ' [default: "max"]') -def import_data(type, input_path, output_path, input_format, validate_level): - - from q2cli.core.config import CONFIG - - artifact = _import(type, input_path, input_format, validate_level) - artifact.save(output_path) - - if input_format is None: - input_format = artifact.format.__name__ - - success = 'Imported %s as %s to %s' % (input_path, - input_format, - output_path) - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(short_help='Take a peek at a rachis Artifact or ' - 'Visualization.', - help="Display basic information about a rachis Artifact or " - "Visualization, including its UUID and type.", - cls=ToolCommand) -@click.argument('paths', nargs=-1, required=True, - type=click.Path(exists=True, file_okay=True, dir_okay=False, - readable=True), metavar=_COMBO_METAVAR) -@click.option('--tsv/--no-tsv', default=False, - help='Print as machine-readable tab-separated values.') -def peek(paths, tsv): - import qiime2.sdk - from q2cli.core.config import CONFIG - - metadatas = {os.path.basename(path): - qiime2.sdk.Result.peek(path) for path in paths} - - if tsv: - click.echo("Filename\tType\tUUID\tData Format") - for path, m in metadatas.items(): - click.echo(f"{path}\t{m.type}\t{m.uuid}\t{m.format}") - - elif len(metadatas) == 1: - metadata = metadatas[os.path.basename(paths[0])] - click.echo(CONFIG.cfg_style('type', "UUID")+": ", nl=False) - click.echo(metadata.uuid) - click.echo(CONFIG.cfg_style('type', "Type")+": ", nl=False) - click.echo(metadata.type) - if metadata.format is not None: - click.echo(CONFIG.cfg_style('type', "Data format")+": ", nl=False) - click.echo(metadata.format) - - else: - COLUMN_FILENAME = "Filename" - COLUMN_TYPE = "Type" - COLUMN_UUID = "UUID" - COLUMN_DATA_FORMAT = "Data Format" - - filename_width = max([len(p) for p in paths] - + [len(COLUMN_FILENAME)]) - type_width = max([len(i.type) for i in metadatas.values()] - + [len(COLUMN_TYPE)]) - uuid_width = max([len(i.uuid) for i in metadatas.values()] - + [len(COLUMN_UUID)]) - data_format_width = \ - max([len(i.format) if i.format is not None else 0 - for i in metadatas.values()] + [len(COLUMN_DATA_FORMAT)]) - - padding = 2 - format_string = f"{{f:<{filename_width + padding}}} " + \ - f"{{t:<{type_width + padding}}} " + \ - f"{{u:<{uuid_width + padding}}} " + \ - f"{{d:<{data_format_width + padding}}}" - - click.secho( - format_string.format( - f=COLUMN_FILENAME, - t=COLUMN_TYPE, - u=COLUMN_UUID, - d=COLUMN_DATA_FORMAT), - bold=True, fg="green") - for path, m in metadatas.items(): - click.echo( - format_string.format( - f=path, - t=m.type, - u=m.uuid, - d=(m.format if m.format is not None else 'N/A'))) - - -_COLUMN_TYPES = ['categorical', 'numeric'] - - -@tools.command(name='cast-metadata', - short_help='Designate metadata column types.', - help='Designate metadata column types.' - ' Supported column types are as follows: %s.' - ' Providing multiple file paths to this command will merge' - ' the metadata.' % (', '.join(_COLUMN_TYPES)), - cls=ToolCommand) -@click.option('--cast', required=True, metavar='COLUMN:TYPE', multiple=True, - help='Parameter for each metadata column that should' - ' be cast as a specified column type (supported types are as' - ' follows: %s). The required formatting for this' - ' parameter is --cast COLUMN:TYPE, repeated for each column' - ' and the associated column type it should be cast to in' - ' the output.' % (', '.join(_COLUMN_TYPES))) -@click.option('--ignore-extra', is_flag=True, - help='If this flag is enabled, cast parameters that do not' - ' correspond to any of the column names within the provided' - ' metadata will be ignored.') -@click.option('--error-on-missing', is_flag=True, - help='If this flag is enabled, failing to include cast' - ' parameters for all columns in the provided metadata will' - ' result in an error.') -@click.option('--output-file', required=False, - type=click.Path(exists=False, file_okay=True, dir_okay=False, - writable=True), - help='Path to file where the modified metadata should be' - ' written to.') -@click.argument('paths', nargs=-1, required=True, metavar='METADATA...', - type=click.Path(exists=True, file_okay=True, dir_okay=False, - readable=True)) -def cast_metadata(paths, cast, output_file, ignore_extra, - error_on_missing): - import tempfile - from qiime2 import Metadata, metadata - - md = _merge_metadata(paths) - - cast_dict = {} - try: - for casting in cast: - if ':' not in casting: - raise click.BadParameter( - message=f'Missing `:` in --cast {casting}', - param_hint='cast') - splitter = casting.split(':') - if len(splitter) != 2: - raise click.BadParameter( - message=f'Incorrect number of fields in --cast {casting}.' - f' Observed {len(splitter)}' - f' {tuple(splitter)}, expected 2.', - param_hint='cast') - col, type_ = splitter - if col in cast_dict: - raise click.BadParameter( - message=(f'Column name "{col}" appears in cast more than' - ' once.'), - param_hint='cast') - cast_dict[col] = type_ - except Exception as err: - header = \ - ('Could not parse provided cast arguments into unique COLUMN:TYPE' - ' pairs. Please make sure all cast flags are of the format --cast' - ' COLUMN:TYPE') - q2cli.util.exit_with_error(err, header=header) - - types = set(cast_dict.values()) - if not types.issubset(_COLUMN_TYPES): - raise click.BadParameter( - message=('Unknown column type provided. Please make sure all' - ' columns included in your cast contain a valid column' - ' type. Valid types: %s' % - (', '.join(_COLUMN_TYPES))), - param_hint='cast') - - column_names = set(md.columns.keys()) - cast_names = set(cast_dict.keys()) - - if not ignore_extra: - if not cast_names.issubset(column_names): - cast = cast_names.difference(column_names) - raise click.BadParameter( - message=('The following cast columns were not found' - ' within the metadata: %s' % - (', '.join(cast))), - param_hint='cast') - - if error_on_missing: - if not column_names.issubset(cast_names): - cols = column_names.difference(cast_names) - raise click.BadParameter( - message='The following columns within the metadata' - ' were not provided in the cast: %s' % - (', '.join(cols)), - param_hint='cast') - - # Remove entries from the cast dict that are not in the metadata to avoid - # errors further down the road - for cast in cast_names: - if cast not in column_names: - cast_dict.pop(cast) - - with tempfile.NamedTemporaryFile() as temp: - md.save(temp.name) - try: - cast_md = Metadata.load(temp.name, cast_dict) - except metadata.io.MetadataFileError as e: - raise click.BadParameter(message=e, param_hint='cast') from e - - if output_file: - cast_md.save(output_file) - else: - with tempfile.NamedTemporaryFile(mode='w+') as stdout_temp: - cast_md.save(stdout_temp.name) - stdout_str = stdout_temp.read() - click.echo(stdout_str) - - -@tools.command(name='inspect-metadata', - 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.', - cls=ToolCommand) -@click.option('--tsv/--no-tsv', default=False, - help='Print as machine-readable TSV instead of text.') -@click.argument('paths', nargs=-1, required=True, metavar='METADATA...', - type=click.Path(file_okay=True, dir_okay=False, readable=True)) -@q2cli.util.pretty_failure(traceback=None) -def inspect_metadata(paths, tsv, failure): - metadata = _merge_metadata(paths) - - # we aren't expecting errors below this point, so set traceback to default - failure.traceback = 'stderr' - failure.header = "An unexpected error has occurred:" - - COLUMN_NAME = "COLUMN NAME" - COLUMN_TYPE = "TYPE" - max_name_len = max([len(n) for n in metadata.columns] - + [len(COLUMN_NAME)]) - max_type_len = max([len(p.type) for p in metadata.columns.values()] - + [len(COLUMN_TYPE)]) - - if tsv: - import csv - import io - - def formatter(*row): - # This is gross, but less gross than robust TSV writing. - with io.StringIO() as fh: - writer = csv.writer(fh, dialect='excel-tab', lineterminator='') - writer.writerow(row) - return fh.getvalue() - else: - formatter = ("{0:>%d} {1:%d}" % (max_name_len, max_type_len)).format - - click.secho(formatter(COLUMN_NAME, COLUMN_TYPE), bold=True) - if not tsv: - click.secho(formatter("=" * max_name_len, "=" * max_type_len), - bold=True) - - for name, props in metadata.columns.items(): - click.echo(formatter(name, props.type)) - - if not tsv: - click.secho(formatter("=" * max_name_len, "=" * max_type_len), - bold=True) - click.secho(("{0:>%d} " % max_name_len).format("IDS:"), - bold=True, nl=False) - click.echo(metadata.id_count) - click.secho(("{0:>%d} " % max_name_len).format("COLUMNS:"), - bold=True, nl=False) - click.echo(metadata.column_count) - - -def _merge_metadata(paths): - m = [q2cli.util.load_metadata(p) for p in paths] - metadata = m[0] - if m[1:]: - metadata = metadata.merge(*m[1:]) - - return metadata - - -@tools.command(short_help='Make a rachis Report.', - help='Make a rachis report consisting of multiple ' - 'visualizations. Visualizations must have unique ' - 'filenames.', - cls=ToolCommand) -@click.argument('visualization_paths', metavar='VIZ', required=True, nargs=-1, - type=click.Path(file_okay=True, dir_okay=False, readable=True)) -@click.option('--report-path', required=True, metavar='REPORT', - type=click.Path(exists=False, file_okay=True, dir_okay=False, - writable=True), - help='Path where output report should be written.') -def make_report(visualization_paths, report_path): - from qiime2 import Visualization - from q2templates.reports import matryoshka_template - from q2cli.core.config import CONFIG - - loaded = [Visualization.load(path) for path in visualization_paths] - keys = [os.path.basename(x).rsplit('.', 1)[0] for x in visualization_paths] - if len(set(keys)) < len(keys): - error = ('Multiple files share the same name and cannot be' - ' disambiguated. Please ensure that all filenames provided' - ' are unique.') - click.echo(CONFIG.cfg_style('error', error), err=True) - click.get_current_context().exit(1) - - entries = dict(zip(keys, loaded)) - - report = Visualization.make_report(matryoshka_template, entries) - result = report.save(report_path) - click.echo(CONFIG.cfg_style('success', f'Report saved to {result}')) - - -@tools.command(short_help='View a rachis Result.', - help="Displays a rachis Result until the command exits. To " - "open a rachis Visualization so it can be used after the " - "command exits, use 'rachis tools extract'.", - cls=ToolCommand) -@click.argument('result-path', metavar='RESULT', - type=click.Path(file_okay=True, dir_okay=False, readable=True)) -@click.option('--port', required=False, type=click.IntRange(1024, 65535), - default=None, help='The port to serve the webapp on.') -@click.option('--verbose', is_flag=True, - help='Display all GET requests in the terminal.') -def view(result_path, port, verbose): - # Get the abspath to the result - result_path = os.path.abspath(result_path) - - # Guard headless envs from having to import anything large - import sys - - if not os.getenv('DISPLAY') and sys.platform != 'darwin': - raise click.UsageError( - 'Result viewing is currently not supported in headless ' - 'environments. You can view Results at https://view.qiime2.org, ' - 'or move the Result to an environment with a display and view it ' - 'with `rachis tools view`.') - - import signal - import random - import tempfile - import threading - import http.server - import contextlib - import urllib.parse - import mimetypes - - from qiime2.sdk import Result - - from q2cli.core.config import CONFIG - - # Load and extract result - result = Result.load(result_path) - - extracted_path = os.path.join(tempfile.gettempdir(), str(result.uuid)) - if not os.path.exists(extracted_path): - result.extract(result_path, tempfile.gettempdir()) - - # This ought to look like a session id generated by normal view - CHAR_SET = 'abcdefghijklmnopqrstuvwxyz0123456789' - SESSION_LEN = 32 - session = ''.join(random.choice(CHAR_SET) for i in range(SESSION_LEN)) - - # Start server - class Handler(http.server.SimpleHTTPRequestHandler): - def do_GET(self): - # Redirect the output from these requests to devnull if not verbose - with open(os.devnull, 'w') as devnull: - with contextlib.redirect_stderr( - sys.stderr if verbose else devnull): - # Determine if this is a request for the file we are - # supposed to be viewing - if self.path == f'/{session}': - if not os.path.exists(result_path): - self.send_error(404) - self.end_headers() - else: - self._handle_file_exists(result_path) - # Determine if this is a request for a file within the - # visualization - elif self.path.startswith(f'/_/{session}/{result.uuid}/'): - file_path = self.path.split('?', 1)[0] - file_path = file_path.split('#', 1)[0] - file_path = file_path.split(str(result.uuid))[1] - file_path = urllib.parse.unquote(file_path) - file_path = extracted_path + file_path - file_path = os.path.abspath(file_path) - - if not os.path.exists(file_path) or \ - not file_path.startswith(extracted_path): - self.send_error(404) - self.end_headers() - else: - self._handle_file_exists(file_path) - # Otherwise default to super class. This will respond - # appropriately to requests for assets that are part of the - # vendored view app and will reject any requests for files - # outside the served directory - else: - super().do_GET() - - def _handle_file_exists(self, file_path): - type_, _ = mimetypes.guess_type(self.path, strict=False) - if type_ is None: - type_ = 'application/octet-stream' - - file_size = os.path.getsize(file_path) - - self.send_response(200) - self.send_header('content-type', type_) - self.send_header('content-length', file_size) - self.end_headers() - - with open(file_path, 'rb') as file: - self.wfile.write(file.read()) - - # Get the path to the packaged vendored view - # TODO: This won't work if we start packaging rachis as a wheel, we will - # have to reimplement this used importlib.resources and it may be mildly - # annoying to make things work properly. It's hard to tell right now since - # we do not use a wheel. - # https://docs.python.org/3/library/importlib.resources.html - import importlib - MODULE_INIT = importlib.import_module('q2cli').__file__ - MODULE_BASE_DIR = os.path.abspath(os.path.dirname(MODULE_INIT)) - VENDOR_PATH = os.path.join(MODULE_BASE_DIR, 'assets', 'view') - - # Set up the server socket - import socket - - server_socket = socket.socket() - # If port is None then slap a 0 into here to get a free port - server_socket.bind(('localhost', 0 if port is None else port)) - # Get the port off the opened socket, if a port was passed, this will set - # the port to itself no harm done. If no port was passed, this will get the - # open port that .bind found. - port = server_socket.getsockname()[1] - - # Start up the server - server = http.server.HTTPServer( - ('localhost', port), lambda *_: Handler(*_, directory=VENDOR_PATH), - bind_and_activate=False) - server.socket = server_socket - - # Don't listen until the server is already going - server_socket.listen(0) - click.echo(f'Agent started on port: {port}') - - # Stop server on termination of main thread - def stop(): - server.shutdown() - sys.exit(0) - - signal.signal(signal.SIGTERM, stop) - - # Start the server in a new thread - thread = threading.Thread(target=server.serve_forever) - thread.daemon = True - thread.start() - - # Open page on server - filename = os.path.basename(result_path) - url = f'http://localhost:{port}?file={filename}&session={session}' - launch_status = click.launch(url) - click.echo('Your view should open in your default browser shortly. You ' - f'may open it manually at the URL:\n\n{url}\n') - - # Yell if there was an error - if launch_status != 0: - click.echo( - CONFIG.cfg_style('error', 'Viewing result failed while attempting ' - f'to open {result_path}'), - err=True) - - # Wait for shut down request - while True: - click.echo("Press the 'q' key, Control-C, or Control-D to quit. This " - "view may no longer be accessible or work correctly after " - "quitting.") - # There is currently a bug in click.getchar where translation - # of Control-C and Control-D into KeyboardInterrupt and - # EOFError (respectively) does not work on Python 3. The code - # here should continue to work as expected when the bug is - # fixed in Click. - # - # https://github.com/pallets/click/issues/583 - try: - char = click.getchar() - click.echo() - if char in {'q', '\x03', '\x04'}: - break - except (KeyboardInterrupt, EOFError): - break - - -@tools.command(short_help="Extract a rachis Artifact or Visualization " - "archive.", - help="Extract all contents of a rachis Artifact or " - "Visualization's archive, including provenance, metadata, " - "and actual data. Use 'rachis tools export' to export " - "only the data stored in an Artifact or Visualization, " - "with the choice of exporting to different formats.", - cls=ToolCommand) -@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') -@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 ' - '[default: current working directory]', - default=os.getcwd()) -def extract(input_path, output_path): - import zipfile - import qiime2.sdk - from q2cli.core.config import CONFIG - - try: - extracted_dir = qiime2.sdk.Result.extract(input_path, output_path) - except (zipfile.BadZipFile, ValueError): - raise click.BadParameter( - '%s is not a valid rachis Result. Only rachis Artifacts and ' - 'Visualizations can be extracted.' % input_path) - else: - success = 'Extracted %s to directory %s' % (input_path, extracted_dir) - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(short_help='Validate data in a rachis Artifact.', - help='Validate data in a rachis Artifact. rachis ' - 'automatically performs some basic validation when ' - 'managing your data; use this command to perform explicit ' - '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.', - cls=ToolCommand) -@click.argument('path', type=click.Path(exists=True, file_okay=True, - dir_okay=False, readable=True), - metavar=_COMBO_METAVAR) -@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).', - default='max', show_default=True) -def validate(path, level): - import qiime2.sdk - from q2cli.core.config import CONFIG - - try: - result = qiime2.sdk.Result.load(path) - except Exception as e: - header = 'There was a problem loading %s as a rachis Result:' % path - q2cli.util.exit_with_error(e, header=header) - - try: - result.validate(level) - except qiime2.plugin.ValidationError as e: - header = 'Result %s does not appear to be valid at level=%s:' % ( - path, level) - q2cli.util.exit_with_error(e, header=header, traceback=None) - except Exception as e: - header = ('An unexpected error has occurred while attempting to ' - 'validate result %s:' % path) - q2cli.util.exit_with_error(e, header=header) - else: - click.echo(CONFIG.cfg_style('success', f'Result {path} appears to be ' - f'valid at level={level}.')) - - -@tools.command(short_help='Print citations for a rachis result.', - help='Print citations as a BibTex file (.bib) for a rachis' - ' result.', - cls=ToolCommand) -@click.argument('path', type=click.Path(exists=True, file_okay=True, - dir_okay=False, readable=True), - metavar=_COMBO_METAVAR) -def citations(path): - import qiime2.sdk - import io - from q2cli.core.config import CONFIG - ctx = click.get_current_context() - - try: - result = qiime2.sdk.Result.load(path) - except Exception as e: - header = 'There was a problem loading %s as a rachis result:' % path - q2cli.util.exit_with_error(e, header=header) - - if result.citations: - with io.StringIO() as fh: - result.citations.save(fh) - click.echo(fh.getvalue(), nl=False) - ctx.exit(0) - else: - click.echo(CONFIG.cfg_style('problem', 'No citations found.'), - err=True) - ctx.exit(1) - - -@tools.command(name='cache-create', - short_help='Create an empty cache at the given location.', - help='Create an empty cache at the given location.', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=False, readable=True), - help='Path to a nonexistent directory to be created as a cache.') -def cache_create(cache): - from qiime2.core.cache import Cache - from q2cli.core.config import CONFIG - - try: - Cache(cache) - except Exception as e: - header = "There was a problem creating a cache at '%s':" % cache - q2cli.util.exit_with_error(e, header=header, traceback=None) - - success = "Created cache at '%s'" % cache - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-remove', - short_help='Removes a given key from a cache.', - help='Removes a given key from a cache then runs garbage ' - 'collection on the cache.', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to remove the key from.') -@click.option('--key', required=True, multiple=True, - help='The key to remove from the cache. Pass `key` parameter ' - 'multiple times to remove multiple keys.') -def cache_remove(cache, key): - from qiime2.core.cache import Cache - from q2cli.core.config import CONFIG - - # I don't want to instantiate this every time in the loop - try: - _cache = Cache(cache) - except Exception as e: - header = f"The path '{cache}' is not a valid rachis cache." - q2cli.util.exit_with_error(e, header=header, traceback=None) - - for _key in key: - try: - # TODO: This will run gc after every key removal. Don't need to do - # that - _cache.remove(_key) - except Exception as e: - header = f"There was a problem removing the key '{_key}' from "\ - f"the cache '{cache}'" - q2cli.util.exit_with_error(e, header=header, traceback=None) - else: - success = f"Removed key '{_key}' from cache '{cache}'" - click.echo(CONFIG.cfg_style('success', success)) - - success = f"Removed key(s) '{key}' from cache '{cache}'" - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-garbage-collection', - short_help='Runs garbage collection on the cache at the ' - 'specified location.', - help='Runs garbage collection on the cache at the specified ' - 'location if the specified location is a cache.', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to run garbage collection on.') -def cache_garbage_collection(cache): - from qiime2.core.cache import Cache - from q2cli.core.config import CONFIG - - try: - _cache = Cache(cache) - _cache.garbage_collection() - except Exception as e: - header = "There was a problem running garbage collection on the " \ - "cache at '%s':" % cache - q2cli.util.exit_with_error(e, header=header, traceback=None) - - success = "Ran garbage collection on cache at '%s'" % cache - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-store', - short_help='Stores a .qza in the cache under a key.', - help='Stores a .qza in the cache under a key.', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to save into.') -@click.option('--artifact-path', required=True, - type=click.Path(exists=True, file_okay=True, dir_okay=False, - readable=True), - help='Path to a .qza to save into the cache.') -@click.option('--key', required=True, - help='The key to save the artifact under (must be a valid ' - 'Python identifier).') -def cache_store(cache, artifact_path, key): - from qiime2.sdk.result import Result - from qiime2.core.cache import Cache - from q2cli.core.config import CONFIG - from rachis.sdk.util import validate_result_collection_keys - - # If the Result takes a long time to load, we can waste all that time - # then explode on cache.save, better to validate the key twice once here - # and once in .save than load the entire Result before exploding. Key - # validation is cheap. - validate_result_collection_keys(key) - - try: - artifact = Result.load(artifact_path) - _cache = Cache(cache) - _cache.save(artifact, key) - except Exception as e: - header = "There was a problem saving the artifact '%s' to the cache " \ - "'%s' under the key '%s':" % (artifact_path, cache, key) - q2cli.util.exit_with_error(e, header=header, traceback=None) - - success = "Saved the artifact '%s' to the cache '%s' under the key " \ - "'%s'" % (artifact_path, cache, key) - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-import', - short_help='Imports data into an Artifact in the cache under a ' - 'key.', - help='Imports data into an Artifact in the cache under a key.', - cls=ToolCommand) -@click.option('--type', required=True, - help='The semantic type of the artifact that will be created ' - 'upon importing. Use `rachis tools list-types` to see what ' - 'importable semantic types are available in the current ' - 'deployment.') -@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.') -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to save into.') -@click.option('--key', required=True, - help='The key to save the artifact under (must be a valid ' - 'Python identifier).') -@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. Use `rachis tools list-formats ' - '--importable` to see which formats of input data are ' - 'importable.') -@click.option('--validate-level', required=False, default='max', - type=click.Choice(['min', 'max']), - help='How much to validate the imported data before creating the' - ' artifact. A value of "max" will generally read the entire' - ' file or directory, whereas "min" will not usually do so.' - ' [default: "max"]') -def cache_import(type, input_path, cache, key, input_format, validate_level): - from qiime2 import Cache - from q2cli.core.config import CONFIG - from rachis.sdk.util import validate_result_collection_keys - - # If the Artifact takes a long time to import, we can waste all that time - # then explode on cache.save, better to validate the key twice once here - # and once in .save than import the entire Artifact before exploding. Key - # validation is cheap. - validate_result_collection_keys(key) - - artifact = _import(type, input_path, input_format, validate_level) - _cache = Cache(cache) - _cache.save(artifact, key) - - if input_format is None: - input_format = artifact.format.__name__ - - success = 'Imported %s as %s to %s:%s' % (input_path, - input_format, - cache, - key) - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-export', - short_help='Export data from a rachis Artifact ' - 'or a Visualization in a given cache under a given key.', - help='Exporting extracts (and optionally transforms) data ' - 'stored inside an Artifact or Visualization. Note that ' - 'Visualizations cannot be transformed with --output-format', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to export from.') -@click.option('--key', required=True, - help='The key of the Artifact or Visualization to export from ' - 'the cache.') -@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 data should be exported to.') -@click.option('--output-format', required=False, - help='Format which the data should be exported as. ' - 'This option cannot be used with Visualizations') -def export_cache(cache, key, output_path, output_format): - from qiime2 import Cache - from q2cli.core.config import CONFIG - - try: - _cache = Cache(cache) - result = _cache.load(key) - - output_format = _export(result, output_format, output_path) - except Exception as e: - header = "There was a problem exporting the Artifact/Visualization " \ - f"with the key '{key}' from the cache '{cache}'." - q2cli.util.exit_with_error(e, header=header, traceback=None) - - output_type = 'file' if os.path.isfile(output_path) else 'directory' - success = f"Exported {cache}:{key} as {output_format} to {output_type} "\ - f"{output_path}" - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-fetch', - short_help='Fetches an artifact out of a cache into a .qza.', - help='Fetches the artifact saved to the specified cache under ' - 'the specified key into a .qza at the specified location.', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to load from.') -@click.option('--key', required=True, - help='The key to the artifact being loaded.') -@click.option('--output-path', required=True, - type=click.Path(exists=False, readable=True), - help='Path to put the .qza we are loading the artifact into.') -def cache_fetch(cache, key, output_path): - from qiime2.core.cache import Cache - from q2cli.core.config import CONFIG - - try: - _cache = Cache(cache) - artifact = _cache.load(key) - artifact.save(output_path) - except Exception as e: - header = "There was a problem loading the artifact with the key " \ - "'%s' from the cache '%s' and saving it to the file '%s':" % \ - key, cache, output_path - q2cli.util.exit_with_error(e, header=header, traceback=None) - - success = "Loaded artifact with the key '%s' from the cache '%s' and " \ - "saved it to the file '%s'" % (key, cache, output_path) - click.echo(CONFIG.cfg_style('success', success)) - - -@tools.command(name='cache-status', - short_help='Checks the status of the cache.', - help='Lists all keys in the given cache. Peeks artifacts ' - 'pointed to by keys to data and lists the number of ' - 'artifacts in the pool for keys to pools.', - cls=ToolCommand) -@click.option('--cache', required=True, - type=click.Path(exists=True, file_okay=False, dir_okay=True, - readable=True), - help='Path to an existing cache to check the status of.') -def cache_status(cache): - from qiime2.core.cache import Cache - from qiime2.sdk.result import Result - - from q2cli.core.config import CONFIG - - data_output = [] - pool_output = [] - try: - _cache = Cache(cache) - with _cache.lock: - for key in _cache.get_keys(): - key_values = _cache.read_key(key) - - if 'data' in key_values: - data = key_values['data'] - data_output.append( - 'data: %s -> %s' % - (key, str(Result.peek(_cache.data / data)))) - elif 'pool' in key_values: - pool = key_values['pool'] - pool_output.append( - 'pool: %s -> size = %s' % - (key, str(len(os.listdir(_cache.pools / pool))))) - except Exception as e: - header = "There was a problem getting the status of the cache at " \ - "path '%s':" % cache - q2cli.util.exit_with_error(e, header=header, traceback=None) - - if not data_output: - data_output = 'No data keys in cache' - else: - data_output = '\n'.join(data_output) - data_output = 'Data keys in cache:\n' + data_output - - if not pool_output: - pool_output = 'No pool keys in cache' - else: - pool_output = '\n'.join(pool_output) - pool_output = 'Pool keys in cache:\n' + pool_output - - output = data_output + '\n\n' + pool_output - success = "Status of the cache at the path '%s':\n\n%s" % \ - (cache, output) - click.echo(CONFIG.cfg_style('success', success)) - - -replay_in_fp_help = ( - 'filepath to a rachis Archive (.qza or .qzv) or directory of Archives' -) -replay_recurse_help = ( - 'if in-fp is a directory, will also search sub-directories when finding ' - 'Archives to parse' -) -replay_validate_checksums_help = ( - 'check that replayed archives are intact and uncorrupted' -) -replay_parse_metadata_help = ( - 'parse the original metadata captured in provenance for review or replay' -) -replay_use_recorded_metadata_help = ( - 're-use the original metadata captured in provenance' -) -replay_suppress_header_help = ( - 'do not write header/footer blocks in the output script' -) -replay_verbose_help = ( - 'print status messages to stdout while processing' -) -replay_dump_recorded_metadata_help = ( - 'write the original metadata captured in provenance to disk in the ' - '--metadata-out-dir directory' -) - - -@tools.command(name='replay-provenance', cls=ToolCommand) -@click.option('--in-fp', required=True, help=replay_in_fp_help) -@click.option('--recurse/--no-recurse', - default=False, - show_default=True, - help=replay_recurse_help) -@click.option('--usage-driver', - default='cli', - show_default=True, - help='the target interface for your replay script', - type=click.Choice(['python3', 'cli'], case_sensitive=False)) -@click.option('--validate-checksums/--no-validate-checksums', - default=True, - show_default=True, - help=replay_validate_checksums_help) -@click.option('--parse-metadata/--no-parse-metadata', - default=True, - show_default=True, - help=replay_parse_metadata_help) -@click.option('--use-recorded-metadata/--no-use-recorded-metadata', - default=False, - show_default=True, - help=replay_use_recorded_metadata_help) -@click.option('--suppress-header/--no-suppress-header', - default=False, - show_default=True, - help=replay_suppress_header_help) -@click.option('--verbose/--no-verbose', - default=True, - show_default=True, - help=replay_verbose_help) -@click.option('--dump-recorded-metadata/--no-dump-recorded-metadata', - default=True, - show_default=True, - help=replay_dump_recorded_metadata_help) -@click.option('--metadata-out-dir', - default='', - show_default=True, - help=('the directory where captured study metadata ' - 'should be written if --dump-recorded-metadata. This ' - 'often produces many outputs, so a dedicated directory ' - 'should generally be used. Creates the directory if it ' - 'does not already exist. By default, metadata is written ' - 'to `${PWD}/recorded_metadata/`')) -@click.option('--out-fp', - required=True, - type=click.Path(exists=False, writable=True), - help='the filepath where your replay script should be written') -def provenance_replay( - in_fp: str, - out_fp: str, - usage_driver: Literal['python3', 'cli'], - recurse: bool = False, - validate_checksums: bool = True, - parse_metadata: bool = True, - use_recorded_metadata: bool = False, - suppress_header: bool = False, - verbose: bool = True, - dump_recorded_metadata: bool = True, - metadata_out_dir: str = '' -): - """ - Replay provenance from a rachis Artifact filepath to a written executable - """ - from qiime2.core.archive.provenance_lib.replay import replay_provenance - from qiime2.sdk.util import get_available_usage_drivers - from q2cli.util import get_plugin_manager - - get_plugin_manager() - usage_drivers = get_available_usage_drivers() - try: - usage_driver_type = usage_drivers[usage_driver] - except KeyError: - msg = ( - f'The {usage_driver} usage driver is not available in the ' - 'current environment.' - ) - raise ValueError(msg) - - try: - in_fp = q2cli.util.try_as_cache_input(in_fp) - except ValueError: - pass - - replay_provenance( - usage_driver=usage_driver_type, - payload=in_fp, - out_fp=out_fp, - validate_checksums=validate_checksums, - parse_metadata=parse_metadata, - recurse=recurse, - use_recorded_metadata=use_recorded_metadata, - suppress_header=suppress_header, - verbose=verbose, - dump_recorded_metadata=dump_recorded_metadata, - md_out_dir=metadata_out_dir - ) - filename = os.path.realpath(out_fp) - click.echo(f'{usage_driver} replay script written to {filename}') - - -@tools.command(name='replay-citations', cls=ToolCommand) -@click.option('--in-fp', required=True, help=replay_in_fp_help) -@click.option('--recurse/--no-recurse', - default=False, - show_default=True, - help=replay_recurse_help) -@click.option('--deduplicate/--no-deduplicate', - default=True, - show_default=True, - help=('If deduplicate, duplicate citations will be removed ' - 'heuristically, e.g. by comparing DOI fields. ' - 'This greatly reduces manual curation of reference lists, ' - 'but introduces a small risk of reference loss.')) -@click.option('--suppress-header/--no-suppress-header', - default=False, - show_default=True, - help=replay_suppress_header_help) -@click.option('--verbose/--no-verbose', - default=True, - show_default=True, - help=replay_verbose_help) -@click.option('--out-fp', - required=True, - type=click.Path(exists=False, writable=True), - help='the filepath where your bibtex file should be written') -def citations_replay( - in_fp: str, - out_fp: str, - recurse: bool = False, - deduplicate: bool = True, - suppress_header: bool = False, - verbose: bool = True -): - """ - Reports all citations from a rachis Artifact or directory of Artifacts, - with the goal of improving and simplifying attribution of/in published - work. - - Not for use in reporting e.g. software versions used in an analysis, as - deduplication removes duplicate references with different plugin versions. - """ - from qiime2.core.archive.provenance_lib.parse import ProvDAG - from qiime2.core.archive.provenance_lib.replay import replay_citations - - try: - in_fp = q2cli.util.try_as_cache_input(in_fp) - except ValueError: - pass - - dag = ProvDAG(in_fp, verbose=verbose, recurse=recurse) - replay_citations( - dag, - out_fp=out_fp, - deduplicate=deduplicate, - suppress_header=suppress_header - ) - filename = os.path.realpath(out_fp) - click.echo(f'citations bibtex file written to {filename}') - - -@tools.command(name='replay-supplement', cls=ToolCommand) -@click.option('--in-fp', required=True, - help='filepath to a rachis Archive or directory of Archives') -@click.option('--recurse/--no-recurse', - default=False, - show_default=True, - help=('if in-fp is a directory, will also search sub-directories' - ' when finding .qza/.qzv files to parse')) -@click.option('--deduplicate/--no-deduplicate', - default=True, - show_default=True, - help=('If deduplicate, duplicate citations will be removed ' - 'heuristically, e.g. by comparing DOI fields. ' - 'This greatly reduces manual curation of reference lists, ' - 'but introduces a small risk of reference loss.')) -@click.option('--validate-checksums/--no-validate-checksums', - default=True, - show_default=True, - help=replay_validate_checksums_help) -@click.option('--parse-metadata/--no-parse-metadata', - default=True, - show_default=True, - help=replay_parse_metadata_help) -@click.option('--use-recorded-metadata/--no-use-recorded-metadata', - default=False, - show_default=True, - help=replay_use_recorded_metadata_help) -@click.option('--suppress-header/--no-suppress-header', - default=False, - show_default=True, - help=replay_suppress_header_help) -@click.option('--verbose/--no-verbose', - default=True, - show_default=True, - help=replay_verbose_help) -@click.option('--dump-recorded-metadata/--no-dump-recorded-metadata', - default=True, - show_default=True, - help='write the original metadata captured in provenance to ' - 'recorded_metadata/ inside the archive') -@click.option('--out-fp', - required=True, - type=click.Path(exists=False, writable=True), - help='the filepath where your reproduciblity supplement zipfile ' - 'should be written') -def supplement_replay( - in_fp: str, - out_fp: str, - validate_checksums: bool = True, - parse_metadata: bool = True, - use_recorded_metadata: bool = False, - recurse: bool = False, - deduplicate: bool = True, - suppress_header: bool = False, - verbose: bool = True, - dump_recorded_metadata: bool = True -): - """ - Produces a zipfile package of useful documentation supporting in silico - reproducibility of some rachis Result(s) from a rachis Artifact or - directory of Artifacts. - - Package includes: - - replay scripts for all supported interfaces - - a bibtex-formatted collection of all citations - """ - from qiime2.core.archive.provenance_lib.replay import replay_supplement - from qiime2.sdk.util import get_available_usage_drivers - from q2cli.util import get_plugin_manager - - get_plugin_manager() - usage_drivers = get_available_usage_drivers() - usage_driver_types = list(usage_drivers.values()) - if not usage_driver_types: - msg = ( - 'There are no available usage drivers registered in the current ' - 'environment.' - ) - raise ValueError(msg) - - try: - in_fp = q2cli.util.try_as_cache_input(in_fp) - except ValueError: - pass - - replay_supplement( - usage_drivers=usage_driver_types, - payload=in_fp, - out_fp=out_fp, - validate_checksums=validate_checksums, - parse_metadata=parse_metadata, - use_recorded_metadata=use_recorded_metadata, - recurse=recurse, - deduplicate=deduplicate, - suppress_header=suppress_header, - verbose=verbose, - dump_recorded_metadata=dump_recorded_metadata - ) - - -@tools.command( - name='annotation-create', - short_help='Add a new Annotation to a rachis Result.', - help='Attach an Annotation to an existing Artifact or Visualization.' - ' Supported for rachis archives of version 7.0+.', - cls=ToolCommand -) -@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 the `.qza` or `.qzv` you want to annotate.' -) -@click.option( - '--annotation-type', - required=True, - type=click.Choice(list(ANNOTATION_TYPE_DICT.keys()), case_sensitive=True), - help='Annotation type to create.' -) -@click.option( - '--name', - required=True, - help='Name for your Annotation (must be unique for a given Result).' -) -# NOTE-specific inputs -@click.option( - '--text', - required=False, - help='Inline text for the Annotation.' - ' Mutually exclusive with `--file`.' -) -@click.option( - '--file', - 'filepath', - required=False, - type=click.Path(exists=True, file_okay=True, - dir_okay=False, readable=True), - help='Path to a text file whose contents will be added' - ' to your Annotation. Mutually exclusive with `--text`.' -) -# SIGNATURE-specific input -@click.option( - '--fingerprint', - required=False, - help='Fingerprint of the keypair in GnuPG (spaces ok). ' -) -@click.option( - '--output-path', - required=False, - type=click.Path(file_okay=True, dir_okay=False, writable=True), - help='Where to write the newly annotated result.' - '[default: overwrite input file]' -) -def annotation_create(input_path, annotation_type, name, - # note inputs - text, filepath, - # signature input - fingerprint, - output_path): - """Load a Result, add an Annotation and save.""" - import qiime2.sdk - from qiime2.core.annotate import Note, Signature - from q2cli.core.config import CONFIG - - result = qiime2.sdk.Result.load(input_path) - - if annotation_type == 'Note': - # enforce exactly one of `--text` or `--file` - if (text is None) == (filepath is None): - raise click.UsageError('Exactly one of `--text` or `--file`' - ' must be provided.') - # ensure Signature-specific inputs aren't used - if fingerprint: - raise click.UsageError('`--fingerprint` is only valid for ' - 'annotations of type Signature.') - - annotation = Note(name=name, text=text, filepath=filepath) - - elif annotation_type == 'Signature': - if fingerprint is None: - raise click.UsageError('`--signature` must be provided.') - if text or filepath: - raise click.UsageError('`--text`/`--filepath` are only valid for ' - 'annotations of type Note.') - - annotation = Signature(name=name, fingerprint=fingerprint) - - else: - raise click.BadParameter('Unsupported annotation type: ' - f'{annotation_type}') - - try: - result.add_annotation(annotation) - except Exception as e: - click.echo(CONFIG.cfg_style('error', str(e)), err=True) - raise click.Abort() - - out_fp = output_path or input_path - result.save(out_fp) - - click.echo( - CONFIG.cfg_style('success', - f'Added {annotation_type} "{name}" to {out_fp}') - ) - - -@tools.command( - name='annotation-remove', - short_help='Remove an Annotation from a rachis Result.', - help='Remove an existing Annotation (by name) from' - ' an Artifact or Visualization.', - cls=ToolCommand -) -@click.option( - '--input-path', - required=True, - type=click.Path(exists=True, dir_okay=False, readable=True), - help='The `.qza` or `.qzv` from which to remove the Annotation.' -) -@click.option( - '--name', - required=True, - help='The name of the Annotation to remove.' -) -@click.option( - '--output-path', - required=False, - type=click.Path(dir_okay=False, writable=True), - help='Where to write the updated Result.' - '[default: overwrite input filepath]' -) -def annotation_remove(input_path, name, output_path): - """Load a Result, remove an Annotation by name and save.""" - import qiime2.sdk - from q2cli.core.config import CONFIG - - result = qiime2.sdk.Result.load(input_path) - - try: - result.remove_annotation(name) - except Exception as e: - click.echo(CONFIG.cfg_style('error', str(e)), err=True) - raise click.Abort() - - out_fp = output_path or input_path - result.save(out_fp) - - click.echo( - CONFIG.cfg_style('success', - f'Removed annotation "{name}" from {out_fp}') - ) - - -@tools.command( - name='annotation-fetch', - short_help='Fetch an Annotation from a rachis Result.', - help='Fetch an existing Annotation (by name) from' - ' an Artifact or Visualization.', - cls=ToolCommand -) -@click.option( - '--input-path', - required=True, - type=click.Path(exists=True, dir_okay=False, readable=True), - help='The `.qza` or `.qzv` to fetch the Annotation from.' -) -@click.option( - '--name', - required=True, - help='The name of the Annotation to fetch.' -) -@click.option( - '--verbose/--no-verbose', - default=False, - help='Whether to display the contents of the Annotation.' -) -def annotation_fetch(input_path, name, verbose): - """ - Load a Result, retrieve an Annotation by name and display the contents. - """ - import qiime2.sdk - from q2cli.core.config import CONFIG - - result = qiime2.sdk.Result.load(input_path) - - try: - annotation = result.get_annotation(name) - except Exception as e: - click.echo(CONFIG.cfg_style('error', str(e)), err=True) - raise click.Abort() - - click.echo(CONFIG.cfg_style('type', "ID")+": ", nl=False) - click.echo(annotation.id) - click.echo(CONFIG.cfg_style('type', "name")+": ", nl=False) - click.echo(annotation.name) - click.echo(CONFIG.cfg_style('type', "type")+": ", nl=False) - click.echo(annotation.annotation_type) - if verbose: - if annotation.annotation_type == 'Signature': - click.echo('', nl=True) - click.echo(CONFIG.cfg_style('type', "signer_name")+": ", nl=False) - click.echo(annotation.signer_name) - click.echo(CONFIG.cfg_style('type', "signer_email")+": ", nl=False) - click.echo(annotation.signer_email) - click.echo(CONFIG.cfg_style('type', "fingerprint")+": ", nl=False) - click.echo(annotation.fingerprint) - else: - click.echo('', nl=True) - click.echo(CONFIG.cfg_style('type', "contents")+": ", nl=False) - click.echo(annotation.contents) - - -@tools.command( - name='annotation-list', - short_help='List Annotations on a rachis Result.', - help='List all Annotations that are attached to' - ' an Artifact or Visualization.', - cls=ToolCommand -) -@click.option( - '--input-path', - required=True, - type=click.Path(exists=True, dir_okay=False, readable=True), - help='The `.qza` or `.qzv` to list Annotations from.' -) -def annotation_list(input_path): - """ - Load a Result, retrieve all Annotations - and display their ids, names & types. - """ - import qiime2.sdk - from q2cli.core.config import CONFIG - - result = qiime2.sdk.Result.load(input_path) - - try: - annotations_iter = result.iter_annotations() - except Exception as e: - click.echo(CONFIG.cfg_style('error', str(e)), err=True) - raise click.Abort() - - annotations = list(annotations_iter) - # we may as well raise an error here to make it clear - # that there are no annotations on the given result - if not annotations: - raise click.ClickException("No Annotations found.") - - for annotation in annotations: - click.echo(CONFIG.cfg_style('type', "ID")+": ", nl=False) - click.echo(annotation.id) - click.echo(CONFIG.cfg_style('type', "name")+": ", nl=False) - click.echo(annotation.name) - click.echo(CONFIG.cfg_style('type', "type")+": ", nl=False) - click.echo(annotation.annotation_type) - click.echo('', nl=True) - - -@tools.command( - name='signature-verify', - short_help='Verify a Signature Annotation on a rachis Result.', - help='Verify the Signature fingerprint matches an existing key pair on ' - 'the local GPG keyring, the checksum digest matches a newly ' - 'calculated sha512sum of the root checksums file, and the ' - 'Signature-level checksums match with the expected sha512sum of ' - 'each file on a rachis Result.', - cls=ToolCommand -) -@click.option( - '--input-path', - required=True, - type=click.Path(exists=True, dir_okay=False, readable=True), - help='The `.qza` or `.qzv` to verify a Signature from.' -) -@click.option( - '--name', - required=True, - help='The name of the Signature to verify.' -) -def signature_verify(input_path, name): - import qiime2.sdk - from q2cli.core.config import CONFIG - - result = qiime2.sdk.Result.load(input_path) - - try: - result.verify(name) - except Exception as e: - click.echo(CONFIG.cfg_style('error', str(e)), err=True) - raise click.Abort() - - click.echo(CONFIG.cfg_style('success', - f'Signature {name} on Result ' - f'{input_path} verified successfully.')) - - -@tools.command( - name='redact-metadata', - short_help='Remove metadata from a Result.', - help='Remove all metadata from a Result and return the Result ' - 'otherwise unchanged.' -) -@click.option( - '--input-path', - required=True, - help='The path to the Result to remove metadata from.' -) -@click.option( - '--output-path', - required=True, - help='Path to save the Result with redacted metadata to.' -) -def redact_metadata(input_path, output_path): - from rachis.sdk.result import Result - from q2cli.core.config import CONFIG - - result = Result.load(input_path) - - try: - result.redact_metadata() - except ValueError as e: - click.echo(CONFIG.cfg_style('error', str(e)), err=True) - raise click.Abort() - - result.save(output_path) - - click.echo( - CONFIG.cfg_style( - 'success', - f'Succesfully redacted metadata from {input_path}, and saved to ' - f'{output_path}.' - ) - ) diff --git a/q2cli/click/__init__.py b/q2cli/click/__init__.py deleted file mode 100644 index 5a85cefd..00000000 --- a/q2cli/click/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- diff --git a/q2cli/click/command.py b/q2cli/click/command.py deleted file mode 100644 index 82000380..00000000 --- a/q2cli/click/command.py +++ /dev/null @@ -1,365 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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): - from q2cli.core.config import CONFIG - 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.echo(CONFIG.cfg_style('problem', - problems.center(78, ' ')), 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.echo(CONFIG.cfg_style('error', msg), err=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): - from q2cli.core.config import CONFIG - """Writes the usage line into the formatter.""" - pieces = self.collect_usage_pieces(ctx) - formatter.write_usage(CONFIG.cfg_style('command', ctx.command_path), - ' '.join(pieces)) - - def format_options(self, ctx, formatter, COL_MAX=23, COL_MIN=10): - from q2cli.core.config import CONFIG - # 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((CONFIG.cfg_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 - from q2cli.core.config import CONFIG - 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 = CONFIG.cfg_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 = " " + CONFIG.cfg_style('type', line) - first_iter = False - else: - dangling_edge = len(type_indent) + len(line) - line = type_indent + CONFIG.cfg_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, CONFIG.cfg_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) + CONFIG.cfg_style( - 'default_arg', requirements) + '\n') - - def _color_important(self, tokens, ctx): - import re - from q2cli.core.config import CONFIG - - 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 = CONFIG.cfg_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 diff --git a/q2cli/click/licenses/click.LICENSE.rst b/q2cli/click/licenses/click.LICENSE.rst deleted file mode 100644 index 6078d1bc..00000000 --- a/q2cli/click/licenses/click.LICENSE.rst +++ /dev/null @@ -1,40 +0,0 @@ -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. - diff --git a/q2cli/click/option.py b/q2cli/click/option.py deleted file mode 100644 index 006995a6..00000000 --- a/q2cli/click/option.py +++ /dev/null @@ -1,350 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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: - if multiple == 'list': - multiple = list - elif multiple == 'dict': - multiple = dict - else: - multiple = 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 - - # This nonsense: - # https://github.com/pallets/click/blob - # /08f71b08e2b7ee9b1ea27daf6d3040999fc68551 - # /src/click/core.py#L2576-L2584 - if is_bool_flag and multiple is not None: - to_add_multiple = attrs.pop('multiple') - - super().__init__(**attrs) - - if is_bool_flag and multiple is not None: - self.multiple = to_add_multiple - - # 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 - # this consume deals with the metadata file - md_file, source = super().consume_value(ctx, opts) - # consume uses self.name, so mutate but backup for after - backup, self.name = self.name, self.q2_extra_dest - try: - # this consume deals with the metadata column - md_col, _ = super().consume_value(ctx, opts) - # If `--m-metadata-column` isn't provided, need to set md_col to None - # in order for the click.MissingParameter errors below to be raised - except click.MissingParameter: - md_col = None - - self.name = backup - # These branches won't get hit unless there's a value associated with - # md_col - the try/except case above handled the situation where the - # metadata_column parameter itself wasn't provided (vs just a value) - 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, source) - else: - return ((md_file, md_col), source) - - 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(opts=self.opts, action='store', dest=self.name, - nargs=1, obj=self) - parser.add_option(opts=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(opts=self.opts, action=action, const=True, - **shared) - parser.add_option(opts=self.secondary_opts, action=action, - const=False, **shared) - elif self.multiple: - action = 'append_greedy' - parser.add_option(opts=self.opts, action='append_greedy', **shared) - else: - super().add_to_parser(parser, ctx) - - def get_default(self, ctx, call=True): - if self.required and not ctx.resilient_parsing and not ( - self.q2_prefix == 'o' and ctx.params.get('output_dir', False)): - raise click.MissingParameter(ctx=ctx, param=self) - return super().get_default(ctx, call=call) - - def process_value(self, ctx, value): - try: - return super().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) - keys, value = self._split_and_validate_input_keys(value) - - if self.q2_multiple is set: - self._check_length(value, ctx) - - # This means we loaded a proper Collection directory. When we - # load in a Collection directory for an action that takes a - # Collection input, we get a tuple containing a dictionary of - # the Collection we wanted. When we load in a Collection - # directory for an action that takes a List, we get a list - # containing a dictionary of the Collection we wanted. We just - # extract that dictionary. - if (isinstance(value, tuple) or isinstance(value, list)) \ - and len(value) == 1 and isinstance(value[0], dict): - value = value[0] - - # We already have a dict, so we already have keys - if isinstance(value, dict): - keys = value.keys() - value = list(value.values()) - elif self.q2_multiple is dict: - if keys is None: - keys = range(len(value)) - value = value - else: - 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( - 'received <%s> as an argument, which is incompatible' - ' with parameter type: %r' % (args, type_expr), - ctx=ctx, param=self) - - if self.q2_multiple is dict: - value = {str(k): v for k, v in zip(keys, value)} - 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 " - "rachis 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: - _values = [] - - if self.q2_multiple is set: - self._check_length(value, ctx) - - keys = [] - if self.q2_multiple is dict and type(value) is not dict: - _values = {} - - keyed = False - unkeyed = False - # All params in a Collection must be either keyed or - # unkeyed. We cannot have a mix because it makes things - # ambiguous - for idx, item in enumerate(value): - if ':' in item: - if unkeyed: - raise KeyError( - 'The keyed value <%s> has been mixed' - ' with unkeyed values. All values must' - ' be keyed or unkeyed.' % item) - key, _value = item.split(':', 1) - _values[key] = _value - keyed = True - else: - if keyed: - raise KeyError( - 'The unkeyed value <%s> has been' - ' mixed with keyed values. All values' - ' must be keyed or unkeyed.' % item) - _values[str(idx)] = item - unkeyed = True - else: - _values = value - - value = \ - qiime2.sdk.util.parse_primitive(self.q2_ast, _values) - except ValueError: - args = ', '.join(map(repr, value)) - expr = qiime2.sdk.util.type_from_ast(self.q2_ast) - raise click.BadParameter( - 'received <%s> as an argument, which is incompatible' - ' with parameter type: %r' % (args, expr), - ctx=ctx, param=self) - return value - elif self.q2_prefix == 'i': - value = super().type_cast_value(ctx, value) - if value is not None: - return value[1] - return value - - # We have an output here - return super().type_cast_value(ctx, value) - - def _split_and_validate_input_keys(self, value): - """ This function ensures that if a user passed in a de-facto - collection they did so properly. - """ - keys = [t[0] for t in value] - values = [t[1] for t in value] - - if any(key is not None and not key.isidentifier() for key in keys): - raise ValueError('All keys must be valid Python identifiers.' - ' Python identifier rules may be found here' - ' https://www.askpython.com/python/' - 'python-identifiers-rules-best-practices') - - # If we had no keys, we are fine - if all(key is None for key in keys): - return None, values - - has_nones = any(key is None for key in keys) - has_keys = any(key is not None for key in keys) - - # We cannot have keys for something that isn't a dict - if self.q2_multiple is not dict and has_keys: - raise ValueError('Keyed values may only be supplied for ' - 'Collection inputs.') - # We cannot have a mixture of keyed and unkeyed values - elif self.q2_multiple is dict and has_keys and has_nones: - raise ValueError('Keyed values cannot be mixed with unkeyed ' - 'values.') - - return keys, values - - def _check_length(self, value, ctx): - import collections - - if isinstance(value, tuple) and len(value) == 1 and \ - isinstance(value[0], dict): - value = list(value[0].values()) - - 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( - 'received <%s> as an argument, which contains duplicates' - ' of the following: <%s>' % (args, dups), ctx=ctx, param=self) diff --git a/q2cli/click/parser.py b/q2cli/click/parser.py deleted file mode 100644 index 62c43426..00000000 --- a/q2cli/click/parser.py +++ /dev/null @@ -1,161 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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': - 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, obj, opts, dest, action=None, nargs=1, const=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. - """ - 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(obj=obj, opts=opts, dest=dest, action=action, - nargs=nargs, const=const) - # 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): - from q2cli.core.artifact_cache_global import set_used_artifact_cache - - backup = args.copy() # args will be mutated by super() - try: - # We need to set this before we would normally parse it out so we - # can use the requested cache for all the operations that use a - # cache. Look for all uses of USED_ARTIFACT_CACHE. Some of these - # are during arg parsing - if '--use-cache' in backup: - set_used_artifact_cache(backup) - - 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) diff --git a/q2cli/click/type.py b/q2cli/click/type.py deleted file mode 100644 index 9ce46a7b..00000000 --- a/q2cli/click/type.py +++ /dev/null @@ -1,193 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 - from q2cli.core.artifact_cache_global import get_used_artifact_cache - - with get_used_artifact_cache(): - 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 - from q2cli.util import output_in_cache - from qiime2.core.type.util import is_collection_type - # Click path fails to validate writability on new paths - - # Check if our output path is actually in a cache and if it is skip our - # other checks - if output_in_cache(value): - return value - - 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 (directory and not os.path.exists(directory) - and not is_collection_type(param.type.type_expr)): - self.fail('Directory %r does not exist, cannot save %r into it.' - % (directory, os.path.basename(value)), param, ctx) - - 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 - import q2cli.util - - try: - result, error = q2cli.util._load_input(value) - if result is not None: - result_value = result[1] - except Exception as e: - header = f'There was a problem loading {value!r} as an artifact:' - q2cli.util.exit_with_error( - e, header=header, traceback='stderr') - - if error: - self.fail(str(error), param, ctx) - # We want to use click's fail to pretty print whatever error we got - # from get_input - - if isinstance(result_value, 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 rachis 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_value 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_value.type), param, ctx) - return result - - def _convert_metadata(self, value, param, ctx): - import q2cli.util - - if self.type_expr.name == 'MetadataColumn': - value, column = value - - metadata = q2cli.util.load_metadata(value) - - 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 - - try: - return qiime2.sdk.util.parse_primitive(self.type_expr, value) - except ValueError: - expr = qiime2.sdk.util.type_from_ast(self.type_ast) - raise click.BadParameter( - 'received <%s> as an argument, which is incompatible' - ' with parameter type: %r' % (value, expr), - ctx=ctx) - - @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)' diff --git a/q2cli/commands.py b/q2cli/commands.py deleted file mode 100644 index 4c6b47c1..00000000 --- a/q2cli/commands.py +++ /dev/null @@ -1,675 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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.builtin.dev -import q2cli.builtin.info -import q2cli.builtin.tools - -from q2cli.click.command import BaseCommandMixin -from q2cli.core.config import CONFIG - - -class RootCommand(BaseCommandMixin, click.MultiCommand): - """This class defers to either the PluginCommand or the builtin cmds""" - _builtin_commands = { - 'info': q2cli.builtin.info.info, - 'tools': q2cli.builtin.tools.tools, - 'dev': q2cli.builtin.dev.dev - } - - def __init__(self, *args, **kwargs): - import re - import sys - - unicodes = ["\u2018", "\u2019", "\u201C", "\u201D", "\u2014", "\u2013"] - category_regex = re.compile(r'--m-(\S+)-category') - - invalid_chars = [] - categories = [] - for command in sys.argv: - if any(x in command for x in unicodes): - invalid_chars.append(command) - - match = category_regex.fullmatch(command) - if match is not None: - param_name, = match.groups() - # Maps old-style option name to new name. - categories.append((command, '--m-%s-column' % param_name)) - - if invalid_chars or categories: - if invalid_chars: - msg = ("Error: Detected invalid character in: %s\nVerify the " - "correct quotes or dashes (ASCII) are being used." - % ', '.join(invalid_chars)) - click.echo(CONFIG.cfg_style('error', msg), err=True) - if categories: - old_to_new_names = '\n'.join( - 'Instead of %s, trying using %s' % (old, new) - for old, new in categories) - msg = ("Error: The following options no longer exist because " - "metadata *categories* are now called metadata " - "*columns* in rachis.\n\n%s" % old_to_new_names) - click.echo(CONFIG.cfg_style('error', msg), err=True) - sys.exit(-1) - - super().__init__(*args, **kwargs) - - # Plugin state for current deployment that will be loaded from cache. - # Used to construct the dynamic CLI. - self._plugins = None - - @property - def _plugin_lookup(self): - import q2cli.util - - # See note in `q2cli.completion.write_bash_completion_script` for why - # `self._plugins` will not always be obtained from - # `q2cli.cache.CACHE.plugins`. - if self._plugins is None: - import q2cli.core.cache - self._plugins = q2cli.core.cache.CACHE.plugins - - name_map = {} - for name, plugin in self._plugins.items(): - if plugin['actions']: - name_map[q2cli.util.to_cli_name(name)] = plugin - return name_map - - def list_commands(self, ctx): - import itertools - - # Avoid sorting builtin commands as they have a predefined order based - # on applicability to users. For example, it isn't desirable to have - # the `dev` command listed before `info` and `tools`. - builtins = self._builtin_commands - plugins = sorted(self._plugin_lookup) - return itertools.chain(builtins, plugins) - - def get_command(self, ctx, name): - if name in self._builtin_commands: - return self._builtin_commands[name] - - try: - plugin = self._plugin_lookup[name] - except KeyError: - 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.echo( - CONFIG.cfg_style('error', "Error: rachis has no " - "plugin/command named %r." % name + hint), - err=True) - ctx.exit(2) # Match exit code of `return None` - - return PluginCommand(plugin, name) - - -class PluginCommand(BaseCommandMixin, click.MultiCommand): - """Provides ActionCommands based on available Actions""" - def __init__(self, plugin, name, *args, **kwargs): - import q2cli.util - - # the cli currently doesn't differentiate between methods - # and visualizers, it treats them generically as Actions - self._plugin = plugin - self._action_lookup = {} - self._hidden_actions = {} - - # Hide actions that start with _ by default - for id, a in plugin['actions'].items(): - if id.startswith('_'): - self._hidden_actions[q2cli.util.hidden_to_cli_name(id)] = a - else: - self._action_lookup[q2cli.util.to_cli_name(id)] = a - - support = 'Getting user support: %s' % plugin['user_support_text'] - website = 'Plugin website: %s' % plugin['website'] - description = 'Description: %s' % plugin['description'] - help_ = '\n\n'.join([description, website, support]) - - params = [ - click.Option(('--version',), is_flag=True, expose_value=False, - is_eager=True, callback=self._get_version, - help='Show the version and exit.'), - q2cli.util.example_data_option(self._get_plugin), - q2cli.util.citations_option(self._get_citation_records) - ] - - if self._hidden_actions != {}: - params.append( - click.Option( - ('--show-hidden-actions',), is_flag=True, - expose_value=False, is_eager=True, - callback=self._get_hidden_actions, - help="This plugin has hidden actions with names starting " - "with '_'. These are generally called internally by " - "pipelines. Passing this flag will display those " - "actions.")) - - super().__init__(name, *args, short_help=plugin['short_description'], - help=help_, params=params, **kwargs) - - def _get_version(self, ctx, param, value): - if not value or ctx.resilient_parsing: - return - - import q2cli.util - pm = q2cli.util.get_plugin_manager() - for plugin in pm.plugins.values(): - if (self._plugin['name'] == plugin.name): - pkg_name = plugin.project_name - pkg_version = plugin.version - break - else: - pkg_name = pkg_version = "[UNKNOWN]" - - click.echo( - "rachis 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): - import q2cli.util - pm = q2cli.util.get_plugin_manager() - return pm.plugins[self._plugin['name']].citations - - def _get_hidden_actions(self, ctx, param, value): - """Add actions that start with _ back to the lookup""" - # Click calls this whether the flag was provided or not. If the flag - # was not provided value is, unsurprisingly, False. We do not want to - # execute this if the flag was not provided. - # - # Resilient parsing has something to do with ignoring default values - # which for this is False. Honestly not 100% sure why we need that - # here, but it is in the check in _get_version, and I a mimicking that - if not value or ctx.resilient_parsing: - return - - from click.utils import echo - - self._action_lookup.update(self._hidden_actions) - - # Handle the printing and exiting here. This feels like a pretty - # serious misuse of click, but it probably isn't the most egregious in - # the cli - echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - def _get_plugin(self): - import q2cli.util - pm = q2cli.util.get_plugin_manager() - return pm.plugins[self._plugin['name']] - - def list_commands(self, ctx): - return sorted(self._action_lookup) - - def get_command(self, ctx, name): - try: - # Hidden actions are still valid commands - self._action_lookup.update(self._hidden_actions) - action = self._action_lookup[name] - except KeyError: - 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.echo( - CONFIG.cfg_style('error', "Error: rachis plugin %r has no " - "action %r." % (self._plugin['name'], name) + - hint), err=True) - ctx.exit(2) # Match exit code of `return None` - - return ActionCommand(name, self._plugin, action) - - -class ActionCommand(BaseCommandMixin, click.Command): - """A click manifestation of a rachis API Action (Method/Visualizer) - - """ - def __init__(self, name, plugin, action): - import q2cli.util - import q2cli.click.type - - self.plugin = plugin - self.action = action - - 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).') - ] - - # If this action is a pipeline it needs additional options for - # recycling and parallelization - if action['type'] == 'pipeline': - self._misc.extend([ - click.Option(['--recycle-pool'], required=False, - type=str, - help='Use a cache pool for pipeline resumption. ' - 'rachis will cache your results in this ' - 'pool for reuse by future invocations. ' - 'These pool are retained until deleted by ' - 'the user. If not provided, rachis will ' - 'create a pool which is automatically ' - 'reused by invocations of the same action ' - 'and removed if the action is successful. ' - 'Note: these pools are local to the ' - 'cache you are using.'), - click.Option(['--no-recycle'], is_flag=True, required=False, - help='Do not recycle results from a previous ' - 'failed pipeline run or save the results ' - 'from this run for future recycling.'), - click.Option(['--parallel'], is_flag=True, required=False, - help='Execute your action in parallel. This flag ' - 'will use your default parallel config.'), - click.Option(['--parallel-config'], required=False, - type=click.Path(exists=True, dir_okay=False), - help='Execute your action in parallel using a ' - 'config at the indicated path.')]) - - # TODO: update default cache name from qiime2 to rachis - self._misc.extend([ - q2cli.util.example_data_option( - self._get_plugin, self.action['id']), - q2cli.util.citations_option(self._get_citation_records), - click.Option(['--use-cache'], required=False, - type=click.Path(exists=True, file_okay=False), - help='Specify the cache to be used for the ' - 'intermediate work of this action. If not ' - 'provided, the default cache under ' - '$TMP/qiime2/ will be used. ' - 'IMPORTANT FOR HPC USERS: If you are on an HPC ' - 'system and are using parallel execution it is ' - 'important to set this to a location that is ' - 'globally accessible to all nodes in the ' - 'cluster.')]) - - options = [*self._inputs, *self._params, *self._outputs, *self._misc] - help_ = [action['description']] - - if self.action['deprecated']: - help_.append(CONFIG.cfg_style( - 'warning', 'WARNING:\n\nThis command is deprecated and will ' - 'be removed in a future version of this plugin.')) - elif self.action['migrated']: - mig_dict = self.action['migrated'] - mig_help = self._format_migration_help( - q2cli.util.to_cli_name(self.plugin['name']), mig_dict - ) - help_.append(CONFIG.cfg_style('warning', mig_help)) - - super().__init__(name, params=options, callback=self, - short_help=action['name'], help='\n\n'.join(help_)) - - def _format_migration_help(self, plugin, mig_dict): - # required - to_plugin = mig_dict['to_plugin'] - # optional - from_distro = mig_dict.get('from_distro') - to_distro = mig_dict.get('to_distro') - epoch = mig_dict.get('epoch') - - base_msg = f'is slated for migration from the {plugin} plugin' - destination = f'to the {to_plugin} plugin' - - if from_distro: - base_msg += f' of the {from_distro} distribution' - - if to_distro: - destination += f' of the {to_distro} distribution' - - when = f'in {epoch}' if epoch else 'in a future release' - - return ( - 'WARNING:\n\n' - f'This command {base_msg} {destination} {when}.' - ) - - def _build_generated_options(self): - import q2cli.click.option - - inputs = [] - params = [] - outputs = [] - - for item in self.action['signature']: - item = item.copy() - type = item.pop('type') - - if type == 'input': - storage = inputs - elif type == 'parameter': - storage = params - else: - storage = outputs - - opt = q2cli.click.option.GeneratedOption(prefix=type[0], **item) - storage.append(opt) - - return inputs, params, outputs - - 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): - # Get plugin level citations - citations = list(self._get_plugin().citations) - # Add action level citations - citations.extend(self._get_action().citations) - return tuple(citations) - - def _get_plugin(self): - import q2cli.util - pm = q2cli.util.get_plugin_manager() - return pm.plugins[self.plugin['name']] - - def _get_action(self): - plugin = self._get_plugin() - return plugin.actions[self.action['id']] - - def __call__(self, **kwargs): - """Called when user hits return, **kwargs are Dict[click_names, Obj]""" - import os - import click - from contextlib import nullcontext - - import qiime2.util - from qiime2.core.cache import Cache - from qiime2.sdk import ResultCollection - - from q2cli.util import ( - output_in_cache, _get_cache_path_and_key, get_default_recycle_pool, - capture_rachis_warnings - ) - from q2cli.core.artifact_cache_global import ( - get_used_artifact_cache, unset_used_artifact_cache) - - cache = get_used_artifact_cache() - - output_dir = kwargs.pop('output_dir') - # If they gave us a cache and key combo as an output dir, we want to - # error out, so we check if their output dir contains a : and the part - # before it is a cache - if output_dir: - potential_cache = output_dir.rsplit(':', 1)[0] - if potential_cache and os.path.exists(potential_cache) and \ - Cache.is_cache(potential_cache): - exc = ValueError(f"The given output dir '{output_dir}' " - "appears to be a cache:key combo. Cache keys " - "cannot be used as output dirs.") - q2cli.util.exit_with_error(exc) - - # Args pertaining to pipeline resumption - recycle_pool = kwargs.pop('recycle_pool', None) - no_recycle = kwargs.pop('no_recycle', False) - - if recycle_pool is not None and no_recycle: - exc = ValueError('Cannot set a pool to be used for recycling and ' - 'no recycle simultaneously.') - q2cli.util.exit_with_error(exc) - - # We already dealt with this kwarg, we needed to do it earlier to make - # sure we used the desired cache for all operations. Look for the call - # site for set_used_artifact_cache - kwargs.pop('use_cache') - - parallel = kwargs.pop('parallel', False) - parallel_config_fp = kwargs.pop('parallel_config', None) - - if parallel_config_fp is not None: - parallel = True - - 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 - # Make sure our inputs are backed by the cache we are using. This - # is necessary for HPCs where our input .qzas may be in a location - # that is not globally accessible to the cluster. The user should - # be using a cache that is in a globally accessible location. We - # need to ensure we put our artifacts in that cache. - elif prefix == 'i': - value_ = value - - if isinstance(value, list): - value_ = [cache.process_pool.save(v) - for v in value] - elif isinstance(value, dict) or \ - isinstance(value, ResultCollection): - value_ = { - k: cache.process_pool.save(v) - for k, v in value.items()} - elif isinstance(value, set): - value_ = set([cache.process_pool.save(v) - for v in value]) - elif value is not None: - value_ = cache.process_pool.save(value) - - arguments[key] = value_ - else: - arguments[key] = value - - outputs = self._order_outputs(init_outputs) - action = self._get_action() - - # If --no-recycle is not set, pipelines attempt to recycle their - # outputs from a pool by default allowing recovery of failed pipelines - # from point of failure without needing to restart the pipeline from - # the beginning - default_pool = get_default_recycle_pool( - f'{action.plugin_id}_{action.id}') - if not no_recycle and action.type == 'pipeline' and \ - recycle_pool is None: - # We implicitly use a pool named - # recycle___ if no pool is - # provided - recycle_pool = default_pool - - if recycle_pool is not None and recycle_pool != default_pool and \ - recycle_pool not in cache.get_pools(): - msg = ("The pool '%s' does not exist on the cache at '%s'. It " - "will be created." % - (recycle_pool, cache.path)) - click.echo(CONFIG.cfg_style('warning', msg)) - - # `qiime2.util.redirected_stdio` defaults to stdout/stderr when - # supplied `None`. - log = None - - if not verbose: - import tempfile - log = tempfile.NamedTemporaryFile(prefix='rachis-cli-err-', - suffix='.log', - delete=False, mode='w') - if action.deprecated: - # We don't need to worry about redirecting this, since it should a) - # always be shown to the user and b) the framework-originated - # FutureWarning will wind up in the log file in quiet mode. - - msg = ('Plugin warning from %s:\n\n%s is deprecated and ' - 'will be removed in a future version of this plugin.' % - (q2cli.util.to_cli_name(self.plugin['name']), self.name)) - click.echo(CONFIG.cfg_style('warning', msg)) - - if action.migrated: - msg = ('Plugin warning from %s:\n\n%s' % ( - q2cli.util.to_cli_name(self.plugin['name']), - action._build_migration_message())) - click.echo(CONFIG.cfg_style('warning', msg)) - - cleanup_logfile = False - try: - with ( - capture_rachis_warnings() if not quiet else nullcontext(), - qiime2.util.redirected_stdio(stdout=log, stderr=log) - ): - if parallel: - from qiime2.sdk.parallel_config import \ - (load_config_from_file, ParallelConfig) - - action = action.parallel - if parallel_config_fp is None: - parallel_config = ParallelConfig() - else: - config, mapping = \ - load_config_from_file(parallel_config_fp) - parallel_config = ParallelConfig(config, mapping) - - with parallel_config: - results = self._execute_action( - action, arguments, cache, recycle_pool) - else: - results = self._execute_action( - action, arguments, cache, recycle_pool) - except Exception as e: - header = ('Plugin error from %s:' - % q2cli.util.to_cli_name(self.plugin['name'])) - if verbose: - # log is not a file - log = 'stderr' - q2cli.util.exit_with_error(e, header=header, traceback=log) - else: - cleanup_logfile = True - finally: - # OS X will reap temporary files that haven't been touched in - # 36 hours, double check that the log is still on the filesystem - # before trying to delete. Otherwise this will fail and the - # output won't be written. - if log and cleanup_logfile and os.path.exists(log.name): - log.close() - os.remove(log.name) - - if output_dir is not None: - os.makedirs(output_dir) - - for result, output in zip(results, outputs): - if isinstance(output, tuple) and len(output) == 1: - output = output[0] - - if output_in_cache(output) and output_dir is None: - cache_path, key = _get_cache_path_and_key(output) - output_cache = Cache(cache_path) - - if isinstance(result, ResultCollection): - output_cache.save_collection(result, key) - path = output - else: - output_cache.save(result, key) - path = output - else: - path = result.save(output) - - if not quiet: - if output_in_cache(output): - message = \ - f"Added {result.type} to cache: {cache_path} as: {key}" - else: - type = f'Collection[{list(result.values())[0].type}]' if \ - isinstance(result, ResultCollection) else result.type - message = f"Saved {type} to: {path}" - - click.echo(CONFIG.cfg_style('success', message)) - - # If we used a default recycle pool for a pipeline and the pipeline - # succeeded, then we need to clean up the pool. Make sure to do this at - # the very end so if a failure happens during writing results we still - # have them - # - # It is possible that another run of the same action already finished - # and removed the pool we were using in which case we do not want to - # attempt to delete it again and get an error - with cache.lock: - if recycle_pool == default_pool \ - and os.path.exists(cache.pools / recycle_pool): - cache.remove(recycle_pool) - - # Set the USED_ARTIFACT_CACHE back to the default cache. This is mostly - # useful for the tests that invoke actions back to back to back without - # exiting the process - unset_used_artifact_cache() - - def _execute_action(self, action, arguments, cache, recycle_pool=None): - with cache: - if recycle_pool is None: - results = action(**arguments) - results = results._result() - else: - pool = cache.create_pool( - key=recycle_pool, reuse=True) - with pool: - results = action(**arguments) - - # If we executed in a pool using parsl we need to get - # our results inside of the context manager to ensure - # that the pool is set for the entirety of the - # execution - results = results._result() - - return results - - def _order_outputs(self, outputs): - ordered = [] - for item in self.action['signature']: - if item['type'] == 'output': - ordered.append(outputs[item['name']]) - return ordered - - def format_epilog(self, ctx, formatter): - if self.action['epilog']: - with formatter.section(click.style('Examples', bold=True)): - for line in self.action['epilog']: - formatter.write(' ' * formatter.current_indent) - formatter.write(line) - formatter.write('\n') diff --git a/q2cli/core/__init__.py b/q2cli/core/__init__.py deleted file mode 100644 index 5a85cefd..00000000 --- a/q2cli/core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- diff --git a/q2cli/core/artifact_cache_global.py b/q2cli/core/artifact_cache_global.py deleted file mode 100644 index 8044fb87..00000000 --- a/q2cli/core/artifact_cache_global.py +++ /dev/null @@ -1,81 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- - -# Do not make this the default cache. If this is the default cache then we will -# instantiate the default cache when the module is imported which will write a -# process pool to the default cache which is undesirable if that isn't the -# cache we will be using for this action -_USED_ARTIFACT_CACHE = None - - -def set_used_artifact_cache(args): - """Validates that the args passed in actually contain a valid path to an - existing cache following the --use-cache argument and if so sets that cache - as the used cache for this invocation. - - Parameters - ---------- - args : List[str] - The arguments provided on the cli to this rachis invocation/ - - NOTES - ----- - Should only be called if --use-cache is already known to be in the args - provided. - - Should only be called once to init the used cache for this invocation. - """ - from qiime2.core.cache import Cache - from q2cli.util import exit_with_error - - global _USED_ARTIFACT_CACHE - - use_cache_idx = args.index('--use-cache') - - # They need to provide some kind of arg to use_cache - if len(args) < use_cache_idx + 2: - exc = ValueError('--use-cache expected an argument but none was ' - 'provided.') - exit_with_error(exc) - - cache_path = args[use_cache_idx + 1] - - # The arg given should be a path that points to an existing cache - if not Cache.is_cache(cache_path): - exc = ValueError('--use-cache expected a path to an existing cache as ' - f"an argument but received '{cache_path}' which is " - 'not a path to an existing cache.') - exit_with_error(exc) - - _USED_ARTIFACT_CACHE = Cache(cache_path) - - -def unset_used_artifact_cache(): - """Set the USED_ARTIFACT_CACHE back to None. - """ - global _USED_ARTIFACT_CACHE - - _USED_ARTIFACT_CACHE = None - - -def get_used_artifact_cache(): - """If the used cache has been set then return it otherwise return the - default cache. We use this getter because we don't want to instantiate the - default cache unless that is the cache we are using. This is because if we - instantiate the default cache we will put a process pool in it, and we want - to avoid that unless necessary. - - Returns - ------- - Cache - The default cache if the user didn't set a cache or the cache they set - if they did set one. - """ - from qiime2.core.cache import Cache - - return Cache() if _USED_ARTIFACT_CACHE is None else _USED_ARTIFACT_CACHE diff --git a/q2cli/core/assets/cli_howto.txt b/q2cli/core/assets/cli_howto.txt deleted file mode 100644 index 17849d92..00000000 --- a/q2cli/core/assets/cli_howto.txt +++ /dev/null @@ -1,28 +0,0 @@ - -# Instructions for use: -# 1. Open this script in a text editor or IDE. Support for BASH -# syntax highlighting can be helpful. -# 2. Search or scan visually for '<' or '>' characters to find places where -# user input (e.g. a filepath or column name) is required. These must be -# replaced with your own values. E.g. -> 'patient_id'. -# Failure to remove '<' or '>' may result in `No such File ...` errors -# 3. Search for 'FIXME' comments in the script, and respond as directed. -# 4. Remove all 'FIXME' comments from the script completely. Failure to do so -# may result in 'Missing Option' errors -# 5. Adjust the arguments to the commands below to suit your data and metadata. -# If your data is not identical to that in the replayed analysis, -# changes may be required. (e.g. sample ids or rarefaction depth) -# 6. Optional: replace any filenames in this script that begin with 'XX' with -# unique file names to ensure they are preserved. rachis saves all outputs -# from all actions in this script to disk regardless of whether those -# outputs were in the original collection of replayed results. The filenames -# of "un-replayed" artifacts are prefixed with 'XX' so they may be easily -# located. These names are not guaranteed to be unique, so 'XX_table.qza' -# may be overwritten by another 'XX_table.qza' later in the script. -# 7. Activate your replay conda environment, and confirm you have installed all -# plugins used by the script. -# 8. Run this script with `bash `, or copy-paste commands -# into the terminal for a more interactive analysis. -# 9. Optional: to delete all results not required to produce the figures and -# data used to generate this script, navigate to the directory in which you -# ran the script and `rm XX*.qz*` diff --git a/q2cli/core/assets/copyright_note.txt b/q2cli/core/assets/copyright_note.txt deleted file mode 100644 index 603cdd23..00000000 --- a/q2cli/core/assets/copyright_note.txt +++ /dev/null @@ -1,4 +0,0 @@ -# This document is a representation of the scholarly work of the creator of the -# rachis Results provided as input to this software, and may be protected by -# intellectual property law. Please respect all copyright restrictions and -# licenses governing the use, modification, and redistribution of this work. diff --git a/q2cli/core/cache.py b/q2cli/core/cache.py deleted file mode 100644 index a119501d..00000000 --- a/q2cli/core/cache.py +++ /dev/null @@ -1,279 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- - - -class DeploymentCache: - """Cached CLI state for a rachis deployment. - - In this context, a rachis deployment is the set of installed Python - packages, including their exact versions, that register one or more rachis - plugins. The exact version of rachis-cli is also included in the - deployment. - - The deployment cache stores the current deployment's package names and - versions in a requirements.txt file under the cache directory. This file is - used to determine if the cache is outdated. If the cache is determined to - be outdated, it will be refreshed based on the current deployment state. - Thus, adding, removing, upgrading, or downgrading a plugin package or - rachis-cli itself will trigger a cache refresh. - - Two mechanisms are provided to force a cache refresh. Setting the - environment variable Q2CLIDEV to any value will cause the cache to be - refreshed upon instantiation. Calling `.refresh()` will also refresh the - cache. Forced refreshing of the cache is useful for plugin and/or q2cli - developers who want their changes to take effect in the CLI without - changing their package versions. - - Cached CLI state is stored in a state.json file under the cache directory. - It is not a public file format and it is not versioned. rachis-cli is - included as part of the rachis deployment so that the cached state can - always be read (or recreated as necessary) by the currently installed - version of rachis-cli. - - This class is intended to be a singleton because it is responsible for - managing the on-disk cache. Having more than one instance managing the - cache has the possibility of two instances clobbering the cache (e.g. in a - multithreaded/multiprocessing situation). Also, having a single instance - improves performance by only reading and/or refreshing the cache a - single time during its lifetime. Having two instances could, for example, - trigger two cache refreshes if Q2CLIDEV is set. To support these use-cases, - a module-level `CACHE` variable stores a single instance of this class. - - """ - - # Public API - - def __init__(self): - import os - - # Indicates if the cache has been refreshed. For performance purposes, - # the cache is only refreshed a single time (at maximum) during the - # object's lifetime. Thus, "hot reloading" isn't supported, but this - # shouldn't be necessary for the CLI. - self._refreshed = False - - self._cache_dir = self._get_cache_dir() - - # TODO: update Q2CLIDEV to RACHISCLIDEV - refresh = 'Q2CLIDEV' in os.environ - self._state = self._get_cached_state(refresh=refresh) - - @property - def plugins(self): - """Decoded JSON object representing CLI state on a per-plugin basis.""" - return self._state['plugins'] - - def refresh(self): - """Trigger a forced refresh of the cache. - - If the cache has already been refreshed (either by this method or at - some point during instantiation), this method is a no-op. - - """ - if not self._refreshed: - self._state = self._get_cached_state(refresh=True) - - # Private API - - def _get_cache_dir(self): - import os - import q2cli.util - - cache_dir = q2cli.util.get_cache_dir() - os.makedirs(cache_dir, exist_ok=True) - return cache_dir - - def _get_cached_state(self, refresh): - import json - import os.path - import q2cli.util - - current_requirements = self._get_current_requirements() - state_path = os.path.join(self._cache_dir, 'state.json') - # See note on `get_completion_path` for why knowledge of this path - # exists in `q2cli.util` and not in this class. - completion_path = q2cli.util.get_completion_path() - - # The cache must be refreshed in the following cases: - - # 1) We have been explicitly told to refresh. - if refresh: - self._cache_current_state(current_requirements) - # 2) The current deployment requirements are different than the cached - # requirements. - elif current_requirements != self._get_cached_requirements(): - self._cache_current_state(current_requirements) - # 3) The cached state file does not exist. - elif not os.path.exists(state_path): - self._cache_current_state(current_requirements) - # 4) The cached bash completion script does not exist. - elif not os.path.exists(completion_path): - self._cache_current_state(current_requirements) - - def decoder(obj): - if obj.get('__q2type__', None) == 'set': - return set(obj['value']) - return obj - - # Now that the cache is up-to-date, read it. - try: - with open(state_path, 'r') as fh: - return json.load(fh, object_hook=decoder) - except json.JSONDecodeError: - # 5) The cached state file can't be read as JSON. - self._cache_current_state(current_requirements) - with open(state_path, 'r') as fh: - return json.load(fh, object_hook=decoder) - - # NOTE: The private methods below are all used internally within - # `_get_cached_state`. - - def _get_current_requirements(self): - """Includes installed versions of q2cli and rachis plugins.""" - import os - import importlib.metadata - import q2cli - - reqs = {f'q2cli=={q2cli.__version__}'} - - # A distribution (i.e. Python package) can have multiple plugins, where - # each plugin is its own entry point. A distribution's `Requirement` is - # hashable, and the `set` is used to exclude duplicates. Thus, we only - # gather the set of requirements for all installed Python packages - # containing one or more plugins. It is not necessary to track - # individual plugin names and versions in order to determine if the - # cache is outdated. - for entry_point in importlib.metadata.entry_points( - group='qiime2.plugins'): - if 'QIIMETEST' in os.environ: - if entry_point.name in ('dummy-plugin', 'other-plugin'): - reqs.add(f'{entry_point.name}=={entry_point.dist.version}') - else: - if entry_point.name not in ('dummy-plugin', 'other-plugin'): - reqs.add(f'{entry_point.name}=={entry_point.dist.version}') - - return reqs - - def _get_cached_requirements(self): - import os.path - - path = os.path.join(self._cache_dir, 'requirements.txt') - - if not os.path.exists(path): - # No cached requirements. The empty set will always trigger a cache - # refresh because the current requirements will, at minimum, - # contain q2cli. - return set() - else: - with open(path, 'r') as fh: - contents = fh.read() - try: - # Each line in the file is a different dep - deps = set(contents.split('\n')) - if '' in deps: - # Pop off the empty newline at the bottom of the file - deps.remove('') - return deps - except Exception: - # Unreadable cached requirements, trigger a cache refresh. - return set() - - def _cache_current_state(self, requirements): - import json - import os.path - import click - import q2cli.core.completion - import q2cli.util - - click.secho( - "rachis is caching your current deployment for improved " - "performance. This may take a few moments and should only happen " - "once per deployment.", fg='yellow', err=True) - - cache_dir = self._cache_dir - state = self._get_current_state() - - path = os.path.join(cache_dir, 'state.json') - - class Q2JSONEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, set): - return { - '__q2type__': 'set', - 'value': list(obj), - } - return super().default(obj) - - with open(path, 'w') as fh: - json.dump(state, fh, cls=Q2JSONEncoder) - - 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 - # (e.g. a plugin can't be loaded in `_get_current_state`). If any part - # of the cache writing fails, it needs to be refreshed the next time - # the cache is accessed. The absence of a requirements file will - # trigger this cache refresh, avoiding this bug: - # https://github.com/qiime2/q2cli/issues/88 - path = os.path.join(cache_dir, 'requirements.txt') - with open(path, 'w') as fh: - for req in requirements: - # `str(Requirement)` is the recommended way to format a - # `Requirement` that can be read with `Requirement.parse`. - fh.write(str(req)) - fh.write('\n') - - self._refreshed = True - - def _get_current_state(self): - """Get current CLI state as an object that is serializable as JSON. - - WARNING: This method is very slow and should only be called when the - cache needs to be refreshed. - - """ - import q2cli.util - - state = { - 'plugins': {} - } - - plugin_manager = q2cli.util.get_plugin_manager() - for name, plugin in plugin_manager.plugins.items(): - state['plugins'][name] = self._get_plugin_state(plugin) - - return state - - def _get_plugin_state(self, plugin): - import q2cli.core.state - - state = q2cli.core.state.get_plugin_state(plugin) - for id, action in plugin.actions.items(): - state['actions'][id]['epilog'] = self._get_action_epilog(action) - - return state - - def _get_action_epilog(self, action): - import q2cli.core.usage - - lines = [] - for name, example in action.examples.items(): - use = q2cli.core.usage.CLIUsage() - - use.comment('### example: %s\n' % (name.replace('_', ' '),)) - example(use) - use.recorder.append('') - - lines += use.recorder - - return lines - - -# Singleton. Import and use this instance as necessary. -CACHE = DeploymentCache() diff --git a/q2cli/core/completion.py b/q2cli/core/completion.py deleted file mode 100644 index 7d68277e..00000000 --- a/q2cli/core/completion.py +++ /dev/null @@ -1,169 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- - - -# NOTE: This module is closely coupled with `q2cli.cache`. It is a separate -# module to avoid cluttering `q2cli.cache` with completion script code, and -# provides a place to support other shell completion scripts in the future -# (e.g. zsh). - -def write_bash_completion_script(plugins, path): - """ - - Parameters - ---------- - plugins : dict - Decoded JSON object representing CLI state on a per-plugin basis (e.g. - as returned by `DeploymentCache.plugins`). See note within this - function for why this parameter is necessary. - path : str - Path to write completion script to. - - """ - import os - import os.path - import stat - import textwrap - from q2cli.__main__ import qiime as root - - # `write_bash_completion_script` is called by `q2cli.cache.DeploymentCache` - # when it is refreshing its cache. `q2cli.commands.RootCommand` could have - # already asked for the cache, for example, if the user ran a command and - # the cache must be refreshed. The bash completion script is generated by - # traversing the `RootCommand` tree, so there is a cycle when `RootCommand` - # attempts to access the cache in order to build itself. We work around - # this by bootstrapping the `RootCommand`'s `._plugins` attribute with the - # plugin state that has already been loaded by `DeploymentCache`. - root._plugins = plugins - - cmd_reply = _generate_command_reply(root) - cmd_reply = textwrap.indent(cmd_reply, ' ') - completion_script = COMPLETION_SCRIPT_TEMPLATE.format(cmd_reply=cmd_reply) - - with open(path, 'w') as fh: - fh.write(completion_script) - - # Make bash completion script executable: - # http://stackoverflow.com/a/12792002/3776794 - st = os.stat(path) - # Set executable bit for user,group,other for root/sudo installs - os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - -def _generate_command_reply(cmd): - """Recursively generate completion reply for this command and subcommands. - - Parameters - ---------- - cmd : click.Command - Command to generate completion replies for (including its subcommands). - - """ - import textwrap - import click - - ctx = None - - options = ['--help'] - for param in cmd.params: - 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): - subcmd_names.extend(cmd.list_commands(ctx)) - - subcmd_cases = [] - for subcmd_name in subcmd_names: - subcmd_reply = _generate_command_reply( - cmd.get_command(ctx, subcmd_name)) - subcmd_reply = textwrap.indent(subcmd_reply, ' ') - - case = SUBCOMMAND_CASE_TEMPLATE.format( - subcmd_name=subcmd_name, subcmd_reply=subcmd_reply) - subcmd_cases.append(case) - - subcmd_cases = textwrap.indent('\n'.join(subcmd_cases), ' ' * 6) - - cmd_reply = COMMAND_REPLY_TEMPLATE.format( - options=' '.join(options), subcmd_names=' '.join(subcmd_names), - subcmd_cases=subcmd_cases) - - return cmd_reply - - -# NOTE: using double braces to avoid `str.format` interpolation when bash needs -# curly braces in the generated code. -# -# NOTE: the handling of a negative COMP_CWORD is necessary in certain versions -# of bash (e.g. at least the bash shipped with OS X 10.9.5). When adding -# whitespace to the end of a command, and then moving the cursor backwards in -# the command and hitting , COMP_CWORD can be negative (I've only seen -2 -# as its value). This is a bash bug and is not documented behavior. Other CLIs -# with tab completion suffer from the same issue, and each one deals with this -# bug differently (some not at all, e.g. `git`). The workaround used below -# seems to provide the least destructive completion behavior for our CLI. -# -# Bug report reference: -# https://lists.gnu.org/archive/html/bug-bash/2009-07/msg00108.html - -COMPLETION_SCRIPT_TEMPLATE = """\ -#!/usr/bin/env bash - -_rachis_completion() -{{ - local COMP_WORDS=(${{COMP_WORDS[*]}}) - local incomplete - if [[ ${{COMP_CWORD}} -lt 0 ]] ; then - COMP_CWORD="${{#COMP_WORDS[*]}}" - incomplete="" - else - incomplete="${{COMP_WORDS[COMP_CWORD]}}" - fi - - local curpos nextpos nextword - nextpos=0 - -{cmd_reply} - - return 0 -}} - -_rachis_completion -""" - -COMMAND_REPLY_TEMPLATE = """\ -curpos=${{nextpos}} -while : -do - nextpos=$((curpos + 1)) - nextword="${{COMP_WORDS[nextpos]}}" - if [[ ${{nextpos}} -eq ${{COMP_CWORD}} ]] ; then - if [[ ${{incomplete}} == -* ]] ; then - echo "$(compgen -W "{options}" -- $incomplete)" - else - echo "$(compgen -W "{subcmd_names}" -- $incomplete)" - fi - return 0 - else - case "${{nextword}}" in -{subcmd_cases} - esac - curpos=${{nextpos}} - fi -done\ -""" - -SUBCOMMAND_CASE_TEMPLATE = """\ -{subcmd_name}) -{subcmd_reply} - ;; -""" diff --git a/q2cli/core/config.py b/q2cli/core/config.py deleted file mode 100644 index ae268f78..00000000 --- a/q2cli/core/config.py +++ /dev/null @@ -1,120 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os -import configparser - -import click - -import q2cli.util - - -class CLIConfig(): - path = os.path.join(q2cli.util.get_app_dir(), 'cli-colors.theme') - VALID_SELECTORS = frozenset( - ['option', 'type', 'default_arg', 'command', 'emphasis', 'problem', - 'warning', 'error', 'required', 'success']) - VALID_STYLINGS = frozenset( - ['fg', 'bg', 'bold', 'dim', 'underline', 'blink', 'reverse']) - VALID_COLORS = frozenset( - ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', - 'bright_black', 'bright_red', 'bright_green', 'bright_yellow', - 'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white']) - VALID_BOOLEANS = {'true': True, - 'false': False, - 't': True, - 'f': False} - - def __init__(self): - if os.path.exists(self.path): - self.styles = self.get_editable_styles() - try: - self.parse_file(self.path) - except Exception as e: - # Let's just be safe and make no attempt to use CONFIG to - # format this text if the CONFIG is broken - click.secho( - "We encountered the following error when parsing your " - f"theme:\n\n{str(e)}\n\nIf you want to use a custom " - "theme, please either import a new theme, or reset your " - "current theme. If you encountered this message while " - "importing a new theme or resetting your current theme, " - "ignore it.", - fg='red') - self.styles = self.get_default_styles() - else: - self.styles = self.get_default_styles() - - def get_default_styles(self): - return {'option': {'fg': 'bright_blue'}, - 'type': {'fg': 'green'}, - 'default_arg': {'fg': 'magenta'}, - 'command': {'fg': 'bright_blue'}, - 'emphasis': {'underline': True}, - 'problem': {'fg': 'yellow'}, - 'warning': {'fg': 'yellow', 'bold': True}, - 'error': {'fg': 'red', 'bold': True}, - 'required': {'underline': True}, - 'success': {'fg': 'green'}} - - # This maintains the default colors while getting rid of all the default - # styling modifiers so what the user puts in their file is all they'll see - def get_editable_styles(self): - return {'option': {}, - 'type': {}, - 'default_arg': {}, - 'command': {}, - 'emphasis': {}, - 'problem': {}, - 'warning': {}, - 'error': {}, - 'required': {}, - 'success': {}} - - def _build_error(self, current, valid_list, valid_string): - valids = ', '.join(valid_list) - raise configparser.Error(f'{current!r} is not a {valid_string}. The ' - f'{valid_string}s are:\n{valids}') - - def parse_file(self, fp): - if os.path.exists(fp): - parser = configparser.ConfigParser() - parser.read(fp) - for selector_user in parser.sections(): - selector = selector_user.lower() - if selector not in self.VALID_SELECTORS: - self._build_error(selector_user, self.VALID_SELECTORS, - 'valid selector') - for styling_user in parser[selector]: - styling = styling_user.lower() - if styling not in self.VALID_STYLINGS: - self._build_error(styling_user, self.VALID_STYLINGS, - 'valid styling') - val_user = parser[selector][styling] - val = val_user.lower() - if styling == 'fg' or styling == 'bg': - if val not in self.VALID_COLORS: - self._build_error(val_user, self.VALID_COLORS, - 'valid color') - else: - if val not in self.VALID_BOOLEANS: - self._build_error(val_user, self.VALID_BOOLEANS, - 'valid boolean') - val = self.VALID_BOOLEANS[val] - self.styles[selector][styling] = val - else: - raise configparser.Error(f'{fp!r} is not a valid filepath.') - - def cfg_style(self, selector, text, required=False): - kwargs = self.styles[selector] - if required: - kwargs = {**self.styles[selector], **self.styles['required']} - return click.style(text, **kwargs) - - -CONFIG = CLIConfig() diff --git a/q2cli/core/state.py b/q2cli/core/state.py deleted file mode 100644 index 1d3ce727..00000000 --- a/q2cli/core/state.py +++ /dev/null @@ -1,182 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- - - -def get_plugin_state(plugin): - state = { - # TODO this conversion also happens in the framework - # (qiime2/plugins.py) to generate an importable module name from a - # plugin's `.name` attribute. Centralize this knowledge in the - # framework, ideally as a machine-friendly plugin ID (similar to - # `Action.id`). - 'id': plugin.name.replace('-', '_'), - 'name': plugin.name, - 'version': plugin.version, - 'website': plugin.website, - 'user_support_text': plugin.user_support_text, - 'description': plugin.description, - 'short_description': plugin.short_description, - 'actions': {} - } - - for id, action in plugin.actions.items(): - state['actions'][id] = get_action_state(action) - - return state - - -def get_action_state(action): - import itertools - - state = { - 'id': action.id, - 'name': action.name, - 'type': action.type, - 'description': action.description, - 'signature': [], - 'epilog': [], - 'deprecated': action.deprecated, - 'migrated': dict(action.migrated) if action.migrated else False, - } - - sig = action.signature - for name, spec in itertools.chain(sig.signature_order.items(), - sig.outputs.items()): - data = {'name': name, 'repr': _get_type_repr(spec.qiime_type), - 'ast': spec.qiime_type.to_ast()} - - if name in sig.inputs: - type = 'input' - elif name in sig.parameters: - type = 'parameter' - else: - type = 'output' - data['type'] = type - - if spec.has_description(): - data['description'] = spec.description - if spec.has_default(): - data['default'] = spec.default - - data['metavar'] = _get_metavar(spec.qiime_type) - data['multiple'], data['is_bool_flag'], data['metadata'] = \ - _special_option_flags(spec.qiime_type) - - state['signature'].append(data) - - return state - - -def _special_option_flags(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 _is_bool(type): - is_bool_flag = True - - return multiple, is_bool_flag, metadata - - -def _is_bool(type_expr): - return all(t.name == 'Bool' for t in type_expr) \ - and not type_expr.is_bottom() - - -def _get_type_repr(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) and \ - not qiime2.sdk.util.is_union(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(type): - import qiime2.sdk.util - - name_to_var = { - 'Visualization': 'VISUALIZATION', - 'Int': 'INTEGER', - 'Str': 'TEXT', - 'Float': 'NUMBER', - 'Bool': '', - 'Jobs': 'NJOBS', - 'Threads': 'NTHREADS', - } - - 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' - elif _is_bool(type): - metavar = '' - elif qiime2.sdk.util.is_union(type): - 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 diff --git a/q2cli/core/usage.py b/q2cli/core/usage.py deleted file mode 100644 index b9c18df1..00000000 --- a/q2cli/core/usage.py +++ /dev/null @@ -1,818 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 -import os -import importlib.resources -import re -import shlex -import textwrap -from typing import Any, Callable, Dict, List, Tuple, Type - -from qiime2 import ResultCollection -import qiime2.sdk.usage as usage -from qiime2.sdk.usage import ( - UsageVariable, Usage, UsageInputs, UsageOutputs -) -from qiime2.core.archive.provenance_lib.usage_drivers import ( - build_header, build_footer, ReplayUsageAction -) -from qiime2.core.archive.provenance_lib import ProvDAG - -import q2cli.util as util -from q2cli.core.state import get_action_state -import q2cli.click.option - - -def write_example_data(action, output_dir): - for example_name, example in action.examples.items(): - cli_name = util.to_cli_name(example_name) - example_path = os.path.join(output_dir, cli_name) - - use = CLIUsage() - example(use) - - for fn, val in use.get_example_data(): - os.makedirs(example_path, exist_ok=True) - path = os.path.join(example_path, fn) - val.save(path) - try: - hint = repr(val.type) - except AttributeError: - hint = 'Metadata' - - yield hint, path - - -def write_plugin_example_data(plugin, output_dir): - for name, action in plugin.actions.items(): - path = os.path.join(output_dir, util.to_cli_name(name)) - - yield from write_example_data(action, path) - - -class CLIUsageVariable(usage.UsageVariable): - EXT = { - 'artifact': '.qza', - 'artifact_collection': '/', - 'visualization_collection': '/', - 'visualization': '.qzv', - 'metadata': '.tsv', - 'column': '', - 'format': '', - } - - ELEMENT_EXT = { - 'artifact_collection': EXT['artifact'], - 'visualization_collection': EXT['visualization'] - } - - @property - def ext(self): - return self.EXT[self.var_type] - - @staticmethod - def to_cli_name(val): - return util.to_cli_name(val) - - def _key_helper(self, input_path, key): - if self.var_type not in self.COLLECTION_VAR_TYPES: - raise KeyboardInterrupt( - f'Cannot key non-collection type {self.var_type}') - - return "%s%s%s" % (input_path, key, self.ELEMENT_EXT[self.var_type]) - - def to_interface_name(self): - if hasattr(self, '_q2cli_ref'): - return self._q2cli_ref - - cli_name = '%s%s' % (self.name, self.ext) - - # don't disturb file names, this will break importing where rachis - # relies on specific filenames being present in a dir - if self.var_type not in ('format', 'column'): - cli_name = self.to_cli_name(cli_name) - - cli_name = shlex.quote(cli_name) - return cli_name - - def assert_has_line_matching(self, path, expression, key=None): - if not self.use.enable_assertions: - return - - INDENT = self.use.INDENT - input_path = self.to_interface_name() - expr = shlex.quote(expression) - - if key: - input_path = self._key_helper(input_path, key) - - lines = [ - 'rachis dev assert-result-data %s \\' % (input_path,), - INDENT + '--zip-data-path %s \\' % (path,), - INDENT + '--expression %s' % (expr,), - ] - - self.use.recorder.extend(lines) - - def assert_output_type(self, semantic_type, key=None): - if not self.use.enable_assertions: - return - - INDENT = self.use.INDENT - input_path = self.to_interface_name() - - if key: - input_path = self._key_helper(input_path, key) - - # TODO: update qiime-type to rachis-type ? - lines = [ - 'rachis dev assert-result-type %s \\' % (input_path,), - INDENT + '--qiime-type %s' % (str(semantic_type),), - ] - - self.use.recorder.extend(lines) - - -class CLIUsage(usage.Usage): - INDENT = ' ' * 2 - - def __init__(self, enable_assertions=False, action_collection_size=None): - super().__init__() - self.recorder = [] - self.init_data = [] - self.enable_assertions = enable_assertions - self.action_collection_size = action_collection_size - self.output_dir_counter = collections.defaultdict(int) - - def usage_variable(self, name, factory, var_type): - return CLIUsageVariable(name, factory, var_type, self) - - def render(self, flush=False): - rendered = '\n'.join(self.recorder) - if flush: - self.recorder = [] - self.init_data = [] - return rendered - - def init_artifact(self, name, factory): - variable = super().init_artifact(name, factory) - - self.init_data.append(variable) - - return variable - - def init_artifact_collection(self, name, factory): - variable = super().init_artifact_collection(name, factory) - - self.init_data.append(variable) - - return variable - - def construct_artifact_collection(self, name, members): - variable = super().construct_artifact_collection( - name, members - ) - - rc_dir = variable.to_interface_name() - - keys = members.keys() - names = [name.to_interface_name() for name in members.values()] - - keys_arg = '( ' - for key in keys: - keys_arg += f'{key} ' - keys_arg += ')' - names_arg = '( ' - for name in names: - names_arg += f'{name} ' - names_arg += ')' - - lines = [ - '## constructing result collection ##', - f'rc_name={rc_dir}', - 'ext=.qza', - f'keys={keys_arg}', - f'names={names_arg}', - 'construct_result_collection', - '##', - ] - self.recorder.extend(lines) - - return variable - - def get_artifact_collection_member(self, name, variable, key): - accessed_variable = super().get_artifact_collection_member( - name, variable, key - ) - - rc_dir = variable.to_interface_name() - member_fp = os.path.join(rc_dir, f'{key}.qza') - - lines = [ - '## accessing result collection member ##', - f'ln -s {member_fp} {accessed_variable.to_interface_name()}', - '##', - ] - self.recorder.extend(lines) - - return accessed_variable - - def import_from_format(self, name, semantic_type, - variable, view_type=None): - imported_var = super().import_from_format( - name, semantic_type, variable, view_type=view_type) - - in_fp = variable.to_interface_name() - out_fp = imported_var.to_interface_name() - - lines = [ - 'rachis tools import \\', - self.INDENT + '--type %r \\' % (semantic_type,) - ] - - if view_type is not None: - if type(view_type) is not str: - view_type = view_type.__name__ - lines.append(self.INDENT + '--input-format %s \\' % (view_type,)) - - lines += [ - self.INDENT + '--input-path %s \\' % (in_fp,), - self.INDENT + '--output-path %s' % (out_fp,), - ] - - self.recorder.extend(lines) - - return imported_var - - def init_format(self, name, factory, ext=None): - if ext is not None: - name = '%s.%s' % (name, ext.lstrip('.')) - - variable = super().init_format(name, factory, ext=ext) - - self.init_data.append(variable) - - return variable - - def init_metadata(self, name, factory): - variable = super().init_metadata(name, factory) - - self.init_data.append(variable) - - return variable - - def comment(self, text): - self.recorder += ['# ' + ln for ln in textwrap.wrap(text, width=74)] - - def peek(self, variable): - var_name = variable.to_interface_name() - self.recorder.append('rachis tools peek %s' % var_name) - - def merge_metadata(self, name, *variables): - var = super().merge_metadata(name, *variables) - - # this is our special "short-circuit" attr to handle special-case - # .to_interface_name() needs - var._q2cli_ref = ' '.join(v.to_interface_name() for v in variables) - return var - - def get_metadata_column(self, name, column_name, variable): - var = super().get_metadata_column(name, column_name, variable) - - # this is our special "short-circuit" attr to handle special-case - # .to_interface_name() needs - var._q2cli_ref = (variable.to_interface_name(), column_name) - return var - - def view_as_metadata(self, name, variable): - # use the given name so that namespace behaves as expected, - # then overwrite it because viewing is a no-op in q2cli - var = super().view_as_metadata(name, variable) - # preserve the original interface name of the QZA as this will be - # implicitly converted to metadata when executed. - var._q2cli_ref = variable.to_interface_name() - return var - - def action(self, action, inputs, outputs): - variables = super().action(action, inputs, outputs) - - vars_dict = variables._asdict() - - plugin_name = util.to_cli_name(action.plugin_id) - action_name = util.to_cli_name(action.action_id) - self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) - - action_f = action.get_action() - action_state = get_action_state(action_f) - - ins = inputs.map_variables(lambda v: v.to_interface_name()) - outs = {k: v.to_interface_name() for k, v in vars_dict.items()} - signature = {s['name']: s for s in action_state['signature']} - - for param_name, value in ins.items(): - self._append_action_line(signature, param_name, value) - - max_collection_size = self.action_collection_size - if max_collection_size is not None and len(outs) > max_collection_size: - dir_name = self._build_output_dir_name(plugin_name, action_name) - self.recorder.append( - self.INDENT + '--output-dir %s \\' % (dir_name)) - self._rename_outputs(vars_dict, dir_name) - else: - for param_name, value in outs.items(): - self._append_action_line(signature, param_name, value) - - self.recorder[-1] = self.recorder[-1][:-2] # remove trailing \ - - return variables - - def _build_output_dir_name(self, plugin_name, action_name): - base_name = '%s-%s' % (plugin_name, action_name) - self.output_dir_counter[base_name] += 1 - current_inc = self.output_dir_counter[base_name] - if current_inc == 1: - return base_name - return '%s-%d' % (base_name, current_inc) - - def _rename_outputs(self, vars_dict, dir_name): - for signature_name, variable in vars_dict.items(): - name = '%s%s' % (signature_name, variable.ext) - variable._q2cli_ref = os.path.join(dir_name, name) - - def _append_action_line(self, signature, param_name, value): - param_state = signature[param_name] - if value is not None: - for opt, val in self._make_param(value, param_state): - line = self.INDENT + opt - if val is not None: - line += ' ' + val - line += ' \\' - - self.recorder.append(line) - - def _make_param(self, value, state): - state = state.copy() - type_ = state.pop('type') - - opt = q2cli.click.option.GeneratedOption(prefix=type_[0], **state) - option = opt.opts[0] - - # INPUTS AND OUTPUTS - if type_ in ('input', 'output'): - if isinstance(value, str): - return [(option, value)] - else: - if isinstance(value, set): - value = sorted(value) - return [(option, ' '.join(value))] - - # METADATA FILE - if state['metadata'] == 'file': - return [(option, value)] - - # METADATA COLUMN - if state['metadata'] == 'column': - # md cols are special, we have pre-computed the interface-specific - # names and stashed them in an attr, so unpack to get the values - fn, col_name = value - return [(option, fn), (opt.q2_extra_opts[0], col_name)] - - # PARAMETERS - if type_ == 'parameter': - if isinstance(value, set): - value = [shlex.quote(str(v)) for v in value] - return [(option, ' '.join(sorted(value)))] - - if isinstance(value, list): - return [(option, ' '.join(shlex.quote(str(v)) for v in value))] - - if isinstance(value, (dict, ResultCollection)): - return [(option, ' '.join(f'{k}:{shlex.quote(str(v))}' - for k, v in value.items()))] - - if type(value) is bool: - if state['ast']['type'] == 'expression': - if value: - return [(option, None)] - else: - return [(opt.secondary_opts[0], None)] - else: - # This is a more complicated param that can't be expressed - # as a typical `--p-foo/--p-no-foo` so default to baseline - # parameter handling behavior. - pass - - if type(value) is str: - return [(option, shlex.quote(value))] - - return [(option, str(value))] - - raise Exception('Something went terribly wrong!') - - def get_example_data(self): - for val in self.init_data: - yield val.to_interface_name(), val.execute() - - -class ReplayCLIUsageVariable(CLIUsageVariable): - def to_interface_name(self): - ''' - Differs from parent method in that metadata is not kebab-cased, - filepaths are preserved instead. - ''' - if hasattr(self, '_q2cli_ref'): - return self._q2cli_ref - - cli_name = '%s%s' % (self.name, self.ext) - - # don't disturb file names, this will break importing where rachis - # relies on specific filenames being present in a dir - if self.var_type not in ('format', 'column', 'metadata'): - cli_name = self.to_cli_name(cli_name) - - return cli_name - - -class ReplayCLIUsage(CLIUsage): - UsageAction: Type[ReplayUsageAction] = ReplayUsageAction - - shebang = '#!/usr/bin/env bash' - header_boundary = ('#' * 79) - set_ex = [ - '# This tells bash to -e exit immediately if a command fails', - '# and -x show all commands in stdout so you can track progress', - 'set -e -x', - '' - ] - - copyright = importlib.resources.read_text( - f'{__package__}.assets', 'copyright_note.txt' - ).split('\n') - how_to = importlib.resources.read_text( - f'{__package__}.assets', 'cli_howto.txt' - ).split('\n') - - def __init__(self, enable_assertions=False, action_collection_size=None): - ''' - Identical to parent but creates header and footer attributes. - - Parameters - ---------- - enable_assertions : bool - Whether to render has-line-matching and output type assertions. - action_collection_size : int - The number of outputs returned by an action above which outputs are - grouped into and accessed from an --output-dir. - ''' - super().__init__() - self.header = [] - self.footer = [] - self.enable_assertions = enable_assertions - self.action_collection_size = action_collection_size - - def usage_variable(self, name, factory, var_type): - return ReplayCLIUsageVariable(name, factory, var_type, self) - - def _append_action_line(self, signature, param_name: str, value): - ''' - Extends the parent method to accommodate action signatures that may - differ between those found in provenance and those accessible in the - currently executing environment. - - Parameters - ---------- - signature : dict of str -> dict - Mapping of name of signature item to dict of signature spec data. - param_name : str - The name of the parameter for which to render a CLI line. - value : any - The value of an item from a Results. - ''' - param_state = signature.get(param_name) - if param_state is not None: - for opt, val in self._make_param(value, param_state): - line = self.INDENT + opt - if val is not None: - line += ' ' + val - line += ' \\' - self.recorder.append(line) - else: # no matching param name - line = self.INDENT + ( - '# FIXME: The following parameter name was not found in ' - 'your current\n # rachis environment. This may occur ' - 'when the plugin version you have\n # installed does not ' - 'match the version used in the original analysis.\n # ' - 'Please see the docs and correct the parameter name ' - 'before running.') - self.recorder.append(line) - self._append_unknown_param(param_name, value) - - def _append_unknown_param(self, param_name, value): - line = '' - cli_name = re.sub('_', '-', param_name) - line += self.INDENT + '--?-' + cli_name + ' ' + str(value) - line += ' \\' - self.recorder.append(line) - - def _make_param(self, value: Any, state: Dict) -> List[Tuple]: - ''' - Wraps metadata filenames in <> to force users to replace them. - - Parameters - ---------- - value : any - A value of a Result. - state : dict - A collection of info about an item from an action signature. - See q2cli.core.state.py. - - Returns - ------- - list of tuple - See q2cli.core.usage.CLIUsage._make_param for possible outputs. - ''' - if state['metadata'] == 'column': - value = (f'{value[0]}', *value[1:]) - if state['metadata'] == 'file': - value = f'{value}' - return super()._make_param(value, state) - - def import_from_format( - self, - name: str, - semantic_type: str, - variable: UsageVariable, - view_type: Any = None - ) -> UsageVariable: - ''' - Identical to super.import_from_format, but writes - --input-path and follows import block with a - blank line. - - Parameters - ---------- - name : str - The name of the created UsageVariable. - semantic_type : str - The semantic type of the created UsageVariable. - variable : UsageVariable - A UsageVariable of some format type that will materialize the - data to be imported. - view_type : format or str - The view type for importing. - - Returns - ------- - UsageVariable - Of type artifact. - ''' - # need the super().super() here, so pass self to super - imported_var = Usage.import_from_format( - self, name, semantic_type, variable, view_type=view_type - ) - - out_fp = imported_var.to_interface_name() - - lines = [ - 'rachis tools import \\', - self.INDENT + '--type %r \\' % (semantic_type,) - ] - - if view_type is not None: - lines.append( - self.INDENT + '--input-format %s \\' % (view_type,) - ) - - lines += [ - self.INDENT + '--input-path \\', - self.INDENT + '--output-path %s' % (out_fp,), - ] - - lines.append('') - self.recorder.extend(lines) - - return imported_var - - def init_metadata( - self, name: str, factory: Callable, dumped_md_fn: str = '' - ) -> UsageVariable: - ''' - Like parent, but appropriately handles filepaths for recorded md fps. - - Parameters - ---------- - name : str - The name of the UsageVariable to be created. - factory : Callable - The factory responsible for generating the realized value of the - UsageVariable. - - Returns - ------- - UsageVariable - Of type metadata. - ''' - variable = super().init_metadata(name, factory) - - self.init_data.append(variable) - - if dumped_md_fn: - variable.name = f'"{dumped_md_fn}.tsv"' - else: - variable.name = '' - - return variable - - def comment(self, text): - ''' - Identical to parent method, but pads comments with an extra newline. - ''' - super().comment(text) - self.recorder.append('') - - def action( - self, - action: ReplayUsageAction, - inputs: UsageInputs, - outputs: UsageOutputs - ) -> UsageOutputs: - ''' - Overrides parent method to fill in missing outputlines from - action_f.signature. - Also pads actions with an extra newline. - - Parameters - ---------- - action : ReplayUsageAction - The object representing the Action we are replaying. - inputs : UsageInputs - Mapping of parameter names to arguments for the action. - outputs : ReplayUsageOutputNames - Mapping of registered output names to usage variable names. - - Returns - ------- - UsageOutputs - The results returned by the action. - ''' - plugin_name = q2cli.util.to_cli_name(action.plugin_id) - action_name = q2cli.util.to_cli_name(action.action_id) - - if action.node._action_present_: - variables = self._action_found( - plugin_name, action_name, action, inputs, outputs - ) - else: - variables = self._action_not_found( - plugin_name, action_name, action, inputs, outputs - ) - - return variables - - def _action_found( - self, plugin_name: str, action_name: str, - action: ReplayUsageAction, - inputs: UsageInputs, - outputs: UsageOutputs - ): - self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) - - variables = Usage.action(self, action, inputs, outputs) - vars_dict = variables._asdict() - - # get registered collection of output names so we don't miss any - action_f = action.get_action() - missing_outputs = {} - for output in action_f.signature.outputs: - try: - # If we get a match on output-name, the correct pair is already - # in vars_dict and we can continue - getattr(variables, output) - except AttributeError: - # Otherwise, we should add filler values to missing_outputs - missing_outputs[output] = f'XX_{output}' - - action_state = get_action_state(action_f) - - ins = inputs.map_variables(lambda v: v.to_interface_name()) - outs = {k: v.to_interface_name() for k, v in vars_dict.items()} - outs.update(missing_outputs) - signature = {s['name']: s for s in action_state['signature']} - - for param_name, value in ins.items(): - self._append_action_line(signature, param_name, value) - - max_collection_size = self.action_collection_size - if max_collection_size is not None and len(outs) > max_collection_size: - dir_name = self._build_output_dir_name(plugin_name, action_name) - self.recorder.append( - self.INDENT + '--output-dir %s \\' % (dir_name) - ) - self._rename_outputs(vars_dict, dir_name) - else: - for param_name, value in outs.items(): - self._append_action_line(signature, param_name, value) - - self.recorder[-1] = self.recorder[-1][:-2] # remove trailing `\` - - self.recorder.append('') - return variables - - def _action_not_found( - self, plugin_name: str, action_name: str, - action: ReplayUsageAction, - inputs: UsageInputs, - outputs: UsageOutputs - ): - variables = Usage.action_not_found(self, action, inputs, outputs) - vars_dict = variables._asdict() - - ins = inputs.map_variables(lambda v: v.to_interface_name()) - - self.recorder.append( - '# FIXME: The following action was not found in your current ' - 'rachis\n# environment. Please ensure the action and its ' - 'parameters are correct before\n# running.' - ) - self.recorder.append('rachis %s %s \\' % (plugin_name, action_name)) - - for param_name, value in ins.items(): - self._append_unknown_param(param_name, value) - - dir_name = self._build_output_dir_name(plugin_name, action_name) - self.recorder.append( - self.INDENT + '--output-dir %s \\' % (dir_name) - ) - self._rename_outputs(vars_dict, dir_name) - - self.recorder[-1] = self.recorder[-1][:-2] # remove trailing `\` - - self.recorder.append('') - - return variables - - def render(self, flush=False): - ''' - Return a newline-seperated string of CLI script. - - Parameters - ---------- - flush : bool - Whether to 'flush' the current code. Importantly, this will clear - the top-line imports for future invocations. - - Returns - ------- - str - The rendered string of CLI code. - ''' - if self.header: - self.header = self.header + [''] - if self.footer: - self.footer = [''] + self.footer - rendered = '\n'.join( - self.header + self.set_ex + self.recorder + self.footer - ) - if flush: - self.header = [] - self.footer = [] - self.recorder = [] - self.init_data = [] - return rendered - - def build_header(self): - '''Constructs a renderable header from its components.''' - self.header.extend(build_header( - self.shebang, self.header_boundary, self.copyright, self.how_to - )) - - # for creating result collections in bash - bash_rc_function = [ - 'construct_result_collection () {', - '\tmkdir $rc_name', - '\ttouch $rc_name.order', - '\tfor key in "${keys[@]}"; do', - '\t\techo $key >> $rc_name.order', - '\tdone', - '\tfor i in "${!keys[@]}"; do', - '\t\tln -s ../"${names[i]}" $rc_name"${keys[i]}"$ext', - '\tdone', - '}' - ] - self.header.extend([ - '## function to create result collections ##', - *bash_rc_function, - '##', - ]) - - def build_footer(self, dag: ProvDAG): - ''' - Constructs a renderable footer using the terminal uuids of a ProvDAG. - ''' - self.footer.extend(build_footer(dag, self.header_boundary)) diff --git a/q2cli/tests/__init__.py b/q2cli/tests/__init__.py deleted file mode 100644 index 5a85cefd..00000000 --- a/q2cli/tests/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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. -# ---------------------------------------------------------------------------- diff --git a/q2cli/tests/data/concatenated_ints.qza b/q2cli/tests/data/concatenated_ints.qza deleted file mode 100644 index a87e69c14461afc9aab13d3d3776975adc37502b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19908 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 diff --git a/q2cli/tests/data/mapping_config.toml b/q2cli/tests/data/mapping_config.toml deleted file mode 100644 index a30b82ce..00000000 --- a/q2cli/tests/data/mapping_config.toml +++ /dev/null @@ -1,15 +0,0 @@ -[parsl] -strategy = "None" - -[[parsl.executors]] -class = "ThreadPoolExecutor" -label = "default" -max_threads = 1 - -[[parsl.executors]] -class = "_TEST_EXECUTOR_" -label = "test" -max_threads = 1 - -[parsl.executor_mapping.dummy_plugin] -list_of_ints = "test" diff --git a/q2cli/tests/data/parse_dir_test/inner/right_ints.qza b/q2cli/tests/data/parse_dir_test/inner/right_ints.qza deleted file mode 100644 index 07aa4dd2f6322314426572e800df3b796c315c5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14035 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 diff --git a/q2cli/tests/data/parse_dir_test/left_ints.qza b/q2cli/tests/data/parse_dir_test/left_ints.qza deleted file mode 100644 index dfd29fd99c566190347925e772569923e429175c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14033 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В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%0AR6>fY!*S6)M{-tgh3hJg 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 EchoFormat: - return a - - plugin = get_dummy_plugin() - action_id = 'higher_union_bool_flag_swaps_output_method' - if action_id not in plugin.actions: - P, R = TypeMap({ - Bool % Choices(True): C1[Foo], - Bool % Choices(False): Foo, - }) - plugin.methods.register_function( - function=higher_union_bool_flag_swaps_output_method, - inputs={'a': Bar}, - parameters={'b': P}, - outputs=[('x', R)], - name='Higher Union Bool Flag Swaps Output Method', - description=( - 'Test if a higher-union bool parameter can change output'), - ) - return plugin - - -class CliTests(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - self.artifact1_path = os.path.join(self.tempdir, 'a1.qza') - self.mapping_path = os.path.join(self.tempdir, 'mapping.qza') - - artifact1 = Artifact.import_data(IntSequence1, [0, 42, 43]) - artifact1.save(self.artifact1_path) - self.artifact1_root_dir = str(artifact1.uuid) - - mapping = Artifact.import_data('Mapping', {'foo': '42'}) - mapping.save(self.mapping_path) - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_info(self): - result = self.runner.invoke(info) - self.assertEqual(result.exit_code, 0) - # May not always start with "System versions" if cache updating message - # is printed. - self.assertIn('System versions', result.output) - self.assertIn('Installed plugins', result.output) - self.assertIn('dummy-plugin', result.output) - self.assertIn('other-plugin', result.output) - self.assertIn('Config', result.output) - self.assertIn('Config Source: test config dict', result.output) - - def test_info_level_0(self): - result = self.runner.invoke(info, '--config-level 0') - self.assertEqual(result.exit_code, 0) - # May not always start with "System versions" if cache updating message - # is printed. - self.assertIn('System versions', result.output) - self.assertIn('Installed plugins', result.output) - self.assertIn('dummy-plugin', result.output) - self.assertIn('other-plugin', result.output) - self.assertNotIn('Config', result.output) - self.assertNotIn('Config Source: test config dict', result.output) - - def test_info_level_1(self): - result = self.runner.invoke(info, '--config-level 1') - self.assertEqual(result.exit_code, 0) - # May not always start with "System versions" if cache updating message - # is printed. - self.assertIn('System versions', result.output) - self.assertIn('Installed plugins', result.output) - self.assertIn('dummy-plugin', result.output) - self.assertIn('other-plugin', result.output) - self.assertIn('Config', result.output) - self.assertIn('Config Source: test config dict', result.output) - - def test_info_level_2(self): - result = self.runner.invoke(info, '--config-level 2') - self.assertEqual(result.exit_code, 0) - # May not always start with "System versions" if cache updating message - # is printed. - self.assertIn('System versions', result.output) - self.assertIn('Installed plugins', result.output) - self.assertIn('dummy-plugin', result.output) - self.assertIn('other-plugin', result.output) - self.assertIn('Config', result.output) - self.assertIn('Config Source: test config dict', result.output) - self.assertIn(CONFIG_LEVEL_2, result.output) - - def test_info_level_3(self): - result = self.runner.invoke(info, '--config-level 3') - self.assertEqual(result.exit_code, 0) - # May not always start with "System versions" if cache updating message - # is printed. - self.assertIn('System versions', result.output) - self.assertIn('Installed plugins', result.output) - self.assertIn('dummy-plugin', result.output) - self.assertIn('other-plugin', result.output) - self.assertIn('Config', result.output) - self.assertIn('Config Source: test config dict', result.output) - self.assertIn(CONFIG_LEVEL_3, result.output) - - def test_list_commands(self): - # top level commands, including a plugin, are present - rachis_cli = RootCommand() - commands = rachis_cli.list_commands(ctx=None) - self.assertIn('info', commands) - self.assertIn('tools', commands) - self.assertIn('dummy-plugin', commands) - self.assertIn('other-plugin', commands) - - def test_plugin_list_commands(self): - # plugin commands are present including a method and visualizer - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - commands = command.list_commands(ctx=None) - self.assertIn('split-ints', commands) - self.assertIn('mapping-viz', commands) - - self.assertNotIn('split_ints', commands) - self.assertNotIn('mapping_viz', commands) - self.assertNotIn('-underscore-method', commands) - self.assertNotIn('_underscore-method', commands) - - def test_plugin_list_hidden_commands(self): - # plugin commands are present including a method and visualizer and - # hidden method - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - commands = self.runner.invoke(command, - ['--show-hidden-actions']).output - self.assertIn('split-ints', commands) - self.assertIn('mapping-viz', commands) - self.assertIn('_underscore-method', commands) - - self.assertNotIn('split_ints', commands) - self.assertNotIn('mapping_viz', commands) - self.assertNotIn('_underscore_method', commands) - self.assertNotIn('-underscore-method', commands) - - def test_action_parameter_types(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - results = self.runner.invoke(command, ['typical-pipeline', '--help']) - help_text = results.output - - # Check the help text to make sure the types are displayed correctly - # boolean primitive - self.assertIn('--p-do-extra-thing / --p-no-do-extra-thing', help_text) - # int primitive - self.assertIn('--p-add INTEGER', help_text) - - # Run it to make sure the types are converted correctly, the framework - # will error if it recieves the wrong type from the interface. - output_dir = os.path.join(self.tempdir, 'output-test') - result = self.runner.invoke(command, [ - 'typical-pipeline', '--i-int-sequence', self.artifact1_path, - '--i-mapping', self.mapping_path, '--p-do-extra-thing', '--p-add', - '10', '--output-dir', output_dir, '--verbose']) - self.assertEqual(result.exit_code, 0) - - def test_higher_union_bool_typemap_parameter_is_flag(self): - plugin = _register_higher_union_bool_flag_action() - command = PluginCommand(get_plugin_state(plugin), 'dummy-plugin') - - results = self.runner.invoke( - command, [ - 'higher-union-bool-flag-swaps-output-method', '--help']) - self.assertEqual(results.exit_code, 0) - self.assertIn('--p-b / --p-no-b', results.output) - - input_path = Artifact.import_data( - 'Bar', 'element', view_type=str).save( - os.path.join(self.tempdir, 'bar.qza')) - true_output_path = os.path.join(self.tempdir, 'true-output.qza') - false_output_path = os.path.join(self.tempdir, 'false-output.qza') - - result = self.runner.invoke( - command, [ - 'higher-union-bool-flag-swaps-output-method', - '--i-a', input_path, - '--p-b', - '--o-x', true_output_path, - '--verbose' - ]) - self.assertEqual(result.exit_code, 0) - self.assertEqual(repr(Artifact.load(true_output_path).type), 'C1[Foo]') - - result = self.runner.invoke( - command, [ - 'higher-union-bool-flag-swaps-output-method', - '--i-a', input_path, - '--p-no-b', - '--o-x', false_output_path, - '--verbose' - ]) - self.assertEqual(result.exit_code, 0) - self.assertEqual(repr(Artifact.load(false_output_path).type), 'Foo') - - def test_execute_hidden_action(self): - int_path = os.path.join(self.tempdir, 'int.qza') - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - result = self.runner.invoke( - command, ['_underscore-method', '--o-int', int_path, - '--verbose']) - - self.assertEqual(result.exit_code, 0) - - def test_extract(self): - result = self.runner.invoke( - tools, ['extract', '--input-path', self.artifact1_path, - '--output-path', self.tempdir]) - # command completes sucessfully and creates the correct - # output directory - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists( - os.path.join(self.tempdir, self.artifact1_root_dir))) - # results are correct - data_f = open(os.path.join(self.tempdir, self.artifact1_root_dir, - 'data', 'ints.txt')) - self.assertEqual(data_f.read(), "0\n42\n43\n") - - def test_validate_min(self): - result = self.runner.invoke( - tools, ['validate', self.artifact1_path, '--level', 'min']) - self.assertEqual(result.exit_code, 0) - self.assertIn('appears to be valid at level=min', result.output) - - def test_validate_max(self): - result = self.runner.invoke( - tools, ['validate', self.artifact1_path, '--level', 'max']) - self.assertEqual(result.exit_code, 0) - self.assertIn('appears to be valid at level=max', result.output) - - result = self.runner.invoke(tools, ['validate', self.artifact1_path]) - self.assertEqual(result.exit_code, 0) - self.assertIn('appears to be valid at level=max', result.output) - - def test_split_ints(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - - # build output file names - left_path = os.path.join(self.tempdir, 'left.qza') - right_path = os.path.join(self.tempdir, 'right.qza') - - # TODO: currently must pass `--verbose` to commands invoked by Click's - # test runner because redirecting stdout/stderr raises an - # "io.UnsupportedOperation: fileno" error. Likely related to Click - # mocking a filesystem in the test runner. - result = self.runner.invoke( - command, ['split-ints', '--i-ints', self.artifact1_path, - '--o-left', left_path, '--o-right', right_path, - '--verbose']) - # command completes successfully and creates the correct - # output files - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(left_path)) - self.assertTrue(os.path.exists(right_path)) - # results are correct - left = Artifact.load(left_path) - right = Artifact.load(right_path) - self.assertEqual(left.view(list), [0]) - self.assertEqual(right.view(list), [42, 43]) - - def test_variadic_inputs(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - output_path = os.path.join(self.tempdir, 'output.qza') - - ints1 = Artifact.import_data('IntSequence1', [1, 2, 3]).save( - os.path.join(self.tempdir, 'ints1.qza')) - ints2 = Artifact.import_data('IntSequence2', [4, 5, 6]).save( - os.path.join(self.tempdir, 'ints2.qza')) - set1 = Artifact.import_data('SingleInt', 7).save( - os.path.join(self.tempdir, 'set1.qza')) - set2 = Artifact.import_data('SingleInt', 8).save( - os.path.join(self.tempdir, 'set2.qza')) - - result = self.runner.invoke( - command, - ['variadic-input-method', '--i-ints', ints1, '--i-ints', ints2, - '--i-int-set', set1, '--i-int-set', set2, '--p-nums', '9', - '--p-nums', '10', '--p-opt-nums', '11', '--p-opt-nums', '12', - '--p-opt-nums', '13', '--o-output', output_path, '--verbose']) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(output_path)) - - output = Artifact.load(output_path) - self.assertEqual(output.view(list), list(range(1, 14))) - - def test_with_parameters_only(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - output_path = os.path.join(self.tempdir, 'output.qza') - - result = self.runner.invoke( - command, ['params-only-method', '--p-name', 'Peanut', '--p-age', - '42', '--o-out', output_path, '--verbose']) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(output_path)) - - artifact = Artifact.load(output_path) - self.assertEqual(artifact.view(dict), {'Peanut': '42'}) - - def test_without_inputs_or_parameters(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - output_path = os.path.join(self.tempdir, 'output.qza') - - result = self.runner.invoke( - command, ['no-input-method', '--o-out', output_path, '--verbose']) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(output_path)) - - artifact = Artifact.load(output_path) - self.assertEqual(artifact.view(dict), {'foo': '42'}) - - def test_qza_extension(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - - # build output parameter arguments and expected output file names - left_path = os.path.join(self.tempdir, 'left') - expected_left_path = os.path.join(self.tempdir, 'left.qza') - right_path = os.path.join(self.tempdir, 'right') - expected_right_path = os.path.join(self.tempdir, 'right.qza') - - result = self.runner.invoke( - command, ['split-ints', '--i-ints', self.artifact1_path, - '--o-left', left_path, '--o-right', right_path, - '--verbose']) - # command completes successfully and creates the correct - # output files - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(expected_left_path)) - self.assertTrue(os.path.exists(expected_right_path)) - # results are correct - left = Artifact.load(expected_left_path) - right = Artifact.load(expected_right_path) - self.assertEqual(left.view(list), [0]) - self.assertEqual(right.view(list), [42, 43]) - - def test_qzv_extension(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - # build output parameter arguments and expected output file names - viz_path = os.path.join(self.tempdir, 'viz') - expected_viz_path = os.path.join(self.tempdir, 'viz.qzv') - - result = self.runner.invoke( - command, ['most-common-viz', '--i-ints', self.artifact1_path, - '--o-visualization', viz_path, '--verbose']) - # command completes successfully and creates the correct - # output file - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(expected_viz_path)) - # Visualization contains expected contents - viz = Visualization.load(expected_viz_path) - self.assertEqual(viz.get_index_paths(), {'html': 'data/index.html', - 'tsv': 'data/index.tsv'}) - - def test_verbose_shows_stacktrace(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - output = os.path.join(self.tempdir, 'never-happens.qza') - - result = self.runner.invoke( - command, - ['failing-pipeline', '--i-int-sequence', self.artifact1_path, - '--o-mapping', output, '--p-break-from', 'internal', '--verbose']) - - self.assertEqual(result.exit_code, 1) - self.assertIn('Traceback (most recent call last)', result.output) - - def test_input_conversion(self): - obj = QIIME2Type(IntSequence1.to_ast(), repr(IntSequence1)) - - with self.assertRaisesRegex(click.exceptions.BadParameter, - "x does not exist"): - obj._convert_input('x', None, None) - - # This is to ensure the temp in the regex matches the temp used in the - # method under test in type.py - temp = str(get_cache().path) - with unittest.mock.patch('qiime2.sdk.Result.peek', - side_effect=OSError(errno.ENOSPC, - 'No space left on ' - 'device')): - with self.assertRaisesRegex(click.exceptions.BadParameter, - f'{temp!r}.*' - f'{self.artifact1_path!r}.*' - f'{temp!r}'): - obj._convert_input(self.artifact1_path, None, None) - - def test_syntax_error_in_env(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - - viz_path = os.path.join(self.tempdir, 'viz') - - with unittest.mock.patch('qiime2.sdk.Result.peek', - side_effect=SyntaxError): - result = self.runner.invoke( - command, ['most-common-viz', '--i-ints', self.artifact1_path, - '--o-visualization', viz_path, '--verbose']) - - self.assertEqual(result.exit_code, 1) - self.assertIn('problem loading', result.output) - self.assertIn(self.artifact1_path, result.output) - - def test_deprecated_help_text(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - - result = self.runner.invoke(command, ['deprecated-method', '--help']) - - self.assertEqual(result.exit_code, 0) - self.assertIn('WARNING', result.output) - self.assertIn('deprecated', result.output) - - def test_run_deprecated_gets_warning_msg(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - output_path = os.path.join(self.tempdir, 'output.qza') - - result = self.runner.invoke( - command, - ['deprecated-method', '--o-out', output_path, '--verbose']) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(output_path)) - - artifact = Artifact.load(output_path) - - # Just make sure that the command ran as expected - self.assertEqual(artifact.view(dict), {'foo': '43'}) - - self.assertIn('deprecated', result.output) - - def test_get_citations(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - result = self.runner.invoke(command, ['split-ints', '--citations']) - - self.assertEqual(result.exit_code, 0) - self.assertEqual(result.output, EXPECTED_CITATIONS) - - def test_rachis_warning_is_always_visible(self): - ''' - Tests that the `RachisWarning` warning type gets passed to the standard - error stream even when --verbose is not set. Also ensures that such - errors are still visible with --verbose, and not visible with --quiet. - ''' - with tempfile.TemporaryDirectory() as tempdir: - base_command = [ - 'rachis', 'dummy-plugin', 'raises-rachis-warning', - '--o-output', - Path(tempdir) / 'output.qza' - ] - - # no verbose, no quiet - output = subprocess.run( - base_command, capture_output=True, text=True - ) - self.assertIn('This is an important warning', output.stderr) - - # verbose - output = subprocess.run( - base_command + ['--verbose'], capture_output=True, text=True - ) - self.assertIn('This is an important warning', output.stderr) - - # quiet - output = subprocess.run( - base_command + ['--quiet'], capture_output=True, text=True - ) - self.assertNotIn('This is an important warning', output.stderr) - - -class TestMigrated(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - - self.runner = CliRunner() - self.plugin_command = \ - RootCommand().get_command(ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - self.output_path = os.path.join(self.tempdir, 'output.qza') - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_migrated_all_optional_keys(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-all-optional-keys', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-all-optional-keys', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin of the old distribution to ' - 'the smart plugin of the new distribution ' - 'in 2025.4.', result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_no_optional_keys(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-no-optional-keys', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-no-optional-keys', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin to the smart plugin in a ' - 'future release.', result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_from_distro(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-from-distro', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-from-distro', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin of the old distribution ' - 'to the smart plugin in a future release.', - result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_to_distro(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-to-distro', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-to-distro', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin to the smart plugin of the ' - 'new distribution in a future release.', - result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_epoch(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-epoch', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-epoch', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin to the smart plugin ' - 'in 2025.4.', result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_from_distro_to_distro(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-from-distro-to-distro', - '--o-out', self.output_path, '--verbose']) - result_help = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-from-distro-to-distro', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin of the old distribution to ' - 'the smart plugin of the new distribution ' - 'in a future release.', result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_from_distro_epoch(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-from-distro-epoch', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-from-distro-epoch', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin of the old distribution to ' - 'the smart plugin in 2025.4.', result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - def test_migrated_to_distro_epoch(self): - result = \ - self.runner.invoke(self.plugin_command, - ['migrated-method-to-distro-epoch', - '--o-out', self.output_path, '--verbose']) - result_help = self.runner.invoke(self.plugin_command, - ['migrated-method-to-distro-epoch', - '--help']) - - for rslt in [result, result_help]: - self.assertEqual(rslt.exit_code, 0) - - self.assertIn('This Method is slated for migration from the ' - 'dummy_plugin plugin to the smart plugin of the ' - 'new distribution in 2025.4.', result.output) - - self.assertTrue(os.path.exists(self.output_path)) - artifact = Artifact.load(self.output_path) - self.assertEqual(artifact.view(dict), {'ziggy': '42'}) - - -class TestOptionalArtifactSupport(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - - self.runner = CliRunner() - self.plugin_command = RootCommand().get_command( - ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - self.ints1 = os.path.join(self.tempdir, 'ints1.qza') - Artifact.import_data( - IntSequence1, [0, 42, 43], list).save(self.ints1) - self.ints2 = os.path.join(self.tempdir, 'ints2.qza') - Artifact.import_data( - IntSequence1, [99, -22], list).save(self.ints2) - self.ints3 = os.path.join(self.tempdir, 'ints3.qza') - Artifact.import_data( - IntSequence2, [43, 43], list).save(self.ints3) - self.output = os.path.join(self.tempdir, 'output.qza') - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def _run_command(self, *args): - return self.runner.invoke(self.plugin_command, args) - - 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') - - self.assertEqual(result.exit_code, 0) - self.assertEqual(Artifact.load(self.output).view(list), - [0, 42, 43, 42]) - - 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, - '--o-output', self.output, '--verbose') - - self.assertEqual(result.exit_code, 0) - self.assertEqual(Artifact.load(self.output).view(list), - [0, 42, 43, 42, 99, -22]) - - 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', - '--o-output', self.output, '--verbose') - - self.assertEqual(result.exit_code, 0) - self.assertEqual(Artifact.load(self.output).view(list), - [0, 42, 43, 42, 99, -22, 43, 43, 111]) - - 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, - '--o-output', self.output, '--verbose') - - self.assertEqual(result.exit_code, 1) - self.assertRegex(str(result.output), - 'type IntSequence1.*type IntSequence2.*') - - -class MetadataTestsBase(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - self.runner = CliRunner() - self.plugin_command = RootCommand().get_command( - ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - self.input_artifact = os.path.join(self.tempdir, 'in.qza') - Artifact.import_data( - IntSequence1, [0, 42, 43], list).save(self.input_artifact) - self.output_artifact = os.path.join(self.tempdir, 'out.qza') - - self.metadata_file1 = os.path.join(self.tempdir, 'metadata1.tsv') - with open(self.metadata_file1, 'w') as f: - f.write('id\tcol1\n0\tfoo\nid1\tbar\n') - - self.metadata_file_alt_id_header = os.path.join( - self.tempdir, 'metadata-alt-id-header.tsv') - with open(self.metadata_file_alt_id_header, 'w') as f: - f.write('#SampleID\tcol1\n0\tfoo\nid1\tbar\n') - - self.metadata_file2 = os.path.join(self.tempdir, 'metadata2.tsv') - with open(self.metadata_file2, 'w') as f: - f.write('id\tcol2\n0\tbaz\nid1\tbaa\n') - - self.metadata_file_mixed_types = os.path.join( - self.tempdir, 'metadata-mixed-types.tsv') - with open(self.metadata_file_mixed_types, 'w') as f: - f.write('id\tnumbers\tstrings\nid1\t42\tabc\nid2\t-1.5\tdef\n') - - self.metadata_artifact = os.path.join(self.tempdir, 'metadata.qza') - Artifact.import_data( - 'Mapping', {'a': 'dog', 'b': 'cat'}).save(self.metadata_artifact) - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def _run_command(self, *args): - return self.runner.invoke(self.plugin_command, args) - - def _assertMetadataOutput(self, result, *, exp_tsv, exp_yaml): - self.assertEqual(result.exit_code, 0) - - artifact = Artifact.load(self.output_artifact) - action_dir = artifact._archiver.provenance_dir / 'action' - - if exp_tsv is None: - self.assertFalse((action_dir / 'metadata.tsv').exists()) - else: - with (action_dir / 'metadata.tsv').open() as fh: - self.assertEqual(fh.read(), exp_tsv) - - with (action_dir / 'action.yaml').open() as fh: - self.assertIn(exp_yaml, fh.read()) - - -class TestMetadataSupport(MetadataTestsBase): - def test_required_metadata_missing(self): - result = self._run_command( - 'identity-with-metadata', '--i-ints', self.input_artifact, - '--o-out', self.output_artifact) - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn("Missing option '--m-metadata-file'", result.output) - - def test_optional_metadata_missing(self): - result = self._run_command( - 'identity-with-optional-metadata', '--i-ints', self.input_artifact, - '--o-out', self.output_artifact, '--verbose') - - self._assertMetadataOutput(result, exp_tsv=None, - exp_yaml='metadata: null') - - def test_single_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, '--m-metadata-file', self.metadata_file1, - '--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_single_metadata_alt_id_header(self): - # Test that the Metadata ID header is preserved, and not normalized to - # 'id' (this used to be a bug). ID header normalization should only - # happen when 2+ Metadata are being merged. - 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, '--m-metadata-file', - self.metadata_file_alt_id_header, '--verbose') - - exp_tsv = ( - '#SampleID\tcol1\n' - '#q2:types\tcategorical\n' - '0\tfoo\n' - 'id1\tbar\n' - ) - self._assertMetadataOutput( - result, exp_tsv=exp_tsv, - exp_yaml="metadata: !metadata 'metadata.tsv'") - - def test_multiple_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, '--m-metadata-file', - self.metadata_file_alt_id_header, '--m-metadata-file', - self.metadata_file2, '--m-metadata-file', - self.metadata_artifact, '--verbose') - - exp_tsv = ( - 'id\tcol1\tcol2\ta\tb\n' - '#q2:types\tcategorical\tcategorical\tcategorical\tcategorical' - '\n0\tfoo\tbaz\tdog\tcat\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) - - def test_invalid_metadata_merge(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, '--m-metadata-file', self.metadata_file1, - '--m-metadata-file', self.metadata_file1) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn('overlapping columns', result.output) - - -class TestMetadataColumnSupport(MetadataTestsBase): - # Neither md file or column params provided - def test_required_missing_file_and_column(self): - result = self._run_command( - 'identity-with-metadata-column', '--i-ints', self.input_artifact, - '--o-out', self.output_artifact) - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn("Missing option '--m-metadata-file'", result.output) - - # md file param missing, md column param & value provided - def test_required_missing_file(self): - result = self._run_command( - 'identity-with-metadata-column', '--i-ints', self.input_artifact, - '--m-metadata-column', 'a', '--o-out', self.output_artifact) - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn("Missing option '--m-metadata-file'", result.output) - - # md file param & value provided, md column param missing - def test_required_missing_column(self): - result = self._run_command( - 'identity-with-metadata-column', '--i-ints', self.input_artifact, - '--m-metadata-file', self.metadata_file1, - '--o-out', self.output_artifact) - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn("Missing option '--m-metadata-column'", result.output) - - def test_optional_metadata_missing(self): - result = self._run_command( - 'identity-with-optional-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, '--verbose') - - self._assertMetadataOutput(result, exp_tsv=None, - exp_yaml='metadata: null') - - def test_optional_metadata_without_column(self): - result = self._run_command( - 'identity-with-optional-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-file', self.metadata_file1) - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn("Missing option '--m-metadata-column'", result.output) - - def test_optional_column_without_metadata(self): - result = self._run_command( - 'identity-with-optional-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-column', 'col1') - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn("Missing option '--m-metadata-file'", result.output) - - def test_single_metadata(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-column', 'col1', '--verbose') - - exp_tsv = 'id\tcol1\n#q2:types\tcategorical\n0\tfoo\nid1\tbar\n' - if result.exit_code != 0: - raise ValueError(result.exception) - self._assertMetadataOutput( - result, exp_tsv=exp_tsv, - exp_yaml="metadata: !metadata 'metadata.tsv'") - - def test_multiple_metadata(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_file2, '--m-metadata-file', - self.metadata_artifact, '--m-metadata-column', 'col2', - '--verbose') - - 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( - 'identity-with-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-file', self.metadata_file1, '--m-metadata-file', - self.metadata_file2, '--m-metadata-column', 'col1', - '--m-metadata-column', 'col2') - - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertIn('\'--m-metadata-file\' was specified multiple times', - result.output) - - 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 COLUMN MetadataColumn[Categorical]', - help_text) - - result = self._run_command( - 'identity-with-categorical-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-file', self.metadata_file_mixed_types, - '--m-metadata-column', 'strings', '--verbose') - - exp_tsv = 'id\tstrings\n#q2:types\tcategorical\nid1\tabc\nid2\tdef\n' - self._assertMetadataOutput( - result, exp_tsv=exp_tsv, - exp_yaml="metadata: !metadata 'metadata.tsv'") - - def test_categorical_metadata_column_type_mismatch(self): - result = self._run_command( - 'identity-with-categorical-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-file', self.metadata_file_mixed_types, - '--m-metadata-column', 'numbers') - - self.assertEqual(result.exit_code, 1) - 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 COLUMN MetadataColumn[Numeric]', - help_text) - - result = self._run_command( - 'identity-with-numeric-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-file', self.metadata_file_mixed_types, - '--m-metadata-column', 'numbers', '--verbose') - - exp_tsv = 'id\tnumbers\n#q2:types\tnumeric\nid1\t42\nid2\t-1.5\n' - self._assertMetadataOutput( - result, exp_tsv=exp_tsv, - exp_yaml="metadata: !metadata 'metadata.tsv'") - - def test_numeric_metadata_column_type_mismatch(self): - result = self._run_command( - 'identity-with-numeric-metadata-column', '--i-ints', - self.input_artifact, '--o-out', self.output_artifact, - '--m-metadata-file', self.metadata_file_mixed_types, - '--m-metadata-column', 'strings') - - self.assertEqual(result.exit_code, 1) - self.assertIn("Metadata column", result.output) - self.assertIn("categorical", result.output) - self.assertIn("expected Numeric", result.output) - - -class TestCollectionSupport(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - self.runner = CliRunner() - self.plugin_command = RootCommand().get_command( - ctx=None, name='dummy-plugin') - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - self.art1_path = os.path.join(self.tempdir, 'art1.qza') - self.art2_path = os.path.join(self.tempdir, 'art2.qza') - self.art1 = Artifact.import_data(SingleInt, 0) - self.art2 = Artifact.import_data(SingleInt, 1) - - self.output = os.path.join(self.tempdir, 'out') - self.output2 = os.path.join(self.tempdir, 'out2') - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def _run_command(self, *args): - return self.runner.invoke(self.plugin_command, args) - - def test_collection_roundtrip_list(self): - result = self._run_command( - 'list-params', '--p-ints', '0', '--p-ints', '1', '--o-output', - self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['0'].view(int), 0) - self.assertEqual(collection['1'].view(int), 1) - self.assertEqual(list(collection.keys()), ['0', '1']) - - result = self._run_command( - 'list-of-ints', '--i-ints', self.output, '--o-output', - self.output2, '--verbose' - ) - - self.assertEqual(collection['0'].view(int), 0) - self.assertEqual(collection['1'].view(int), 1) - self.assertEqual(list(collection.keys()), ['0', '1']) - - def test_collection_roundtrip_dict_keyed(self): - result = self._run_command( - 'dict-params', '--p-ints', 'foo:0', '--p-ints', 'bar:1', - '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['foo'].view(int), 0) - self.assertEqual(collection['bar'].view(int), 1) - self.assertEqual(list(collection.keys()), ['foo', 'bar']) - - result = self._run_command( - 'dict-of-ints', '--i-ints', self.output, '--o-output', - self.output2, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['foo'].view(int), 0) - self.assertEqual(collection['bar'].view(int), 1) - self.assertEqual(list(collection.keys()), ['foo', 'bar']) - - def test_collection_roundtrip_dict_unkeyed(self): - result = self._run_command( - 'dict-params', '--p-ints', '0', '--p-ints', '1', - '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['0'].view(int), 0) - self.assertEqual(collection['1'].view(int), 1) - self.assertEqual(list(collection.keys()), ['0', '1']) - - result = self._run_command( - 'dict-of-ints', '--i-ints', self.output, '--o-output', - self.output2, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['0'].view(int), 0) - self.assertEqual(collection['1'].view(int), 1) - self.assertEqual(list(collection.keys()), ['0', '1']) - - def test_de_facto_list(self): - self.art1.save(self.art1_path) - self.art2.save(self.art2_path) - - result = self._run_command( - 'list-of-ints', '--i-ints', self.art1_path, '--i-ints', - self.art2_path, '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['0'].view(int), 0) - self.assertEqual(collection['1'].view(int), 1) - self.assertEqual(list(collection.keys()), ['0', '1']) - - def test_de_facto_dict_keyed(self): - self.art1.save(self.art1_path) - self.art2.save(self.art2_path) - - result = self._run_command( - 'dict-of-ints', '--i-ints', f'foo:{self.art1_path}', '--i-ints', - f'bar:{self.art2_path}', '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['foo'].view(int), 0) - self.assertEqual(collection['bar'].view(int), 1) - self.assertEqual(list(collection.keys()), ['foo', 'bar']) - - def test_de_facto_dict_unkeyed(self): - self.art1.save(self.art1_path) - self.art2.save(self.art2_path) - - result = self._run_command( - 'dict-of-ints', '--i-ints', self.art1_path, '--i-ints', - self.art2_path, '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['0'].view(int), 0) - self.assertEqual(collection['1'].view(int), 1) - self.assertEqual(list(collection.keys()), ['0', '1']) - - def test_mixed_keyed_unkeyed_inputs(self): - self.art1.save(self.art1_path) - self.art2.save(self.art2_path) - - result = self._run_command( - 'dict-of-ints', '--i-ints', f'foo:{self.art1_path}', '--i-ints', - self.art2_path, '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn('Keyed values cannot be mixed with unkeyed values.', - str(result.exception)) - - result = self._run_command( - 'dict-of-ints', '--i-ints', self.art1_path, '--i-ints', - f'bar:{self.art2_path}', '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn('Keyed values cannot be mixed with unkeyed values.', - str(result.exception)) - - def test_mixed_keyed_unkeyed_params(self): - result = self._run_command( - 'dict-params', '--p-ints', 'foo:0', '--p-ints', '1', - '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn('The unkeyed value <1> has been mixed with keyed values.' - ' All values must be keyed or unkeyed', - str(result.exception)) - - result = self._run_command( - 'dict-params', '--p-ints', '0', '--p-ints', 'bar:1', - '--o-output', self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn('The keyed value has been mixed with unkeyed' - ' values. All values must be keyed or unkeyed', - str(result.exception)) - - def test_keyed_path_with_tilde(self): - self.art1.save(self.art1_path) - self.art2.save(self.art2_path) - - tmp = tempfile.gettempdir() - tempdir = os.path.basename(self.tempdir) - - with modified_environ(HOME=tmp): - result = self._run_command( - 'dict-of-ints', - '--i-ints', f'foo:{os.path.join("~", tempdir, "art1.qza")}', - '--i-ints', f'bar:{os.path.join("~", tempdir, "art2.qza")}', - '--o-output', self.output, '--verbose') - - self.assertEqual(result.exit_code, 0) - collection = ResultCollection.load(self.output) - - self.assertEqual(collection['foo'].view(int), 0) - self.assertEqual(collection['bar'].view(int), 1) - self.assertEqual(list(collection.keys()), ['foo', 'bar']) - - def test_directory_with_non_artifacts(self): - input_dir = os.path.join(self.tempdir, 'in') - os.mkdir(input_dir) - - artifact_path = os.path.join(input_dir, 'a.qza') - artifact = Artifact.import_data(IntSequence1, [0, 42, 43]) - artifact.save(artifact_path) - - with open(os.path.join(input_dir, 'bad.txt'), 'w') as fh: - fh.write('This file is not an artifact') - - result = self._run_command( - 'list-of-ints', '--i-ints', input_dir, '--o-output', - self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn("Invalid value for '--i-ints':", result.output) - - def test_empty_directory(self): - result = self._run_command( - 'list-of-ints', '--i-ints', self.tempdir, '--o-output', - self.output, '--verbose' - ) - - self.assertEqual(result.exit_code, 1) - self.assertIn(f"Provided directory '{self.tempdir}' is empty.", - result.output) - - -@contextlib.contextmanager -def modified_environ(*remove, **update): - """ - Taken from: https://stackoverflow.com/a/34333710. - - Updating the os.environ dict only modifies the environment variables from - the perspective of the current Python process, so this isn't dangerous at - all. - - Temporarily updates the ``os.environ`` dictionary in-place. - - The ``os.environ`` dictionary is updated in-place so that the modification - is sure to work in all situations. - - :param remove: Environment variables to remove. - :param update: Dictionary of environment variables and values to - add/update. - """ - env = os.environ - update = update or {} - remove = remove or [] - - # List of environment variables being updated or removed. - stomped = (set(update.keys()) | set(remove)) & set(env.keys()) - # Environment variables and values to restore on exit. - update_after = {k: env[k] for k in stomped} - # Environment variables and values to remove on exit. - remove_after = frozenset(k for k in update if k not in env) - - try: - env.update(update) - [env.pop(k, None) for k in remove] - yield - finally: - env.update(update_after) - [env.pop(k) for k in remove_after] - - -if __name__ == "__main__": - unittest.main() diff --git a/q2cli/tests/test_core.py b/q2cli/tests/test_core.py deleted file mode 100644 index 3828f625..00000000 --- a/q2cli/tests/test_core.py +++ /dev/null @@ -1,457 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os.path -import pathlib -import shutil -import tempfile -import unittest -import configparser -import zipfile - -import pandas as pd - -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 -from qiime2.sdk.util import camel_to_snake -from qiime2.sdk.usage import UsageVariable -from qiime2.sdk import PluginManager -from qiime2.core.archive.provenance_lib import DummyArtifacts, ProvDAG -from qiime2.core.archive.provenance_lib.replay import ( - ReplayConfig, param_is_metadata_column, dump_recorded_md_file, - ReplayNamespaces, build_import_usage, build_action_usage, - ActionCollections, replay_provenance, replay_supplement -) -from qiime2.core.archive.provenance_lib.usage_drivers import ReplayPythonUsage - -import q2cli -import q2cli.util -import q2cli.builtin.info -import q2cli.builtin.tools -from q2cli.commands import RootCommand -from q2cli.core.config import CLIConfig -from q2cli.core.usage import ReplayCLIUsage, CLIUsageVariable - - -class TestOption(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - self.parser = configparser.ConfigParser() - self.path = os.path.join(q2cli.util.get_app_dir(), 'cli-colors.theme') - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def _assertRepeatedOptionError(self, result, option): - self.assertEqual(result.exit_code, 1) - self.assertTrue(result.output.startswith('Usage:')) - self.assertRegex(result.output, '.*%s.* was specified multiple times' - % option) - - def test_repeated_eager_option_with_callback(self): - result = self.runner.invoke( - q2cli.builtin.tools.tools, - ['list-types', '--tsv', '--tsv']) - - self._assertRepeatedOptionError(result, '--tsv') - - def test_repeated_builtin_flag(self): - result = self.runner.invoke( - q2cli.builtin.tools.tools, - ['import', '--input-path', 'a', '--input-path', 'b']) - - self._assertRepeatedOptionError(result, '--input-path') - - def test_repeated_action_flag(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - out_path = os.path.join(self.tempdir, 'out.qza') - - result = self.runner.invoke( - command, ['no-input-method', '--o-out', out_path, - '--verbose', '--verbose']) - - self._assertRepeatedOptionError(result, '--verbose') - - def test_repeated_builtin_option(self): - input_path = os.path.join(self.tempdir, 'ints.txt') - with open(input_path, 'w') as f: - f.write('42\n43\n44\n') - output_path = os.path.join(self.tempdir, 'out.qza') - - result = self.runner.invoke( - q2cli.builtin.tools.tools, - ['import', '--input-path', input_path, - '--output-path', output_path, '--type', 'IntSequence1', - '--type', 'IntSequence1']) - - self._assertRepeatedOptionError(result, '--type') - - def test_repeated_action_option(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - out_path = os.path.join(self.tempdir, 'out.qza') - - result = self.runner.invoke( - command, ['no-input-method', '--o-out', out_path, - '--o-out', out_path]) - - self._assertRepeatedOptionError(result, '--o-out') - - def test_repeated_multiple_option(self): - input_path = os.path.join(self.tempdir, 'ints.qza') - artifact = Artifact.import_data(IntSequence1, [0, 42, 43], list) - artifact.save(input_path) - - metadata_path1 = os.path.join(self.tempdir, 'metadata1.tsv') - with open(metadata_path1, 'w') as f: - f.write('id\tcol1\nid1\tfoo\nid2\tbar\n') - metadata_path2 = os.path.join(self.tempdir, 'metadata2.tsv') - with open(metadata_path2, 'w') as f: - f.write('id\tcol2\nid1\tbaz\nid2\tbaa\n') - - output_path = os.path.join(self.tempdir, 'out.qza') - - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - - result = self.runner.invoke( - command, ['identity-with-metadata', '--i-ints', input_path, - '--o-out', output_path, '--m-metadata-file', - metadata_path1, '--m-metadata-file', metadata_path2, - '--verbose']) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.exists(output_path)) - self.assertEqual(Artifact.load(output_path).view(list), [0, 42, 43]) - - def test_config_expected(self): - self.parser['type'] = {'underline': 't'} - with open(self.path, 'w') as fh: - self.parser.write(fh) - - config = CLIConfig() - config.parse_file(self.path) - - self.assertEqual( - config.styles['type'], {'underline': True}) - - def test_config_bad_selector(self): - self.parser['tye'] = {'underline': 't'} - with open(self.path, 'w') as fh: - self.parser.write(fh) - - config = CLIConfig() - with self.assertRaisesRegex( - configparser.Error, 'tye.*valid selector.*valid selectors'): - config.parse_file(self.path) - - def test_config_bad_styling(self): - self.parser['type'] = {'underlined': 't'} - with open(self.path, 'w') as fh: - self.parser.write(fh) - - config = CLIConfig() - with self.assertRaisesRegex( - configparser.Error, 'underlined.*valid styling.*valid ' - 'stylings'): - config.parse_file(self.path) - - def test_config_bad_color(self): - self.parser['type'] = {'fg': 'purple'} - with open(self.path, 'w') as fh: - self.parser.write(fh) - - config = CLIConfig() - with self.assertRaisesRegex( - configparser.Error, 'purple.*valid color.*valid colors'): - config.parse_file(self.path) - - def test_config_bad_boolean(self): - self.parser['type'] = {'underline': 'g'} - with open(self.path, 'w') as fh: - self.parser.write(fh) - - config = CLIConfig() - with self.assertRaisesRegex( - configparser.Error, 'g.*valid boolean.*valid booleans'): - config.parse_file(self.path) - - def test_no_file(self): - config = CLIConfig() - with self.assertRaisesRegex( - configparser.Error, "'Path' is not a valid filepath."): - config.parse_file('Path') - - -class ReplayCLIUsageTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.das = DummyArtifacts() - cls.tempdir = cls.das.tempdir - cls.pm = PluginManager() - - @classmethod - def tearDownClass(cls): - cls.das.free() - - def test_init_metadata(self): - use = ReplayCLIUsage() - var = use.init_metadata(name='testing', factory=lambda: None) - self.assertEqual(var.name, '') - self.assertEqual(var.var_type, 'metadata') - - def test_init_metadata_with_dumped_md_fn(self): - use = ReplayCLIUsage() - var = use.init_metadata( - name='testing', factory=lambda: None, dumped_md_fn='some_md') - self.assertEqual(var.var_type, 'metadata') - self.assertEqual(var.name, '"some_md.tsv"') - - def test_param_is_metadata_col(self): - cfg = ReplayConfig(use=ReplayCLIUsage(), - use_recorded_metadata=False) - - actual = param_is_metadata_column( - cfg, 'metadata', 'dummy_plugin', 'identity_with_metadata_column' - ) - self.assertTrue(actual) - - actual = param_is_metadata_column( - cfg, 'int1', 'dummy_plugin', 'concatenate_ints' - ) - self.assertFalse(actual) - - with self.assertRaisesRegex(KeyError, "No action.*registered.*"): - param_is_metadata_column( - cfg, 'ints', 'dummy_plugin', 'young' - ) - - with self.assertRaisesRegex(KeyError, "No param.*registered.*"): - param_is_metadata_column( - cfg, 'thugger', 'dummy_plugin', 'split_ints' - ) - - with self.assertRaisesRegex(KeyError, "No plugin.*registered.*"): - param_is_metadata_column( - cfg, 'fake_param', 'dummy_hard', 'split_ints' - ) - - def test_dump_recorded_md_file_to_custom_dir(self): - dag = self.das.int_seq_with_md.dag - uuid = self.das.int_seq_with_md.uuid - - out_dir = 'custom_dir' - provnode = dag.get_node_data(uuid) - og_md = provnode.metadata['metadata'] - action_name = 'concatenate_ints_0' - md_id = 'metadata' - fn = 'metadata.tsv' - - with tempfile.TemporaryDirectory() as tempdir: - cfg = ReplayConfig(use=ReplayCLIUsage(), - md_out_dir=(tempdir + '/' + out_dir)) - dump_recorded_md_file(cfg, provnode, action_name, md_id, fn) - out_path = pathlib.Path(tempdir) / out_dir / action_name / fn - - self.assertTrue(out_path.is_file()) - - dumped_df = pd.read_csv(out_path, sep='\t') - pd.testing.assert_frame_equal(dumped_df, og_md) - - # If we run it again, it shouldn't overwrite 'recorded_metadata', - # so we should have two files - action_name_2 = 'concatenate_ints_1' - md_id2 = 'metadata' - fn2 = 'metadata_1.tsv' - dump_recorded_md_file(cfg, provnode, action_name_2, md_id2, fn2) - out_path2 = pathlib.Path(tempdir) / out_dir / action_name_2 / fn2 - - # are both files where expected? - self.assertTrue(out_path.is_file()) - self.assertTrue(out_path2.is_file()) - - def test_build_import_usage_cli(self): - ns = ReplayNamespaces() - cfg = ReplayConfig(use=ReplayCLIUsage(), - use_recorded_metadata=False) - dag = self.das.concated_ints_v6.dag - import_uuid = '8dea2f1a-2164-4a85-9f7d-e0641b1db22b' - import_node = dag.get_node_data(import_uuid) - c_to_s_type = camel_to_snake(import_node.type) - unq_var_nm = c_to_s_type + '_0' - build_import_usage(import_node, ns, cfg) - rendered = cfg.use.render() - usg_var = ns.get_usg_var_record(import_uuid).variable - out_name = usg_var.to_interface_name() - - self.assertIsInstance(usg_var, UsageVariable) - self.assertEqual(usg_var.var_type, 'artifact') - self.assertEqual(usg_var.name, unq_var_nm) - self.assertRegex(rendered, r'rachis tools import \\') - self.assertRegex(rendered, f" --type '{import_node.type}'") - self.assertRegex(rendered, " --input-path ") - self.assertRegex(rendered, f" --output-path {out_name}") - - def test_build_action_usage_cli(self): - plugin = 'dummy-plugin' - action = 'concatenate-ints' - cfg = ReplayConfig(use=ReplayCLIUsage(), - use_recorded_metadata=False) - - ns = ReplayNamespaces() - import_var_1 = CLIUsageVariable( - 'imported_ints_0', lambda: None, 'artifact', cfg.use - ) - import_var_2 = CLIUsageVariable( - 'imported_ints_1', lambda: None, 'artifact', cfg.use - ) - import_uuid_1 = '8dea2f1a-2164-4a85-9f7d-e0641b1db22b' - import_uuid_2 = '7727c060-5384-445d-b007-b64b41a090ee' - ns.add_usg_var_record(import_uuid_1, 'imported_ints_0') - ns.update_usg_var_record(import_uuid_1, import_var_1) - ns.add_usg_var_record(import_uuid_2, 'imported_ints_1') - ns.update_usg_var_record(import_uuid_2, import_var_2) - - dag = self.das.concated_ints_v6.dag - action_uuid = '5035a60e-6f9a-40d4-b412-48ae52255bb5' - node_uuid = '6facaf61-1676-45eb-ada0-d530be678b27' - node = dag.get_node_data(node_uuid) - actions = ActionCollections( - std_actions={action_uuid: {node_uuid: 'concatenated_ints'}} - ) - unique_var_name = node.action.output_name + '_0' - build_action_usage(node, ns, actions.std_actions, action_uuid, cfg) - rendered = cfg.use.render() - - usg_var = ns.get_usg_var_record(node_uuid).variable - out_name = usg_var.to_interface_name() - - self.assertIsInstance(usg_var, UsageVariable) - self.assertEqual(usg_var.var_type, 'artifact') - self.assertEqual(usg_var.name, unique_var_name) - - self.assertIn(f'rachis {plugin} {action}', rendered) - self.assertIn('--i-ints1 imported-ints-0.qza', rendered) - self.assertIn('--i-ints3 imported-ints-1.qza', rendered) - self.assertIn('--p-int1 7', rendered) - self.assertIn(f'--o-concatenated-ints {out_name}', rendered) - - def test_replay_optional_param_is_none(self): - dag = self.das.int_seq_optional_input.dag - with tempfile.TemporaryDirectory() as tempdir: - out_path = pathlib.Path(tempdir) / 'ns_coll.txt' - replay_provenance(ReplayCLIUsage, dag, out_path, - md_out_dir=tempdir) - - with open(out_path, 'r') as fp: - rendered = fp.read() - self.assertIn('--i-ints int-sequence1-0.qza', rendered) - self.assertIn('--p-num1', rendered) - self.assertNotIn('--i-optional1', rendered) - self.assertNotIn('--p-num2', rendered) - - def test_replay_from_provdag_ns_collision(self): - """ - This artifact's dag contains a few results with the output-name - filtered-table, so is a good check for namespace collisions if - we're not uniquifying variable names properly. - """ - with tempfile.TemporaryDirectory() as tempdir: - self.das.concated_ints.artifact.save( - os.path.join(tempdir, 'c1.qza') - ) - self.das.other_concated_ints.artifact.save( - os.path.join(tempdir, 'c2.qza') - ) - dag = ProvDAG(tempdir) - - exp = ['concatenated-ints-0', 'concatenated-ints-1'] - with tempfile.TemporaryDirectory() as tempdir: - out_path = pathlib.Path(tempdir) / 'ns_coll.txt' - replay_provenance(ReplayCLIUsage, dag, out_path, - md_out_dir=tempdir) - - with open(out_path, 'r') as fp: - rendered = fp.read() - for name in exp: - self.assertIn(name, rendered) - - -class WriteReproducibilitySupplementTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.das = DummyArtifacts() - cls.tempdir = cls.das.tempdir - cls.pm = PluginManager() - - @classmethod - def tearDownClass(cls): - cls.das.free() - - def test_replay_supplement_from_fp(self): - fp = self.das.concated_ints_with_md.filepath - with tempfile.TemporaryDirectory() as tempdir: - out_fp = os.path.join(tempdir, 'supplement.zip') - replay_supplement( - usage_drivers=[ReplayPythonUsage, ReplayCLIUsage], - payload=fp, - out_fp=out_fp - ) - - self.assertTrue(zipfile.is_zipfile(out_fp)) - - exp = { - 'supplement/', - 'supplement/python3_replay.py', - 'supplement/cli_replay.sh', - 'supplement/citations.bib', - 'supplement/recorded_metadata/', - 'supplement/recorded_metadata/' - 'dummy_plugin_identity_with_metadata_0/metadata_0.tsv', - } - with zipfile.ZipFile(out_fp, 'r') as myzip: - namelist_set = set(myzip.namelist()) - for item in exp: - self.assertIn(item, namelist_set) - - def test_replay_supplement_from_provdag(self): - dag = self.das.concated_ints_with_md.dag - - with tempfile.TemporaryDirectory() as tempdir: - out_fp = os.path.join(tempdir, 'supplement.zip') - replay_supplement( - usage_drivers=[ReplayPythonUsage, ReplayCLIUsage], - payload=dag, - out_fp=out_fp - ) - - self.assertTrue(zipfile.is_zipfile(out_fp)) - - exp = { - 'supplement/', - 'supplement/python3_replay.py', - 'supplement/cli_replay.sh', - 'supplement/citations.bib', - 'supplement/recorded_metadata/', - 'supplement/recorded_metadata/' - 'dummy_plugin_identity_with_metadata_0/metadata_0.tsv', - } - with zipfile.ZipFile(out_fp, 'r') as myzip: - namelist_set = set(myzip.namelist()) - for item in exp: - self.assertIn(item, namelist_set) - - -if __name__ == "__main__": - unittest.main() diff --git a/q2cli/tests/test_dev.py b/q2cli/tests/test_dev.py deleted file mode 100644 index 220563f3..00000000 --- a/q2cli/tests/test_dev.py +++ /dev/null @@ -1,190 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os -import shutil -import unittest -import tempfile -import configparser - -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.util -from q2cli.builtin.dev import dev - - -class TestDev(unittest.TestCase): - path = os.path.join(q2cli.util.get_app_dir(), 'cli-colors.theme') - old_settings = None - if os.path.exists(path): - old_settings = configparser.ConfigParser() - old_settings.read(path) - - def setUp(self): - get_dummy_plugin() - self.parser = configparser.ConfigParser() - self.runner = CliRunner(mix_stderr=False) - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - self.generated_config = os.path.join(self.tempdir, 'generated-theme') - - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - self.artifact1_path = os.path.join(self.tempdir, 'a1.qza') - self.mapping_path = os.path.join(self.tempdir, 'mapping.qza') - - artifact1 = Artifact.import_data(IntSequence1, [0, 42, 43]) - artifact1.save(self.artifact1_path) - self.artifact1_root_dir = str(artifact1.uuid) - - mapping = Artifact.import_data('Mapping', {'foo': '42'}) - mapping.save(self.mapping_path) - - self.config = os.path.join(self.tempdir, 'good-config.ini') - self.parser['type'] = {'underline': 't'} - with open(self.config, 'w') as fh: - self.parser.write(fh) - - def tearDown(self): - if self.old_settings is not None: - with open(self.path, 'w') as fh: - self.old_settings.write(fh) - shutil.rmtree(self.tempdir) - - def test_import_theme(self): - result = self.runner.invoke( - dev, ['import-theme', '--theme', self.config]) - self.assertEqual(result.exit_code, 0) - - def test_export_default_theme(self): - result = self.runner.invoke( - dev, ['export-default-theme', '--output-path', - self.generated_config]) - self.assertEqual(result.exit_code, 0) - - def test_reset_theme(self): - result = self.runner.invoke( - dev, ['reset-theme', '--yes']) - self.assertEqual(result.exit_code, 0) - - def test_reset_theme_no_yes(self): - result = self.runner.invoke( - dev, ['reset-theme']) - self.assertNotEqual(result.exit_code, 0) - - # result_type & result_data tests - def test_assert_result_type_artifact_success(self): - result = self.runner.invoke(dev, - ['assert-result-type', - self.mapping_path, - '--qiime-type', 'Mapping']) - - # single regex to account for tempdir path - - expected_regex = r'The input file \(.*mapping.qza\) type and the'\ - r' expected type \(Mapping\) match' - - self.assertEqual(result.exit_code, 0) - self.assertRegex(result.stdout, expected_regex) - - def test_assert_result_type_visualization_success(self): - dummy_plugin = get_dummy_plugin() - - self.viz_path = os.path.join(self.tempdir, 'viz.qzv') - most_common_viz = dummy_plugin.actions['most_common_viz'] - viz = most_common_viz(Artifact.load(self.artifact1_path)) - viz.visualization.save(self.viz_path) - - result = self.runner.invoke(dev, - ['assert-result-type', - self.viz_path, - '--qiime-type', - 'Visualization']) - - expected_regex = r'The input file \(.*viz\.qzv\) type and the'\ - r' expected type \(Visualization\) match' - - self.assertEqual(result.exit_code, 0) - self.assertRegex(result.stdout, expected_regex) - - def test_assert_result_type_load_failure(self): - result = self.runner.invoke(dev, - ['assert-result-type', - 'turkey_sandwhere.qza', - '--qiime-type', 'Mapping']) - - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.stderr, - r'Path\s*\'turkey_sandwhere\.qza\'\s*does not exist') - - def test_assert_result_type_invalid_rachis_type(self): - result = self.runner.invoke(dev, - ['assert-result-type', - self.mapping_path, - '--qiime-type', 'Squid']) - self.assertEqual(result.exit_code, 1) - self.assertIn('Expected Squid, observed Mapping', result.stderr) - - def test_assert_result_data_success(self): - result = self.runner.invoke(dev, - ['assert-result-data', - self.mapping_path, - '--zip-data-path', 'mapping.tsv', - '--expression', '42']) - self.assertEqual(result.exit_code, 0) - self.assertIn(r'"42" was found in mapping.tsv', result.stdout) - - def test_assert_result_data_load_failure(self): - result = self.runner.invoke(dev, - ['assert-result-data', - 'turkey_sandwhen.qza', - '--zip-data-path', 'mapping.tsv', - '--expression', '42']) - - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.stderr, - r'File\s*\'turkey_sandwhen\.qza\'\s*does not exist') - - def test_assert_result_data_zip_data_path_zero_matches(self): - result = self.runner.invoke(dev, - ['assert-result-data', - self.mapping_path, - '--zip-data-path', 'turkey_sandwhy.tsv', - '--expression', '42']) - - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.stderr, - r'did not produce exactly one match.\n' - r'Matches: \[\]\n') - - def test_assert_result_data_zip_data_path_multiple_matches(self): - self.double_path = os.path.join(self.tempdir, 'double.qza') - double_artifact = Artifact.import_data('SingleInt', 3) - double_artifact.save(self.double_path) - result = self.runner.invoke(dev, ['assert-result-data', - self.double_path, - '--zip-data-path', - 'file*.txt', - '--expression', - '3']) - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.stderr, r'Value provided for zip_data_path' - r' \(file\*\.txt\) did not produce' - r' exactly one match\.') - - def test_assert_result_data_match_expression_not_found(self): - result = self.runner.invoke(dev, ['assert-result-data', - self.mapping_path, - '--zip-data-path', 'mapping.tsv', - '--expression', 'foobar']) - - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.stderr, - r'Expression \'foobar\'' - r' not found in .*\/data\/mapping\.tsv\.') diff --git a/q2cli/tests/test_mystery_stew.py b/q2cli/tests/test_mystery_stew.py deleted file mode 100644 index d5f047d4..00000000 --- a/q2cli/tests/test_mystery_stew.py +++ /dev/null @@ -1,51 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os -import subprocess -import tempfile - -from q2cli.core.usage import CLIUsage -from q2cli.util import get_plugin_manager - -import pytest - - -def _labeler(val): - if hasattr(val, 'id'): - return val.id - return val - - -def get_tests(): - tests = [] - pm = get_plugin_manager() - try: - plugin = pm.plugins['mystery-stew'] - except KeyError: - return tests - for action in plugin.actions.values(): - for name in action.examples: - tests.append((action, name)) - return tests - - -@pytest.mark.parametrize('action,example', get_tests(), ids=_labeler) -def test_mystery_stew(action, example): - example_f = action.examples[example] - use = CLIUsage(enable_assertions=True) - example_f(use) - rendered = '\n'.join(use.recorder) - with tempfile.TemporaryDirectory() as tmpdir: - for ref, data in use.get_example_data(): - data.save(os.path.join(tmpdir, ref)) - subprocess.run([rendered], - shell=True, - check=True, - cwd=tmpdir, - env={**os.environ}) diff --git a/q2cli/tests/test_tools.py b/q2cli/tests/test_tools.py deleted file mode 100644 index 33fbb183..00000000 --- a/q2cli/tests/test_tools.py +++ /dev/null @@ -1,2127 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os -import gc -import re -import yaml -import pytest -import shutil -import unittest -from unittest.mock import patch -import tempfile -import zipfile -import bibtexparser as bp - -from click.testing import CliRunner -from qiime2 import Artifact, Metadata -from qiime2.core.testing.util import get_dummy_plugin -from qiime2.core.testing.type import IntSequence1, IntSequence2, SingleInt -from qiime2.metadata.base import SUPPORTED_COLUMN_TYPES -from qiime2.core.cache import Cache -from qiime2.sdk.result import Result -from qiime2.sdk.plugin_manager import PluginManager -from qiime2.core.annotate import Note - -from q2cli.util import load_metadata -from q2cli.builtin.tools import tools -from q2cli.commands import RootCommand -from q2cli.core.usage import ReplayCLIUsage - - -class TestCastMetadata(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - self.metadata_file = os.path.join( - self.tempdir, 'metadata.tsv') - with open(self.metadata_file, 'w') as f: - f.write('id\tnumbers\tstrings\n0\t42\tabc\n1\t-1.5\tdef') - - self.cast_metadata_dump = \ - ('id\tnumbers\tstrings\n#q2:types\tcategorical\tcategorical\n0\t42' - '\tabc\n1\t-1.5\tdef\n\n') - - self.output_file = os.path.join( - self.tempdir, 'test_output.tsv') - - def test_input_invalid_column_type(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'numbers:foo', '--output-file', self.output_file]) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn('Unknown column type provided.', result.output) - - def test_input_duplicate_columns(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'numbers:numerical', '--cast', 'numbers:categorical', - '--output-file', self.output_file]) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn( - '"numbers" appears in cast more than once.', result.output) - - def test_input_invalid_cast_format_missing_colon(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', 'numbers', - '--output-file', self.output_file]) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn('Missing `:` in --cast numbers', result.output) - - def test_input_invalid_cast_format_extra_colon(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', 'numbers::', - '--output-file', self.output_file]) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn('Incorrect number of fields in --cast numbers::', - result.output) - self.assertIn('Observed 3', result.output) - - def test_error_on_extra(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'extra:numeric', '--output-file', self.output_file]) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn( - "The following cast columns were not found within the" - " metadata: extra", result.output) - - def test_error_on_missing(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'numbers:categorical', '--error-on-missing', - '--output-file', self.output_file]) - - self.assertNotEqual(result.exit_code, 0) - self.assertIn( - "The following columns within the metadata" - " were not provided in the cast: strings", - result.output) - - def test_extra_columns_removed(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'numbers:categorical', '--cast', 'extra:numeric', - '--ignore-extra', '--output-file', self.output_file]) - - self.assertEqual(result.exit_code, 0) - casted_metadata = load_metadata(self.output_file) - self.assertNotIn('extra', casted_metadata.columns.keys()) - - def test_complete_successful_run(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'numbers:categorical', '--output-file', self.output_file]) - - self.assertEqual(result.exit_code, 0) - input_metadata = load_metadata(self.metadata_file) - self.assertEqual('numeric', input_metadata.columns['numbers'].type) - - casted_metadata = load_metadata(self.output_file) - self.assertEqual('categorical', - casted_metadata.columns['numbers'].type) - - def test_write_to_stdout(self): - result = self.runner.invoke( - tools, ['cast-metadata', self.metadata_file, '--cast', - 'numbers:categorical']) - - self.assertEqual(result.exit_code, 0) - self.assertEqual(self.cast_metadata_dump, result.output) - - def test_valid_column_types(self): - result = self.runner.invoke(tools, ['cast-metadata', '--help']) - for col_type in SUPPORTED_COLUMN_TYPES: - self.assertIn(col_type, result.output) - - -class TestInspectMetadata(unittest.TestCase): - def setUp(self): - dummy_plugin = get_dummy_plugin() - - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - self.metadata_file_mixed_types = os.path.join( - self.tempdir, 'metadata-mixed-types.tsv') - with open(self.metadata_file_mixed_types, 'w') as f: - f.write('id\tnumbers\tstrings\n0\t42\tabc\n1\t-1.5\tdef\n') - - self.bad_metadata_file = os.path.join( - self.tempdir, 'bad-metadata.tsv') - with open(self.bad_metadata_file, 'w') as f: - f.write('wrong\tnumbers\tstrings\nid1\t42\tabc\nid2\t-1.5\tdef\n') - - self.metadata_artifact = os.path.join(self.tempdir, 'metadata.qza') - Artifact.import_data( - 'Mapping', {'a': 'dog', 'b': 'cat'}).save(self.metadata_artifact) - - self.ints1 = os.path.join(self.tempdir, 'ints1.qza') - ints1 = Artifact.import_data( - 'IntSequence1', [0, 42, 43], list) - ints1.save(self.ints1) - - self.ints2 = os.path.join(self.tempdir, 'ints') - ints1.export_data(self.ints2) - - self.viz = os.path.join(self.tempdir, 'viz.qzv') - most_common_viz = dummy_plugin.actions['most_common_viz'] - self.viz = most_common_viz(ints1).visualization.save(self.viz) - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_artifact_w_metadata(self): - result = self.runner.invoke( - tools, ['inspect-metadata', self.metadata_artifact]) - - self.assertEqual(result.exit_code, 0) - self.assertIn('COLUMN NAME TYPE', result.output) - self.assertIn("=========== ===========", result.output) - self.assertIn("a categorical", result.output) - self.assertIn("b categorical", result.output) - self.assertIn("IDS: 1", result.output) - self.assertIn("COLUMNS: 2", result.output) - - def test_artifact_no_metadata(self): - result = self.runner.invoke(tools, ['inspect-metadata', self.ints1]) - - self.assertEqual(result.exit_code, 1) - self.assertIn("IntSequence1 cannot be viewed as rachis metadata", - result.output) - - def test_visualization(self): - # make a viz first: - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - # build output parameter arguments and expected output file names - viz_path = os.path.join(self.tempdir, 'viz.qzv') - result = self.runner.invoke( - command, ['most-common-viz', '--i-ints', self.ints1, - '--o-visualization', viz_path, '--verbose']) - - result = self.runner.invoke(tools, ['inspect-metadata', viz_path]) - - self.assertEqual(result.exit_code, 1) - self.assertIn("Visualizations cannot be viewed as rachis metadata", - result.output) - - def test_metadata_file(self): - result = self.runner.invoke( - tools, ['inspect-metadata', self.metadata_file_mixed_types]) - - self.assertEqual(result.exit_code, 0) - self.assertIn('COLUMN NAME TYPE', result.output) - self.assertIn("=========== ===========", result.output) - self.assertIn("numbers numeric", result.output) - self.assertIn("strings categorical", result.output) - self.assertIn("IDS: 2", result.output) - self.assertIn("COLUMNS: 2", result.output) - - def test_bad_metadata_file(self): - result = self.runner.invoke( - tools, ['inspect-metadata', self.bad_metadata_file]) - - self.assertEqual(result.exit_code, 1) - self.assertIn("'wrong'", result.output) - - def test_tsv(self): - result = self.runner.invoke(tools, [ - 'inspect-metadata', self.metadata_file_mixed_types, '--tsv']) - - self.assertEqual(result.exit_code, 0) - self.assertIn('COLUMN NAME\tTYPE', result.output) - self.assertIn("numbers\tnumeric", result.output) - self.assertIn("strings\tcategorical", result.output) - - self.assertNotIn("=", result.output) - self.assertNotIn("IDS:", result.output) - self.assertNotIn("COLUMNS:", result.output) - - def test_merged_metadata(self): - result = self.runner.invoke(tools, [ - 'inspect-metadata', - self.metadata_artifact, - self.metadata_file_mixed_types]) - - self.assertEqual(result.exit_code, 0) - self.assertIn('COLUMN NAME TYPE', result.output) - self.assertIn("=========== ===========", result.output) - self.assertIn("a categorical", result.output) - self.assertIn("b categorical", result.output) - self.assertIn("numbers numeric", result.output) - self.assertIn("strings categorical", result.output) - self.assertIn("IDS: 1", result.output) # only 1 ID is shared - self.assertIn("COLUMNS: 4", result.output) - - def test_export_to_dir_w_format(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceDirectoryFormat' - ]) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.isdir(output_path)) - - def test_export_to_dir_no_format(self): - output_path = os.path.join(self.tempdir, 'output') - self.runner.invoke(tools, [ - 'export', '--input-path', self.viz, '--output-path', output_path - ]) - - self.assertTrue(os.path.isdir(output_path)) - self.assertIn('index.html', os.listdir(output_path)) - self.assertIn('index.tsv', os.listdir(output_path)) - - def test_export_to_file(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceFormatV2' - ]) - - with open(output_path, 'r') as f: - file = f.read() - self.assertEqual(result.exit_code, 0) - self.assertIn('0', file) - self.assertIn('42', file) - self.assertIn('43', file) - - def test_export_to_file_creates_directories(self): - output_path = os.path.join(self.tempdir, 'somewhere', 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceFormatV2' - ]) - - with open(output_path, 'r') as f: - file = f.read() - self.assertEqual(result.exit_code, 0) - self.assertIn('0', file) - self.assertIn('42', file) - self.assertIn('43', file) - - def test_export_visualization_to_dir(self): - output_path = os.path.join(self.tempdir, 'output') - self.runner.invoke(tools, [ - 'export', '--input-path', self.viz, '--output-path', output_path - ]) - - self.assertIn('index.html', os.listdir(output_path)) - self.assertIn('index.tsv', os.listdir(output_path)) - self.assertTrue(os.path.isdir(output_path)) - - def test_export_visualization_w_format(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.viz, '--output-path', output_path, - '--output-format', 'IntSequenceDirectoryFormat' - ]) - - self.assertEqual(result.exit_code, 1) - self.assertIn('visualization', result.output) - self.assertIn('--output-format', result.output) - - def test_export_path_file_is_replaced(self): - output_path = os.path.join(self.tempdir, 'output') - with open(output_path, 'w') as file: - file.write('HelloWorld') - self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceFormatV2' - ]) - with open(output_path, 'r') as f: - file = f.read() - self.assertNotIn('HelloWorld', file) - - def test_export_to_file_with_format_success_message(self): - output_path = os.path.join(self.tempdir, 'output.int') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceFormatV2' - ]) - success = 'Exported %s as IntSequenceFormatV2 to file %s\n' % ( - self.ints1, output_path) - self.assertEqual(success, result.output) - - def test_export_to_dir_without_format_success_message(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path - ]) - success = 'Exported %s as IntSequenceDirectoryFormat to '\ - 'directory %s\n' % (self.ints1, output_path) - self.assertEqual(success, result.output) - - def test_export_visualization_to_dir_success_message(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.viz, '--output-path', output_path - ]) - success = 'Exported %s as Visualization to '\ - 'directory %s\n' % (self.viz, output_path) - self.assertEqual(success, result.output) - - def test_extract_to_dir_success_message(self): - result = self.runner.invoke(tools, [ - 'extract', '--input-path', self.ints1, - '--output-path', self.tempdir - ]) - success = 'Extracted %s to directory %s' % (self.ints1, self.tempdir) - self.assertIn(success, result.output) - - def test_import_from_directory_without_format_success_message(self): - output_path = os.path.join(self.tempdir, 'output.qza') - result = self.runner.invoke(tools, [ - 'import', '--input-path', self.ints2, '--type', 'IntSequence1', - '--output-path', output_path - ]) - success = 'Imported %s as IntSequenceDirectoryFormat to '\ - '%s\n' % (self.ints2, output_path) - self.assertEqual(success, result.output) - - def test_import_from_file_with_format_success_message(self): - output_path = os.path.join(self.tempdir, 'output.qza') - result = self.runner.invoke(tools, [ - 'import', '--input-path', os.path.join(self.ints2, 'ints.txt'), - '--type', 'IntSequence1', - '--output-path', output_path, - '--input-format', 'IntSequenceFormat' - ]) - success = 'Imported %s as IntSequenceFormat to '\ - '%s\n' % (os.path.join(self.ints2, 'ints.txt'), output_path) - self.assertEqual(success, result.output) - - -class TestExportToFileFormat(TestInspectMetadata): - def setUp(self): - super().setUp() - # Working directory is changed to temp directory to prevent cluttering - # the repo directory with test files - self.current_dir = os.getcwd() - os.chdir(self.tempdir) - - def tearDown(self): - super().tearDown() - os.chdir(self.current_dir) - - def test_export_file_format(self): - output_path = os.path.join(os.getcwd(), 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceFormat' - ]) - success = 'Exported %s as IntSequenceFormat to file %s\n' % \ - (self.ints1, output_path) - self.assertEqual(success, result.output) - - def test_export_dir_format(self): - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', os.getcwd(), - '--output-format', 'IntSequenceDirectoryFormat' - ]) - - success = 'Exported %s as IntSequenceDirectoryFormat to directory ' \ - '%s\n' % (self.ints1, os.getcwd()) - self.assertEqual(success, result.output) - - def test_export_dir_format_nested(self): - output_path = os.path.join(os.getcwd(), 'output') - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.ints1, '--output-path', output_path, - '--output-format', 'IntSequenceDirectoryFormat' - ]) - - success = 'Exported %s as IntSequenceDirectoryFormat to directory ' \ - '%s\n' % (self.ints1, output_path) - self.assertEqual(success, result.output) - - def test_export_to_filename_without_path(self): - output_path = 'output' - result = self.runner.invoke(tools, [ - 'export', '--input-path', self.viz, '--output-path', output_path - ]) - success = 'Exported %s as Visualization to '\ - 'directory %s\n' % (self.viz, output_path) - self.assertEqual(success, result.output) - - -class TestImport(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.runner = CliRunner() - - cls.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - cls.in_dir1 = os.path.join(cls.tempdir, 'input1') - os.mkdir(cls.in_dir1) - with open(os.path.join(cls.in_dir1, 'ints.txt'), 'w') as fh: - for i in range(5): - fh.write(f'{i}\n') - fh.write('a\n') - - cls.in_dir2 = os.path.join(cls.tempdir, 'input2') - os.mkdir(cls.in_dir2) - with open(os.path.join(cls.in_dir2, 'ints.txt'), 'w') as fh: - fh.write('1\n') - fh.write('a\n') - fh.write('3\n') - - cls.cache = Cache(os.path.join(cls.tempdir, 'new_cache')) - - @classmethod - def tearDownClass(cls): - shutil.rmtree(cls.tempdir) - - def test_import_min_validate(self): - out_fp = os.path.join(self.tempdir, 'out1.qza') - - # import with min allows format error outside of min purview - # (validate level min checks only first 5 items) - result = self.runner.invoke(tools, [ - 'import', '--type', 'IntSequence1', '--input-path', self.in_dir1, - '--output-path', out_fp, '--validate-level', 'min' - ]) - self.assertEqual(result.exit_code, 0) - - # import with max should catch all format errors, max is default - result = self.runner.invoke(tools, [ - 'import', '--type', 'IntSequence1', '--input-path', - self.in_dir1, '--output-path', out_fp - ]) - self.assertEqual(result.exit_code, 1) - self.assertIn('Line 6 is not an integer', result.output) - - out_fp = os.path.join(self.tempdir, 'out2.qza') - - # import with min catches format errors within its purview - result = self.runner.invoke(tools, [ - 'import', '--type', 'IntSequence1', '--input-path', - self.in_dir2, '--output-path', out_fp, '--validate-level', 'min' - ]) - self.assertEqual(result.exit_code, 1) - self.assertIn('Line 2 is not an integer', result.output) - - def test_cache_import_min_validate(self): - # import with min allows format error outside of min purview - # (validate level min checks only first 5 items) - result = self.runner.invoke(tools, [ - 'cache-import', '--type', 'IntSequence1', '--input-path', - self.in_dir1, '--cache', str(self.cache.path), '--key', 'foo', - '--validate-level', 'min' - ]) - self.assertEqual(result.exit_code, 0) - - # import with max should catch all format errors, max is default - result = self.runner.invoke(tools, [ - 'cache-import', '--type', 'IntSequence1', '--input-path', - self.in_dir1, '--cache', str(self.cache.path), '--key', 'foo' - ]) - self.assertEqual(result.exit_code, 1) - self.assertIn('Line 6 is not an integer', result.output) - - # import with min catches format errors within its purview - result = self.runner.invoke(tools, [ - 'cache-import', '--type', 'IntSequence1', '--input-path', - self.in_dir2, '--cache', str(self.cache.path), '--key', 'foo', - '--validate-level', 'min' - ]) - self.assertEqual(result.exit_code, 1) - self.assertIn('Line 2 is not an integer', result.output) - - -class TestCacheTools(unittest.TestCase): - def setUp(self): - get_dummy_plugin() - - self.runner = CliRunner() - self.plugin_command = RootCommand().get_command( - ctx=None, name='dummy-plugin') - self.tempdir = \ - tempfile.TemporaryDirectory(prefix='rachis-cli-test-temp-') - - self.art1 = Artifact.import_data('IntSequence1', [0, 1, 2]) - self.art2 = Artifact.import_data('IntSequence1', [3, 4, 5]) - self.art3 = Artifact.import_data('IntSequence1', [6, 7, 8]) - self.art4 = Artifact.import_data('IntSequence2', [9, 10, 11]) - self.to_import = os.path.join(self.tempdir.name, 'to_import') - self.art1.export_data(self.to_import) - self.cache = Cache(os.path.join(self.tempdir.name, 'new_cache')) - - def tearDown(self): - self.tempdir.cleanup() - - def test_cache_create(self): - cache_path = os.path.join(self.tempdir.name, 'created_cache') - - result = self.runner.invoke( - tools, ['cache-create', '--cache', cache_path]) - - success = "Created cache at '%s'\n" % cache_path - self.assertEqual(success, result.output) - self.assertTrue(Cache.is_cache(cache_path)) - - def test_cache_remove(self): - self.cache.save(self.art1, 'key') - self.assertTrue('key' in self.cache.get_keys()) - - result = self.runner.invoke( - tools, - ['cache-remove', '--cache', str(self.cache.path), '--key', 'key']) - - success = \ - f"Removed key 'key' from cache '{str(self.cache.path)}'\n" \ - f"Removed key(s) '('key',)' from cache '{str(self.cache.path)}'\n" - self.assertEqual(success, result.output) - self.assertFalse('key' in self.cache.get_keys()) - - def test_cache_remove_multiple(self): - self.cache.save(self.art1, 'key3') - self.cache.save(self.art1, 'key2') - self.cache.save(self.art1, 'key1') - - keys = self.cache.get_keys() - self.assertEqual(['key1', 'key2', 'key3'], keys) - - result = self.runner.invoke( - tools, - ['cache-remove', '--cache', str(self.cache.path), '--key', 'key1', - '--key', 'key2'] - ) - - success = \ - f"Removed key 'key1' from cache '{str(self.cache.path)}'\n" \ - f"Removed key 'key2' from cache '{str(self.cache.path)}'\n" \ - "Removed key(s) '('key1', 'key2')' from cache " \ - f"'{str(self.cache.path)}'\n" - - new_keys = self.cache.get_keys() - - self.assertEqual(success, result.output) - self.assertFalse('key1' in new_keys) - self.assertFalse('key2' in new_keys) - self.assertTrue('key3' in new_keys) - - def test_cache_garbage_collection(self): - # Data referenced directly by key - self.cache.save(self.art1, 'foo') - # Data referenced by pool that is referenced by key - pool = self.cache.create_pool(key='bar') - pool.save(self.art2) - # We will be manually deleting the keys that back these two - self.cache.save(self.art3, 'baz') - pool = self.cache.create_pool(key='qux') - pool.save(self.art4) - - # What we expect to see before and after gc - expected_pre_gc_contents = \ - set(('./VERSION', 'keys/foo', 'keys/bar', - 'keys/baz', 'keys/qux', - f'pools/bar/{self.art2.uuid}', - f'pools/qux/{self.art4.uuid}', - f'data/{self.art1.uuid}', f'data/{self.art2.uuid}', - f'data/{self.art3.uuid}', f'data/{self.art4.uuid}')) - - expected_post_gc_contents = \ - set(('./VERSION', 'keys/foo', 'keys/bar', - f'pools/bar/{self.art2.uuid}', - f'data/{self.art1.uuid}', f'data/{self.art2.uuid}')) - - # Assert cache looks how we want pre gc - pre_gc_contents = _get_cache_contents(self.cache) - self.assertEqual(expected_pre_gc_contents, pre_gc_contents) - - # Delete keys - self.cache.remove(self.cache.keys / 'baz') - self.cache.remove(self.cache.keys / 'qux') - - # Make sure Python's garbage collector gets the process pool symlinks - # to the artifact that was keyed on baz and the one in the qux pool - gc.collect() - result = self.runner.invoke( - tools, - ['cache-garbage-collection', '--cache', str(self.cache.path)]) - - success = "Ran garbage collection on cache at '%s'\n" % self.cache.path - self.assertEqual(success, result.output) - - # Assert cache looks how we want post gc - post_gc_contents = _get_cache_contents(self.cache) - self.assertEqual(expected_post_gc_contents, post_gc_contents) - - def test_cache_store(self): - artifact = os.path.join(self.tempdir.name, 'artifact.qza') - self.art1.save(artifact) - - result = self.runner.invoke( - tools, ['cache-store', '--cache', str(self.cache.path), - '--artifact-path', artifact, '--key', 'key']) - - success = "Saved the artifact '%s' to the cache '%s' under the key " \ - "'key'\n" % (artifact, self.cache.path) - self.assertEqual(success, result.output) - - def test_cache_fetch(self): - artifact = os.path.join(self.tempdir.name, 'artifact.qza') - self.cache.save(self.art1, 'key') - - result = self.runner.invoke( - tools, ['cache-fetch', '--cache', str(self.cache.path), - '--key', 'key', '--output-path', artifact]) - - success = "Loaded artifact with the key 'key' from the cache '%s' " \ - "and saved it to the file '%s'\n" % (self.cache.path, artifact) - self.assertEqual(success, result.output) - - def test_cache_roundtrip(self): - in_artifact = os.path.join(self.tempdir.name, 'in_artifact.qza') - out_artifact = os.path.join(self.tempdir.name, 'out_artifact.qza') - - self.art1.save(in_artifact) - - result = self.runner.invoke( - tools, ['cache-store', '--cache', str(self.cache.path), - '--artifact-path', in_artifact, '--key', 'key']) - - success = "Saved the artifact '%s' to the cache '%s' under the key " \ - "'key'\n" % (in_artifact, self.cache.path) - self.assertEqual(success, result.output) - - result = self.runner.invoke( - tools, ['cache-fetch', '--cache', str(self.cache.path), - '--key', 'key', '--output-path', out_artifact]) - - success = "Loaded artifact with the key 'key' from the cache '%s' " \ - "and saved it to the file '%s'\n" % (self.cache.path, out_artifact) - self.assertEqual(success, result.output) - - artifact = Artifact.load(out_artifact) - self.assertEqual([0, 1, 2], artifact.view(list)) - - def test_cache_status(self): - success_template = \ - "Status of the cache at the path '%s':\n\n%s\n\n%s\n" - - # Empty cache - result = self.runner.invoke( - tools, ['cache-status', '--cache', str(self.cache.path)]) - success = \ - success_template % (str(self.cache.path), 'No data keys in cache', - 'No pool keys in cache') - self.assertEqual(success, result.output) - - # Cache with only data - in_artifact = os.path.join(self.tempdir.name, 'in_artifact.qza') - self.art1.save(in_artifact) - self.runner.invoke( - tools, ['cache-store', '--cache', str(self.cache.path), - '--artifact-path', in_artifact, '--key', 'key']) - - result = self.runner.invoke( - tools, ['cache-status', '--cache', str(self.cache.path)]) - data_output = 'Data keys in cache:\ndata: key -> %s' % \ - str(Result.peek(self.cache.data / str(self.art1.uuid))) - success = \ - success_template % (str(self.cache.path), data_output, - 'No pool keys in cache') - self.assertEqual(success, result.output) - - # Cache with data and pool - pool = self.cache.create_pool(key='pool') - pool.save(self.art2) - - result = self.runner.invoke( - tools, ['cache-status', '--cache', str(self.cache.path)]) - pool_output = 'Pool keys in cache:\npool: pool -> size = 1' - success = \ - success_template % (str(self.cache.path), data_output, - pool_output) - self.assertEqual(success, result.output) - - def test_cache_import(self): - self.max_diff = None - result = self.runner.invoke( - tools, ['cache-import', '--type', 'IntSequence1', '--input-path', - self.to_import, '--cache', f'{self.cache.path}', '--key', - 'foo']) - success = 'Imported %s as IntSequenceDirectoryFormat to %s:foo\n' % \ - (self.to_import, self.cache.path) - self.assertEqual(success, result.output) - - -def _get_cache_contents(cache): - """Gets contents of cache not including contents of the artifacts - themselves relative to the root of the cache - """ - cache_contents = set() - - rel_keys = os.path.relpath(cache.keys, cache.path) - rel_data = os.path.relpath(cache.data, cache.path) - rel_pools = os.path.relpath(cache.pools, cache.path) - rel_cache = os.path.relpath(cache.path, cache.path) - - for key in os.listdir(cache.keys): - cache_contents.add(os.path.join(rel_keys, key)) - - for art in os.listdir(cache.data): - cache_contents.add(os.path.join(rel_data, art)) - - for pool in os.listdir(cache.pools): - for link in os.listdir(os.path.join(cache.pools, pool)): - cache_contents.add(os.path.join(rel_pools, pool, link)) - - for elem in os.listdir(cache.path): - if os.path.isfile(os.path.join(cache.path, elem)): - cache_contents.add(os.path.join(rel_cache, elem)) - - return cache_contents - - -class TestPeek(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - # create artifact - self.artifact = os.path.join(self.tempdir, 'artifact.qza') - Artifact.import_data( - 'Mapping', {'foo': 'bar'}).save(self.artifact) - - # create visualization - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - self.viz = os.path.join(self.tempdir, 'viz.qzv') - - self.ints = os.path.join(self.tempdir, 'ints.qza') - ints = Artifact.import_data( - 'IntSequence1', [0, 42, 43], list) - ints.save(self.ints) - - self.runner.invoke( - command, ['most-common-viz', '--i-ints', self.ints, - '--o-visualization', self.viz, '--verbose']) - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_single_artifact(self): - result = self.runner.invoke(tools, ['peek', self.artifact]) - self.assertEqual(result.exit_code, 0) - self.assertIn("UUID:", result.output) - self.assertIn("Type:", result.output) - self.assertIn("Data format:", result.output) - self.assertEqual(result.output.count('\n'), 3) - - def test_single_visualization(self): - result = self.runner.invoke(tools, ['peek', self.viz]) - self.assertEqual(result.exit_code, 0) - self.assertIn("UUID:", result.output) - self.assertIn("Type:", result.output) - self.assertNotIn("Data format:", result.output) - self.assertEqual(result.output.count('\n'), 2) - - def test_artifact_and_visualization(self): - result = self.runner.invoke(tools, ['peek', self.artifact, self.viz]) - self.assertEqual(result.exit_code, 0) - self.assertIn("UUID", result.output) - self.assertIn("Type", result.output) - self.assertIn("Data Format", result.output) - self.assertIn("N/A", result.output) - self.assertEqual(result.output.count('\n'), 3) - - def test_single_file_tsv(self): - result = self.runner.invoke(tools, ['peek', '--tsv', self.artifact]) - self.assertIn("Filename\tType\tUUID\tData Format\n", result.output) - self.assertIn("artifact.qza", result.output) - self.assertEqual(result.output.count('\t'), 6) - self.assertEqual(result.output.count('\n'), 2) - - def test_multiple_file_tsv(self): - result = self.runner.invoke(tools, ['peek', '--tsv', self.artifact, - self.viz]) - self.assertIn("Filename\tType\tUUID\tData Format\n", result.output) - self.assertIn("artifact.qza", result.output) - self.assertIn("viz.qzv", result.output) - self.assertEqual(result.output.count('\t'), 9) - self.assertEqual(result.output.count('\n'), 3) - - -class TestListTypes(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - self.pm = PluginManager() - - def tearDown(self): - pass - - def test_list_all_types(self): - result = self.runner.invoke(tools, ['list-types']) - self.assertEqual(result.exit_code, 0) - - for name, artifact_class_record in self.pm.artifact_classes.items(): - self.assertIn(name, result.output) - self.assertIn(artifact_class_record.description, result.output) - - def test_list_types_fuzzy(self): - types = list(self.pm.artifact_classes)[:5] - result = self.runner.invoke(tools, ['list-types', *types]) - self.assertEqual(result.exit_code, 0) - - # split on \n\n because types and their description are separated - # by two newlines - # len - 1 because split includes '' for the last \n\n split - self.assertGreaterEqual(len(result.output.split('\n\n')) - 1, - len(types)) - - def test_list_types_strict(self): - types = list(self.pm.artifact_classes)[:5] - result = self.runner.invoke(tools, ['list-types', '--strict', *types]) - self.assertEqual(result.exit_code, 0) - self.assertEqual(len(result.output.split('\n\n')) - 1, len(types)) - - result = self.runner.invoke(tools, ['list-types', '--strict', - types[0] + 'x']) - self.assertEqual(result.exit_code, 0) - self.assertEqual(len(result.output), 0) - - result = self.runner.invoke(tools, ['list-types', '--strict', *types, - types[0] + 'x']) - self.assertEqual(result.exit_code, 0) - self.assertEqual(len(result.output.split('\n\n')) - 1, len(types)) - - def test_list_types_tsv(self): - result = self.runner.invoke(tools, ['list-types', '--tsv']) - self.assertEqual(result.exit_code, 0) - - # len - 1 because \n split produces a final '' - self.assertEqual(len(result.output.split('\n')) - 1, - len(self.pm.artifact_classes)) - - no_description_count = 0 - for name, artifact_class_record in self.pm.artifact_classes.items(): - self.assertIn(name, result.output) - self.assertIn(artifact_class_record.description, result.output) - if artifact_class_record.description == '': - no_description_count += 1 - - self.assertEqual(no_description_count, result.output.count('\t\n')) - - -class TestListFormats(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - self.pm = PluginManager() - - def tearDown(self): - pass - - def test_list_all_importable_formats(self): - result = self.runner.invoke(tools, ['list-formats', '--importable']) - self.assertEqual(result.exit_code, 0) - - for name, format_record in self.pm.importable_formats.items(): - self.assertIn(name, result.output) - docstring = format_record.format.__doc__ - if docstring: - description = docstring.split('\n\n')[0].strip() - for word in description: - self.assertIn(word.strip(), result.output) - - def test_list_all_exportable_formats(self): - result = self.runner.invoke(tools, ['list-formats', '--exportable']) - self.assertEqual(result.exit_code, 0) - - for name, format_record in self.pm.exportable_formats.items(): - self.assertIn(name, result.output) - docstring = format_record.format.__doc__ - if docstring: - description = docstring.split('\n\n')[0].strip() - for word in description: - self.assertIn(word.strip(), result.output) - - def test_list_formats_fuzzy(self): - formats = list(self.pm.importable_formats)[:5] - result = self.runner.invoke(tools, ['list-formats', '--importable', - *formats]) - self.assertEqual(result.exit_code, 0) - - # see TestListTypes.test_list_types_fuzzy - self.assertGreaterEqual(len(result.output.split('\n\n')) - 1, - len(formats)) - - def test_list_formats_strict(self): - formats = list(self.pm.exportable_formats)[:5] - result = self.runner.invoke(tools, ['list-formats', '--exportable', - '--strict', *formats]) - self.assertEqual(result.exit_code, 0) - self.assertEqual(len(result.output.split('\n\n')) - 1, len(formats)) - - result = self.runner.invoke(tools, ['list-formats', '--exportable', - '--strict', formats[0] + 'x']) - self.assertEqual(result.exit_code, 0) - self.assertEqual(len(result.output), 0) - - result = self.runner.invoke(tools, ['list-formats', '--exportable', - '--strict', *formats, - formats[0] + 'x']) - self.assertEqual(result.exit_code, 0) - self.assertEqual(len(result.output.split('\n\n')) - 1, len(formats)) - - def test_list_formats_tsv(self): - result = self.runner.invoke(tools, ['list-formats', '--importable', - '--tsv']) - self.assertEqual(result.exit_code, 0) - - # len - 1 because \n split produces a final '' - self.assertEqual(len(result.output.split('\n')) - 1, - len(self.pm.importable_formats)) - - no_description_count = 0 - for name, format_record in self.pm.importable_formats.items(): - self.assertIn(name, result.output) - docstring = format_record.format.__doc__ - if docstring: - description = docstring.split('\n\n')[0].strip() - for word in description: - self.assertIn(word.strip(), result.output) - - if format_record.format.__doc__ is None: - no_description_count += 1 - self.assertEqual(no_description_count, result.output.count('\t\n')) - - -class TestReplay(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - self.pm = PluginManager() - self.dp = self.pm.plugins['dummy-plugin'] - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-replay-temp-') - - # contrive artifacts with different sorts of provenance - int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) - int_seq2 = Artifact.import_data('IntSequence1', [4, 5, 6]) - int_seq3 = Artifact.import_data('IntSequence2', [7, 8]) - concat_ints = self.dp.actions['concatenate_ints'] - concated_ints, = concat_ints(int_seq1, int_seq2, int_seq3, 9, 0) - concated_ints.save(os.path.join(self.tempdir, 'concated_ints.qza')) - - outer_dir = os.path.join(self.tempdir, 'outer_dir') - inner_dir = os.path.join(self.tempdir, 'outer_dir', 'inner_dir') - os.mkdir(outer_dir) - os.mkdir(inner_dir) - shutil.copy(os.path.join(self.tempdir, 'concated_ints.qza'), outer_dir) - int_seq = Artifact.import_data('IntSequence1', [1, 2, 3, 4]) - left_ints, _ = self.dp.actions['split_ints'](int_seq) - left_ints.save(os.path.join(inner_dir, 'left_ints.qza')) - - mapping = Artifact.import_data('Mapping', {'rachis': 2, 'triangle': 3}) - int_seq_with_md, = self.dp.actions['identity_with_metadata']( - int_seq1, - mapping.view(Metadata)) - int_seq_with_md.save(os.path.join(self.tempdir, 'int_seq_with_md.qza')) - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_replay_provenance_cache(self): - cache = Cache(os.path.join(self.tempdir, 'cache')) - int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) - cache.save(int_seq1, 'int_seq1') - cache_fp = os.path.join(self.tempdir, 'cache:int_seq1') - - self.runner.invoke( - tools, - ['replay-provenance', 'in-fp', cache_fp, '--out-fp', self.tempdir] - ) - - def test_replay_provenance(self): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp, 'r') as fh: - rendered = fh.read() - - self.assertIn('rachis tools import', rendered) - self.assertIn('--type \'IntSequence1\'', rendered) - self.assertIn('--type \'IntSequence2\'', rendered) - self.assertIn('--input-path ', rendered) - self.assertIn('--output-path int-sequence1-0.qza', rendered) - self.assertIn('--output-path int-sequence1-1.qza', rendered) - self.assertIn('--output-path int-sequence2-0.qza', rendered) - - self.assertIn('rachis dummy-plugin concatenate-ints', rendered) - self.assertRegex(rendered, '--i-ints[12] int-sequence1-0.qza') - self.assertRegex(rendered, '--i-ints[12] int-sequence1-1.qza') - self.assertIn('--i-ints3 int-sequence2-0.qza', rendered) - self.assertIn('--p-int1 9', rendered) - self.assertIn('--p-int2 0', rendered) - self.assertIn('--o-concatenated-ints concatenated-ints-0.qza', - rendered) - - def test_replay_provenance_python(self): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, - '--usage-driver', 'python3'] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp, 'r') as fh: - rendered = fh.read() - - self.assertIn('from rachis import Artifact', rendered) - self.assertIn('Artifact.import_data', rendered) - self.assertIn('dummy_plugin_actions.concatenate_ints', rendered) - - def test_replay_provenance_recurse(self): - """ - If the directory is parsed recursively, both the concated_ints.qza and - left_ints.qza will be captured. - """ - in_fp = os.path.join(self.tempdir, 'outer_dir') - out_fp = os.path.join(self.tempdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, - '--usage-driver', 'python3', '--recurse'] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp, 'r') as fh: - rendered = fh.read() - - self.assertIn('dummy_plugin_actions.concatenate_ints', rendered) - self.assertIn('dummy_plugin_actions.split_ints', rendered) - - def test_replay_provenance_use_md_without_parse(self): - in_fp = os.path.join(self.tempdir, 'outer_dir') - out_fp = os.path.join(self.tempdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, - '--no-parse-metadata', '--use-recorded-metadata'] - ) - self.assertEqual(result.exit_code, 1) - self.assertIsInstance(result.exception, ValueError) - self.assertRegex(str(result.exception), - 'Metadata not parsed for replay') - - @patch('qiime2.sdk.util.get_available_usage_drivers', - return_value={'cli': ReplayCLIUsage}) - def test_replay_provenance_usage_driver_not_available(self, patch): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', in_fp, '--out-fp', out_fp, - '--usage-driver', 'python3'] - ) - self.assertEqual(result.exit_code, 1) - self.assertIsInstance(result.exception, ValueError) - self.assertIn( - 'python3 usage driver is not available', str(result.exception) - ) - - def test_replay_citations(self): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'citations.bib') - result = self.runner.invoke( - tools, - ['replay-citations', '--in-fp', in_fp, '--out-fp', out_fp] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp) as fh: - bib_database = bp.load(fh) - - # use .*? to non-greedily match version strings - exp = [ - r'action\|dummy-plugin:.*?\|method:concatenate_ints\|0', - r'framework\|rachis:.*?\|0', - r'plugin\|dummy-plugin:.*?\|0', - r'plugin\|dummy-plugin:.*?\|1', - r'transformer\|dummy-plugin:.*?\|builtins:list->' - r'IntSequenceDirectoryFormat\|0', - r'transformer\|dummy-plugin:.*?\|builtins:list->' - r'IntSequenceV2DirectoryFormat\|4', - r'transformer\|dummy-plugin:.*?\|builtins:list->' - r'IntSequenceV2DirectoryFormat\|5', - r'transformer\|dummy-plugin:.*?\|builtins:list->' - r'IntSequenceV2DirectoryFormat|6', - r'transformer\|dummy-plugin:.*?\|builtins:list->' - r'IntSequenceV2DirectoryFormat\|8', - r'view\|dummy-plugin:.*?\|IntSequenceDirectoryFormat\|0' - ] - - self.assertEqual(len(exp), len(bib_database.entries)) - - all_records_str = '' - for record in bib_database.entries_dict.keys(): - all_records_str += f' {record}' - for record in exp: - self.assertRegex(all_records_str, record) - - def test_replay_citations_no_deduplicate(self): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'citations.bib') - result = self.runner.invoke( - tools, - ['replay-citations', '--in-fp', in_fp, '--out-fp', out_fp, - '--no-deduplicate'] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp) as fh: - bib_database = bp.load(fh) - self.assertEqual(28, len(bib_database.entries)) - - with open(out_fp) as fh: - file_contents = fh.read() - framework_citations = \ - re.compile(r'framework\|rachis:.*?\|0.*' * 4, re.DOTALL) - self.assertRegex(file_contents, framework_citations) - - def test_replay_citations_cache(self): - cache = Cache(os.path.join(self.tempdir, 'cache')) - int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) - cache.save(int_seq1, 'int_seq1') - cache_fp = os.path.join(self.tempdir, 'cache:int_seq1') - - self.runner.invoke( - tools, - ['replay-citations', 'in-fp', cache_fp, '--out-fp', self.tempdir] - ) - - def test_replay_supplement(self): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'supplement.zip') - result = self.runner.invoke( - tools, - ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] - ) - self.assertEqual(result.exit_code, 0) - self.assertTrue(zipfile.is_zipfile(out_fp)) - - exp = { - 'supplement/', - 'supplement/python3_replay.py', - 'supplement/cli_replay.sh', - 'supplement/citations.bib' - } - with zipfile.ZipFile(out_fp, 'r') as zfh: - self.assertEqual(exp, set(zfh.namelist())) - - def test_replay_supplement_with_metadata(self): - in_fp = os.path.join(self.tempdir, 'int_seq_with_md.qza') - out_fp = os.path.join(self.tempdir, 'supplement.zip') - result = self.runner.invoke( - tools, - ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] - ) - self.assertEqual(result.exit_code, 0) - self.assertTrue(zipfile.is_zipfile(out_fp)) - - exp = { - 'supplement/', - 'supplement/python3_replay.py', - 'supplement/cli_replay.sh', - 'supplement/citations.bib', - 'supplement/recorded_metadata/', - 'supplement/recorded_metadata/' - 'dummy_plugin_identity_with_metadata_0/', - 'supplement/recorded_metadata/' - 'dummy_plugin_identity_with_metadata_0/metadata_0.tsv', - } - with zipfile.ZipFile(out_fp, 'r') as zfh: - self.assertEqual(exp, set(zfh.namelist())) - - def test_replay_supplement_no_metadata_dump(self): - in_fp = os.path.join(self.tempdir, 'int_seq_with_md.qza') - out_fp = os.path.join(self.tempdir, 'supplement.zip') - result = self.runner.invoke( - tools, - ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp, - '--no-dump-recorded-metadata'] - ) - self.assertEqual(result.exit_code, 0) - self.assertTrue(zipfile.is_zipfile(out_fp)) - - not_exp = 'recorded_metadata/' - with zipfile.ZipFile(out_fp, 'r') as zfh: - self.assertNotIn(not_exp, set(zfh.namelist())) - - @patch('qiime2.sdk.util.get_available_usage_drivers', return_value={}) - def test_replay_supplement_usage_driver_not_available(self, patch): - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(self.tempdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] - ) - self.assertEqual(result.exit_code, 1) - self.assertIsInstance(result.exception, ValueError) - self.assertIn( - 'no available usage drivers', str(result.exception) - ) - - def test_replay_supplement_zipfile(self): - with tempfile.TemporaryDirectory() as tempdir: - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - out_fp = os.path.join(tempdir, 'supplement.zip') - - result = self.runner.invoke( - tools, - ['replay-supplement', '--in-fp', in_fp, '--out-fp', out_fp] - ) - self.assertEqual(result.exit_code, 0) - self.assertTrue(zipfile.is_zipfile(out_fp)) - - unzipped_path = os.path.join(tempdir, 'extracted') - os.makedirs(unzipped_path) - with zipfile.ZipFile(out_fp, 'r') as zfh: - zfh.extractall(unzipped_path) - - self.assertEqual(os.listdir(unzipped_path), ['supplement']) - - def test_replay_supplement_cache(self): - cache = Cache(os.path.join(self.tempdir, 'cache')) - int_seq1 = Artifact.import_data('IntSequence1', [1, 2, 3]) - cache.save(int_seq1, 'int_seq1') - cache_fp = os.path.join(self.tempdir, 'cache:int_seq1') - - self.runner.invoke( - tools, - ['replay-supplement', 'in-fp', cache_fp, '--out-fp', self.tempdir] - ) - - # Leave me alone I know the checksums don't match - @pytest.mark.filterwarnings('ignore::UserWarning') - def test_replay_param_not_found(self): - rachis_cli = RootCommand() - command = rachis_cli.get_command(ctx=None, name='dummy-plugin') - - in_fp = os.path.join(self.tempdir, 'concated_ints.qza') - left_path = os.path.join(self.tempdir, 'left.qza') - right_path = os.path.join(self.tempdir, 'right.qza') - - result = self.runner.invoke( - command, ['split-ints', '--i-ints', in_fp, - '--o-left', left_path, '--o-right', right_path, - '--verbose']) - - self.assertEqual(result.exit_code, 0) - - left = Artifact.load(left_path) - action_fp = os.path.join( - left._archiver.provenance_dir, 'action', 'action.yaml' - ) - with open(action_fp, 'r+') as action_fh: - action_string = action_fh.read() - action_yaml = yaml.safe_load(action_string) - action_yaml['action']['parameters'].append({'not': 'real'}) - - action_fh.write(yaml.dump(action_yaml)) - - with tempfile.TemporaryDirectory() as tmpdir: - out_fp = os.path.join(tmpdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', left_path, '--out-fp', - out_fp, '--no-dump-recorded-metadata'] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp, 'r') as fh: - rendered = fh.read() - - MISSING_PARAM = \ -""" - # FIXME: The following parameter name was not found in your current - # rachis environment. This may occur when the plugin version you have - # installed does not match the version used in the original analysis. - # Please see the docs and correct the parameter name before running. - --?-not real \\ -""" # noqa: E128 - self.assertIn(MISSING_PARAM, rendered) - - def test_replay_action_not_found(self): - datadir = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'data' - ) - artifact_fp = os.path.join(datadir, 'rarefied_table.qza') - - with tempfile.TemporaryDirectory() as tmpdir: - out_fp = os.path.join(tmpdir, 'rendered.txt') - result = self.runner.invoke( - tools, - ['replay-provenance', '--in-fp', artifact_fp, '--out-fp', - out_fp, '--no-dump-recorded-metadata'] - ) - self.assertEqual(result.exit_code, 0) - - with open(out_fp, 'r') as fh: - rendered = fh.read() - - import1 = \ -""" -rachis tools import \\ - --type 'Phylogeny[Rooted]' \\ - --input-path \\ - --output-path phylogeny-rooted-0.qza -""" # noqa: E128 - - import2 = \ -""" -rachis tools import \\ - --type 'FeatureTable[Frequency]' \\ - --input-path \\ - --output-path feature-table-frequency-0.qza -""" # noqa: E128 - - self.assertIn(import1, rendered) - self.assertIn(import2, rendered) - - FIXME_action = \ -""" -# FIXME: The following action was not found in your current rachis -# environment. Please ensure the action and its parameters are correct before -# running. -rachis diversity core-metrics-phylogenetic \\ - --?-table feature-table-frequency-0.qza \\ - --?-phylogeny phylogeny-rooted-0.qza \\ - --?-sampling-depth 13 \\ - --?-metadata .tsv \\ - --?-with-replacement False \\ - --?-n-jobs-or-threads 1 \\ - --?-ignore-missing-samples False \\ - --output-dir diversity-core-metrics-phylogenetic -""" # noqa: E128 - self.assertIn(FIXME_action, rendered) - - -class TestAnnotations(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - # artifact without any starting annotations - self.art1 = os.path.join(self.tempdir, 'ints1.qza') - Artifact.import_data('IntSequence1', [0, 1, 2]).save(self.art1) - - # artifact that will have one starting annotation - self.art2 = os.path.join(self.tempdir, 'ints2.qza') - Artifact.import_data('IntSequence1', [1, 2, 3]).save(self.art2) - - # add note to art2 - self.note1 = Note(name='mynote', text='my special text') - Artifact.load(self.art2).add_annotation(self.note1) - - # output path for self.art1 after adding annotation - self.output1 = os.path.join(self.tempdir, 'ints1_annotated.qza') - - # annotation path for note from file - self.note_file = os.path.join( - self.tempdir, 'note_file.txt') - with open(self.note_file, 'w') as f: - f.write('my special text') - - def tearDown(self): - shutil.rmtree(self.tempdir) - - # ANNOTATION_CREATE - def test_annotation_create_with_text_success(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Note', - '--name', 'mynote', '--text', 'my special text', - '--output-path', self.output1] - ) - # confirm the command doesn't produce an error - self.assertEqual(create_result.exit_code, 0) - - output_uuid = Result.load(self.output1).uuid - # this will just produce one annotation - for annotation in Result.load(self.output1).iter_annotations(): - annotation_uuid = annotation.id - - exp_namelist = { - f'{output_uuid}/checksums.sha512', - f'{output_uuid}/metadata.yaml', - f'{output_uuid}/VERSION', - f'{output_uuid}/annotations/{annotation_uuid}/checksums.sha512', - f'{output_uuid}/annotations/{annotation_uuid}/metadata.yaml', - f'{output_uuid}/annotations/{annotation_uuid}/note.txt', - f'{output_uuid}/provenance/metadata.yaml', - f'{output_uuid}/provenance/citations.bib', - f'{output_uuid}/provenance/VERSION', - f'{output_uuid}/provenance/conda-env.yaml', - f'{output_uuid}/provenance/action/action.yaml', - f'{output_uuid}/data/ints.txt' - } - - exp_contents = 'my special text' - - with zipfile.ZipFile(self.output1, 'r') as zfh: - # confirm file structure is what we expect - self.assertEqual(exp_namelist, set(zfh.namelist())) - - annotation = zfh.read( - f'{output_uuid}/annotations/{annotation_uuid}/note.txt' - ).decode('utf-8') - - # confirm the annotation file contains the text we expect - self.assertEqual(exp_contents, annotation) - - # make sure we see same results as w/inline text for input - def test_annotation_create_with_file_success(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Note', - '--name', 'mynote', '--file', self.note_file, - '--output-path', self.output1] - ) - # confirm the command doesn't produce an error - self.assertEqual(create_result.exit_code, 0) - - output_uuid = Result.load(self.output1).uuid - # this will just produce one annotation - for annotation in Result.load(self.output1).iter_annotations(): - annotation_uuid = annotation.id - - exp_namelist = { - f'{output_uuid}/checksums.sha512', - f'{output_uuid}/metadata.yaml', - f'{output_uuid}/VERSION', - f'{output_uuid}/annotations/{annotation_uuid}/checksums.sha512', - f'{output_uuid}/annotations/{annotation_uuid}/metadata.yaml', - f'{output_uuid}/annotations/{annotation_uuid}/note.txt', - f'{output_uuid}/provenance/metadata.yaml', - f'{output_uuid}/provenance/citations.bib', - f'{output_uuid}/provenance/VERSION', - f'{output_uuid}/provenance/conda-env.yaml', - f'{output_uuid}/provenance/action/action.yaml', - f'{output_uuid}/data/ints.txt' - } - - exp_contents = 'my special text' - - with zipfile.ZipFile(self.output1, 'r') as zfh: - # confirm file structure is what we expect - self.assertEqual(exp_namelist, set(zfh.namelist())) - - annotation = zfh.read( - f'{output_uuid}/annotations/{annotation_uuid}/note.txt' - ).decode('utf-8') - - # confirm the annotation file contains the text we expect - self.assertEqual(exp_contents, annotation) - - # make sure we don't run into any errors & see same result - # when overwriting the original artifact fp - def test_annotation_create_overwriting_input_file_success(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Note', - '--name', 'mynote', '--text', 'my special text', - '--output-path', self.art1] - ) - # confirm the command doesn't produce an error - self.assertEqual(create_result.exit_code, 0) - - output_uuid = Result.load(self.art1).uuid - # this will just produce one annotation - for annotation in Result.load(self.art1).iter_annotations(): - annotation_uuid = annotation.id - - exp_namelist = { - f'{output_uuid}/checksums.sha512', - f'{output_uuid}/metadata.yaml', - f'{output_uuid}/VERSION', - f'{output_uuid}/annotations/{annotation_uuid}/checksums.sha512', - f'{output_uuid}/annotations/{annotation_uuid}/metadata.yaml', - f'{output_uuid}/annotations/{annotation_uuid}/note.txt', - f'{output_uuid}/provenance/metadata.yaml', - f'{output_uuid}/provenance/citations.bib', - f'{output_uuid}/provenance/VERSION', - f'{output_uuid}/provenance/conda-env.yaml', - f'{output_uuid}/provenance/action/action.yaml', - f'{output_uuid}/data/ints.txt' - } - - exp_contents = 'my special text' - - with zipfile.ZipFile(self.art1, 'r') as zfh: - # confirm file structure is what we expect - self.assertEqual(exp_namelist, set(zfh.namelist())) - - annotation = zfh.read( - f'{output_uuid}/annotations/{annotation_uuid}/note.txt' - ).decode('utf-8') - - # confirm the annotation file contains the text we expect - self.assertEqual(exp_contents, annotation) - - def test_annotation_create_invalid_annotation_type_failure(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Foo', - '--name', 'mynote', '--text', 'my special text', - '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(create_result.exit_code, 1) - self.assertIn("Invalid value for '--annotation-type': 'Foo'", - create_result.output) - - def test_annotation_create_text_and_filepath_provided_failure(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Note', - '--name', 'mynote', '--text', 'my special text', - '--file', self.note_file, '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(create_result.exit_code, 2) - self.assertIn("Exactly one of `--text` or `--file` must be provided", - create_result.output) - - def test_annotation_create_no_text_or_filepath_provided_failure(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Note', - '--name', 'mynote', '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(create_result.exit_code, 2) - self.assertIn("Exactly one of `--text` or `--file` must be provided", - create_result.output) - - def test_annotation_create_invalid_annotation_name_failure(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art1, - '--annotation-type', 'Note', - '--name', '@#$%^&*', '--text', 'my special text', - '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(create_result.exit_code, 1) - self.assertIn('Name "@#$%^&*" is not a valid Python identifier', - str(create_result.exception)) - - def test_annotation_create_existing_annotation_name_failure(self): - create_result = self.runner.invoke( - tools, - ['annotation-create', '--input-path', self.art2, - '--annotation-type', 'Note', - '--name', 'mynote', '--file', self.note_file, - '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(create_result.exit_code, 1) - self.assertIn('Duplicate name detected when attempting to add ' - 'Annotation with name: "mynote"', create_result.output) - - # ANNOTATION_REMOVE - def test_annotation_remove_new_filepath_success(self): - remove_result = self.runner.invoke( - tools, - ['annotation-remove', '--input-path', self.art2, - '--name', 'mynote', '--output-path', self.output1] - ) - # confirm the command doesn't produce an error - self.assertEqual(remove_result.exit_code, 0) - - with zipfile.ZipFile(self.output1, 'r') as zfh: - # confirm annotations dir has been removed - self.assertNotIn('annotations', set(zfh.namelist())) - - def test_annotation_remove_existing_filepath_success(self): - remove_result = self.runner.invoke( - tools, - ['annotation-remove', '--input-path', self.art2, - '--name', 'mynote', '--output-path', self.art2] - ) - # confirm the command doesn't produce an error - self.assertEqual(remove_result.exit_code, 0) - - with zipfile.ZipFile(self.art2, 'r') as zfh: - # confirm annotations dir has been removed - self.assertNotIn('annotations', set(zfh.namelist())) - - def test_annotation_remove_no_annotations_failure(self): - remove_result = self.runner.invoke( - tools, - ['annotation-remove', '--input-path', self.art1, - '--name', 'mynote', '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(remove_result.exit_code, 1) - self.assertIn('No Annotation found with name: "mynote"', - remove_result.output) - - def test_annotation_remove_wrong_annotation_name_failure(self): - remove_result = self.runner.invoke( - tools, - ['annotation-remove', '--input-path', self.art2, - '--name', 'myspecialnote', '--output-path', self.output1] - ) - # confirm the command does produce an error - self.assertEqual(remove_result.exit_code, 1) - self.assertIn('No Annotation found with name: "myspecialnote"', - remove_result.output) - - # ANNOTATION_FETCH - def test_annotation_fetch_no_verbose_success(self): - fetch_result = self.runner.invoke( - tools, - ['annotation-fetch', '--input-path', self.art2, '--name', 'mynote'] - ) - # confirm the command doesn't produce an error - self.assertEqual(fetch_result.exit_code, 0) - # ensure the annotation type & name we expect is present in the output - self.assertRegex(fetch_result.output, - r'name:\s*mynote\s*type:\s*Note\b') - - def test_annotation_fetch_verbose_success(self): - fetch_result = self.runner.invoke( - tools, - ['annotation-fetch', '--input-path', self.art2, - '--name', 'mynote', '--verbose'] - ) - # confirm the command doesn't produce an error - self.assertEqual(fetch_result.exit_code, 0) - # ensure the annotation type & name we expect is present in the output - self.assertRegex( - fetch_result.output, - r'name:\s*mynote\s*type:\s*Note\s*contents:\s*my special text\b' - ) - - def test_annotation_fetch_wrong_name_failure(self): - fetch_result = self.runner.invoke( - tools, - ['annotation-fetch', '--input-path', self.art2, - '--name', 'mynote2'] - ) - # confirm the command does produce an error - self.assertEqual(fetch_result.exit_code, 1) - self.assertIn('No Annotation with name: "mynote2"', - fetch_result.output) - - # ANNOTATION_LIST - def test_annotation_list_multiple_annotations_success(self): - self.note2 = Note(name='myothernote', text='my extra special text') - - self.arty = Artifact.load(self.art2) - self.arty.add_annotation(self.note2) - self.arty.save(self.art2) - - list_result = self.runner.invoke( - tools, - ['annotation-list', '--input-path', self.art2] - ) - # confirm the command doesn't produce an error - self.assertEqual(list_result.exit_code, 0) - self.assertRegex(list_result.output, - r'name:\s*mynote\s*type:\s*Note\b') - self.assertRegex(list_result.output, - r'name:\s*myothernote\s*type:\s*Note\b') - - def test_annotation_list_no_annotations_failure(self): - list_result = self.runner.invoke( - tools, - ['annotation-list', '--input-path', self.art1] - ) - # confirm the command does produce an error - self.assertEqual(list_result.exit_code, 1) - self.assertIn('No Annotations found.', list_result.output) - - -class TestCacheExport(unittest.TestCase): - def setUp(self): - dummy_plugin = get_dummy_plugin() - - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - self.cache = Cache(os.path.join(self.tempdir, 'cache')) - - ints1 = Artifact.import_data( - 'IntSequence1', [0, 42, 43], list) - self.ints1 = self.cache.save(ints1, 'ints1') - - most_common_viz = dummy_plugin.actions['most_common_viz'] - viz = most_common_viz(ints1).visualization - self.viz = self.cache.save(viz, 'viz') - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_cache_export_to_dir_w_format(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceDirectoryFormat' - ]) - - self.assertEqual(result.exit_code, 0) - self.assertTrue(os.path.isdir(output_path)) - - def test_cache_export_to_dir_no_format(self): - output_path = os.path.join(self.tempdir, 'output') - self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', - '--output-path', output_path - ]) - - self.assertTrue(os.path.isdir(output_path)) - self.assertIn('index.html', os.listdir(output_path)) - self.assertIn('index.tsv', os.listdir(output_path)) - - def test_cache_export_to_file(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceFormatV2' - ]) - - with open(output_path, 'r') as f: - file = f.read() - self.assertEqual(result.exit_code, 0) - self.assertIn('0', file) - self.assertIn('42', file) - self.assertIn('43', file) - - def test_cache_export_to_file_creates_directories(self): - output_path = os.path.join(self.tempdir, 'somewhere', 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceFormatV2' - ]) - - with open(output_path, 'r') as f: - file = f.read() - self.assertEqual(result.exit_code, 0) - self.assertIn('0', file) - self.assertIn('42', file) - self.assertIn('43', file) - - def test_cache_export_visualization_to_dir(self): - output_path = os.path.join(self.tempdir, 'output') - self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', - '--output-path', output_path - ]) - - self.assertIn('index.html', os.listdir(output_path)) - self.assertIn('index.tsv', os.listdir(output_path)) - self.assertTrue(os.path.isdir(output_path)) - - def test_cache_export_visualization_w_format(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', - '--output-path', output_path, '--output-format', - 'IntSequenceDirectoryFormat' - ]) - - self.assertEqual(result.exit_code, 1) - self.assertIn('visualization', result.output) - self.assertIn('--output-format', result.output) - - def test_cache_export_path_file_is_replaced(self): - output_path = os.path.join(self.tempdir, 'output') - with open(output_path, 'w') as file: - file.write('HelloWorld') - self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceFormatV2' - ]) - - with open(output_path, 'r') as f: - file = f.read() - self.assertNotIn('HelloWorld', file) - - def test_cache_export_to_file_with_format_success_message(self): - output_path = os.path.join(self.tempdir, 'output.int') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceFormatV2' - ]) - success = f'Exported {self.cache.path}:ints1 as IntSequenceFormatV2 ' \ - f'to file {output_path}\n' - self.assertEqual(success, result.output) - - def test_cache_export_to_dir_without_format_success_message(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path - ]) - success = f'Exported {self.cache.path}:ints1 as ' \ - f'IntSequenceDirectoryFormat to directory {output_path}\n' - self.assertEqual(success, result.output) - - def test_cache_export_visualization_to_dir_success_message(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', - '--output-path', output_path - ]) - success = f'Exported {self.cache.path}:viz as Visualization to ' \ - f'directory {output_path}\n' - self.assertEqual(success, result.output) - - def test_cache_export_bad_cache(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.tempdir), '--key', 'viz', - '--output-path', output_path - ]) - - failure = f"Path: '{self.tempdir}' already exists and is not a cache." - self.assertIn(failure, result.output) - - def test_cache_export_bad_key(self): - output_path = os.path.join(self.tempdir, 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'bad', - '--output-path', output_path - ]) - - failure = f"The cache '{self.cache.path}' does not contain the key " \ - "'bad'" - self.assertIn(failure, result.output) - - -class TestCacheExportToFileFormat(unittest.TestCase): - def setUp(self): - dummy_plugin = get_dummy_plugin() - - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - self.cache = Cache(os.path.join(self.tempdir, 'cache')) - - ints1 = Artifact.import_data( - 'IntSequence1', [0, 42, 43], list) - self.ints1 = self.cache.save(ints1, 'ints1') - - most_common_viz = dummy_plugin.actions['most_common_viz'] - viz = most_common_viz(ints1).visualization - self.viz = self.cache.save(viz, 'viz') - - # Working directory is changed to temp directory to prevent cluttering - # the repo directory with test files - self.current_dir = os.getcwd() - os.chdir(self.tempdir) - - def tearDown(self): - shutil.rmtree(self.tempdir) - os.chdir(self.current_dir) - - def test_cache_export_file_format(self): - output_path = os.path.join(os.getcwd(), 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceFormat' - ]) - - success = f'Exported {self.cache.path}:ints1 as IntSequenceFormat ' \ - f'to file {output_path}\n' - self.assertEqual(success, result.output) - - def test_cache_export_to_filename_without_path(self): - output_path = 'output' - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'viz', - '--output-path', output_path - ]) - success = f'Exported {self.cache.path}:viz as Visualization to ' \ - f'directory {output_path}\n' - self.assertEqual(success, result.output) - - def test_cache_export_dir_format(self): - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(str(self.cache.path)), '--key', - 'ints1', '--output-path', os.getcwd(), '--output-format', - 'IntSequenceDirectoryFormat' - ]) - - success = f'Exported {self.cache.path}:ints1 as ' \ - f'IntSequenceDirectoryFormat to directory {os.getcwd()}\n' - self.assertEqual(success, result.output) - - def test_cache_export_dir_format_nested(self): - output_path = os.path.join(os.getcwd(), 'output') - result = self.runner.invoke(tools, [ - 'cache-export', '--cache', str(self.cache.path), '--key', 'ints1', - '--output-path', output_path, '--output-format', - 'IntSequenceDirectoryFormat' - ]) - - success = f'Exported {self.cache.path}:ints1 as ' \ - f'IntSequenceDirectoryFormat to directory {output_path}\n' - self.assertEqual(success, result.output) - - -class TestMakeReport(unittest.TestCase): - def setUp(self): - # ensure dummy plugin is registered - self.dp = get_dummy_plugin() - self.runner = CliRunner() - self.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - # create an artifact to drive visualizations - self.ints = Artifact.import_data('IntSequence1', [0, 1, 2]) - - # create two visualizations with unique basenames - self.viz1 = os.path.join(self.tempdir, 'viz1.qzv') - self.dp.actions['most_common_viz'](self.ints).visualization.save( - self.viz1) - - self.viz2 = os.path.join(self.tempdir, 'viz2.qzv') - self.dp.actions['most_common_viz'](self.ints).visualization.save( - self.viz2) - - def tearDown(self): - shutil.rmtree(self.tempdir) - - def test_make_report_success(self): - report = os.path.join(self.tempdir, 'report.qzv') - result = self.runner.invoke( - tools, ['make-report', self.viz1, self.viz2, '--report-path', - report]) - - self.assertEqual(result.exit_code, 0) - self.assertIn('Report saved to', result.output) - # ensure the report file was written and is a zip archive - self.assertTrue(zipfile.is_zipfile(report)) - - def test_make_report_duplicate_names(self): - # create two visualizations that share the same filename but live in - # different directories to trigger the duplicate-name error - a_dir = os.path.join(self.tempdir, 'a') - b_dir = os.path.join(self.tempdir, 'b') - os.mkdir(a_dir) - os.mkdir(b_dir) - - dup1 = os.path.join(a_dir, 'viz.qzv') - dup2 = os.path.join(b_dir, 'viz.qzv') - self.dp.actions['most_common_viz'](self.ints).visualization.save(dup1) - self.dp.actions['most_common_viz'](self.ints).visualization.save(dup2) - - report = os.path.join(self.tempdir, 'report.qzv') - result = self.runner.invoke( - tools, ['make-report', dup1, dup2, '--report-path', report]) - self.assertNotEqual(result.exit_code, 0) - self.assertIn('Multiple files share the same name', result.output) - - -class TestRedactMetadata(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.tempdir = tempfile.mkdtemp(prefix='rachis-cli-test-temp-') - - metadata_path = os.path.join(cls.tempdir, 'metadata.tsv') - with open(metadata_path, 'w') as fh: - fh.write('sample-id\tbarcode-sequence\n') - fh.write('1\tACT') - dummy_md = Metadata.load(metadata_path) - - pm = PluginManager() - identity_with_metadata = pm.plugins['dummy-plugin'].actions[ - 'identity_with_metadata' - ] - - cls.artifact1_fp = os.path.join(cls.tempdir, 'a1.qza') - cls.artifact2_fp = os.path.join(cls.tempdir, 'a2.qza') - cls.artifact3_fp = os.path.join(cls.tempdir, 'a3.qza') - - artifact1 = Artifact.import_data(IntSequence1, [0, 6, 7]) - artifact1, = identity_with_metadata(artifact1, dummy_md) - artifact1.save(cls.artifact1_fp) - artifact2 = Artifact.import_data(IntSequence2, [3, 4, 5]) - artifact2, = identity_with_metadata(artifact2, dummy_md) - artifact2.save(cls.artifact2_fp) - artifact3 = Artifact.import_data(SingleInt, 9) - artifact3.save(cls.artifact3_fp) - - cls.runner = CliRunner() - cls.redacted_fp = os.path.join(cls.tempdir, 'redacted') - - def test_with_metadata(self): - redacted_fp = self.redacted_fp + '.qza' - result = self.runner.invoke(tools, [ - 'redact-metadata', '--input-path', self.artifact1_fp, - '--output-path', redacted_fp - ]) - - self.assertEqual(result.exit_code, 0) - success = f'Succesfully redacted metadata from {self.artifact1_fp}, ' \ - f'and saved to {redacted_fp}.\n' - self.assertEqual(success, result.output) - - def test_fails_already_redacted(self): - ''' - Redacting metadata from an Artifact twice should fail. - ''' - redacted_fp = self.redacted_fp + '.qza' - self.runner.invoke(tools, [ - 'redact-metadata', '--input-path', self.artifact2_fp, - '--output-path', redacted_fp - ]) - - result = self.runner.invoke(tools, [ - 'redact-metadata', '--input-path', redacted_fp, - '--output-path', os.path.join(self.tempdir, 'bad') - ]) - - self.assertEqual(result.exit_code, 1) - self.assertIn('only redacted metadata', result.output) - - def test_fails_no_metadata(self): - ''' - Redacting metadata from an Artifact with no metadata should fail. - ''' - redacted_fp = self.redacted_fp + '.qza' - result = self.runner.invoke(tools, [ - 'redact-metadata', '--input-path', self.artifact3_fp, - '--output-path', redacted_fp - ]) - - self.assertEqual(result.exit_code, 1) - self.assertIn('Result without metadata', result.output) - - -if __name__ == "__main__": - unittest.main() diff --git a/q2cli/tests/test_usage.py b/q2cli/tests/test_usage.py deleted file mode 100644 index 1f549451..00000000 --- a/q2cli/tests/test_usage.py +++ /dev/null @@ -1,264 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 os -import sys -import subprocess -import tempfile -import unittest - -from q2cli.core.usage import CLIUsage - -from qiime2.core.testing.util import get_dummy_plugin -import pytest - - -def _rt_labeler(val): - if hasattr(val, 'id'): - return val.id - return val - - -@pytest.fixture -def dummy_plugin(monkeypatch): - monkeypatch.setenv('QIIMETEST', '') - return get_dummy_plugin() - - -def get_templated_tests(): - return [ - ('concatenate_ints', - """\ -# This example demonstrates basic usage. -rachis dummy-plugin concatenate-ints \\ - --i-ints1 ints-a.qza \\ - --i-ints2 ints-b.qza \\ - --i-ints3 ints-c.qza \\ - --p-int1 4 \\ - --p-int2 2 \\ - --o-concatenated-ints ints-d.qza -# This example demonstrates chained usage (pt 1). -rachis dummy-plugin concatenate-ints \\ - --i-ints1 ints-a.qza \\ - --i-ints2 ints-b.qza \\ - --i-ints3 ints-c.qza \\ - --p-int1 4 \\ - --p-int2 2 \\ - --o-concatenated-ints ints-d.qza -# This example demonstrates chained usage (pt 2). -rachis dummy-plugin concatenate-ints \\ - --i-ints1 ints-d.qza \\ - --i-ints2 ints-b.qza \\ - --i-ints3 ints-c.qza \\ - --p-int1 41 \\ - --p-int2 0 \\ - --o-concatenated-ints concatenated-ints.qza -# comment 1 -# comment 2 -# comment 1 -# comment 2"""), - ('identity_with_metadata', - """\ -rachis dummy-plugin identity-with-metadata \\ - --i-ints ints.qza \\ - --m-metadata-file md.tsv \\ - --o-out out.qza -rachis dummy-plugin identity-with-metadata \\ - --i-ints ints.qza \\ - --m-metadata-file md1.tsv md2.tsv \\ - --o-out out.qza"""), - ('identity_with_metadata_column', - """\ -rachis dummy-plugin identity-with-metadata-column \\ - --i-ints ints.qza \\ - --m-metadata-file md.tsv \\ - --m-metadata-column a \\ - --o-out out.qza"""), - ('typical_pipeline', - """\ -rachis dummy-plugin typical-pipeline \\ - --i-int-sequence ints.qza \\ - --i-mapping mapper.qza \\ - --p-do-extra-thing \\ - --o-out-map out-map.qza \\ - --o-left left.qza \\ - --o-right right.qza \\ - --o-left-viz left-viz.qzv \\ - --o-right-viz right-viz.qzv -rachis dummy-plugin typical-pipeline \\ - --i-int-sequence ints1.qza \\ - --i-mapping mapper1.qza \\ - --p-do-extra-thing \\ - --o-out-map out-map1.qza \\ - --o-left left1.qza \\ - --o-right right1.qza \\ - --o-left-viz left-viz1.qzv \\ - --o-right-viz right-viz1.qzv -rachis dummy-plugin typical-pipeline \\ - --i-int-sequence left1.qza \\ - --i-mapping out-map1.qza \\ - --p-no-do-extra-thing \\ - --o-out-map out-map2.qza \\ - --o-left left2.qza \\ - --o-right right2.qza \\ - --o-left-viz left-viz2.qzv \\ - --o-right-viz right-viz2.qzv -rachis dev assert-result-data right2.qza \\ - --zip-data-path ints.txt \\ - --expression 1 -rachis dev assert-result-type right2.qza \\ - --qiime-type IntSequence1 -rachis dev assert-result-type out-map1.qza \\ - --qiime-type Mapping"""), - ('optional_artifacts_method', - """\ -rachis dummy-plugin optional-artifacts-method \\ - --i-ints ints.qza \\ - --p-num1 1 \\ - --o-output output1.qza -rachis dummy-plugin optional-artifacts-method \\ - --i-ints ints.qza \\ - --p-num1 1 \\ - --p-num2 2 \\ - --o-output output2.qza -rachis dummy-plugin optional-artifacts-method \\ - --i-ints ints.qza \\ - --p-num1 1 \\ - --o-output output3.qza -rachis dummy-plugin optional-artifacts-method \\ - --i-ints ints.qza \\ - --i-optional1 output3.qza \\ - --p-num1 3 \\ - --p-num2 4 \\ - --o-output output4.qza"""), - ('variadic_input_method', - """\ -rachis dummy-plugin variadic-input-method \\ - --i-ints ints-a.qza ints-b.qza \\ - --i-int-set single-int1.qza single-int2.qza \\ - --p-nums 7 8 9 \\ - --o-output out.qza"""), - ('list_of_ints', - """\ -rachis dummy-plugin list-of-ints \\ - --i-ints ints/ \\ - --o-output out/"""), - ('dict_of_ints', - """\ -rachis dummy-plugin dict-of-ints \\ - --i-ints ints/ \\ - --o-output out/ -rachis dev assert-result-type out/Foo.qza \\ - --qiime-type SingleInt -rachis dev assert-result-data out/Foo.qza \\ - --zip-data-path file1.txt \\ - --expression 1 -## constructing result collection ## -rc_name=rc-in/ -ext=.qza -keys=( a b ) -names=( ints-a.qza ints-b.qza ) -construct_result_collection -## -rachis dummy-plugin dict-of-ints \\ - --i-ints rc-in/ \\ - --o-output rc-out/ -## accessing result collection member ## -ln -s rc-out/b.qza ints-b-from-collection.qza -##"""), - ('viz_collection_pipeline', - """\ -rachis dummy-plugin viz-collection-pipeline \\ - --i-ints ints.qza \\ - --o-visualizations visualizations/ -rachis dev assert-result-type visualizations/ \\ - --qiime-type Collection[Visualization]""") - ] - - -_templ_ids = [x[0] for x in get_templated_tests()] - - -@pytest.mark.parametrize('action,exp', get_templated_tests(), ids=_templ_ids) -def test_templated(dummy_plugin, action, exp): - action = dummy_plugin.actions[action] - - obs = '' - for example_f in action.examples.values(): - use = CLIUsage(enable_assertions=True) - example_f(use) - obs += use.render() - obs += '\n' - - # trim final newline - obs = obs[:-1] - - assert exp == obs - - -def get_rt_tests(): - tests = [] - try: - plugin = get_dummy_plugin() - except RuntimeError: - return tests - - for action in plugin.actions.values(): - for name in action.examples: - tests.append((action, name)) - - return tests - - -@pytest.mark.parametrize('action,example', get_rt_tests(), ids=_rt_labeler) -def test_round_trip(action, example): - example_f = action.examples[example] - use = CLIUsage(enable_assertions=True) - example_f(use) - rendered = use.render() - if sys.platform.startswith('linux'): - # TODO: remove me when arrays are not used in shell - extra = dict(executable='/bin/bash') - else: - extra = dict() - with tempfile.TemporaryDirectory() as tmpdir: - for ref, data in use.get_example_data(): - data.save(os.path.join(tmpdir, ref)) - subprocess.run(rendered, - shell=True, - check=True, - cwd=tmpdir, - env={**os.environ}, - **extra) - - -class ReplayResultCollectionTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.plugin = get_dummy_plugin() - - def test_construct_and_access_collection(self): - action = self.plugin.actions['dict_of_ints'] - use = CLIUsage() - action.examples['construct_and_access_collection'](use) - exp = """\ -## constructing result collection ## -rc_name=rc-in/ -ext=.qza -keys=( a b ) -names=( ints-a.qza ints-b.qza ) -construct_result_collection -## -rachis dummy-plugin dict-of-ints \\ - --i-ints rc-in/ \\ - --o-output rc-out/ -## accessing result collection member ## -ln -s rc-out/b.qza ints-b-from-collection.qza -##""" - - self.assertEqual(exp, use.render()) diff --git a/q2cli/util.py b/q2cli/util.py deleted file mode 100644 index 1122b40d..00000000 --- a/q2cli/util.py +++ /dev/null @@ -1,577 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2016-2026, 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 contextlib - - -class OutOfDisk(Exception): - pass - - -def get_app_dir(): - import os - conda_prefix = os.environ.get('CONDA_PREFIX', '') - environment_cache = os.path.join(conda_prefix, 'var', 'q2cli') - if conda_prefix != '' and (os.access(conda_prefix, os.W_OK | os.X_OK) - or os.path.exists(environment_cache)): - return environment_cache - else: - import click - return click.get_app_dir('q2cli', roaming=False) - - -# NOTE: `get_cache_dir` and `get_completion_path` live here instead of -# `q2cli.cache` because `q2cli.cache` can be slow to import. -# `get_completion_path` (which relies on `get_cache_dir`) is imported and -# executed by the Bash completion function each time the user hits , so it -# must be quick to import. -def get_cache_dir(): - import os.path - return os.path.join(get_app_dir(), 'cache') - - -def get_completion_path(): - import os.path - return os.path.join(get_cache_dir(), 'completion.sh') - - -def hidden_to_cli_name(name): - # Safety first - if not name.startswith('_'): - raise ValueError(f"The name '{name}' does not start with '_' meaning" - " it is not a hidden action and this method should" - " not have been called on it.") - - name = to_cli_name(name) - - # Retain the leading _ - return name.replace('-', '_', 1) - - -def to_cli_name(name): - return name.replace('_', '-') - - -def to_snake_case(name): - return name.replace('-', '_') - - -def exit_with_error(e, header='An error has been encountered:', - traceback='stderr', status=1): - import sys - import traceback as tb - import textwrap - import click - from q2cli.core.config import CONFIG - - footer = [] # footer only exists if traceback is set - tb_file = None - if traceback == 'stderr': - tb_file = sys.stderr - footer = ['See above for debug info.'] - elif traceback is not None: - tb_file = traceback - footer = ['Debug info has been saved to %s' % tb_file.name] - - error = textwrap.indent(str(e), ' ') - segments = [header, error] + footer - - if traceback is not None: - tb.print_exception(type(e), e, e.__traceback__, file=tb_file) - - tb_file.write('\n') - - click.echo(CONFIG.cfg_style('error', '\n\n'.join(segments)), err=True) - - if not footer: - click.echo(err=True) # extra newline to look normal - - try: - click.get_current_context().exit(status) - except RuntimeError: - sys.exit(status) - - -def output_in_cache(fp): - """Determines if an output path follows the format - /path_to_extant_cache:key - """ - from qiime2.core.cache import Cache - - # Tells us right away this isn't in a cache - if ':' not in fp: - return False - - cache_path, key = _get_cache_path_and_key(fp) - - try: - if Cache.is_cache(cache_path): - Cache.validate_key(key) - return True - except FileNotFoundError as e: - # If cache_path doesn't exist, don't treat this as a cache output - if 'No such file or directory' in str(e): - pass - else: - raise e - - # We don't have a cache at all - return False - - -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): - self.header = header - self.traceback = traceback - self.status = status - - def __call__(self, function): - def wrapped(*args, **kwargs): - with self: - return function(*args, **kwargs, failure=self) - # not using functools.wraps to keep import overhead low - # click only seems to need the __name__ - wrapped.__name__ = function.__name__ - return wrapped - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - # if exit_with_error is called twice, then click.exit(1) or sys.exit(1) - # will happen, no need to exit_with_error again in that case. - if exc_val is not None and str(exc_val) != '1': - exit_with_error(exc_val, self.header, self.traceback, self.status) - - return False - - -def convert_primitive(ast): - import click - - mapping = { - 'Int': int, - 'Str': str, - 'Float': float, - 'Color': str, - 'Bool': bool - } - # TODO: it would be a good idea to refactor this someday, but until then - # just handle the few predicates we know about. - predicate = ast['predicate'] - if predicate: - if predicate['name'] == 'Choices' and ast['name'] == 'Str': - return click.Choice(predicate['choices']) - elif predicate['name'] == 'Range' and ast['name'] == 'Int': - start = predicate['range'][0] - end = predicate['range'][1] - # click.IntRange is always inclusive - if start is not None and not predicate['inclusive'][0]: - start += 1 - 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': - # click.FloatRange will be in click 7.0, so for now the - # range handling will just fallback to qiime2. - return mapping['Float'] - else: - raise NotImplementedError() - else: - return mapping[ast['name']] - - -def citations_option(get_citation_records): - import click - from q2cli.core.config import CONFIG - - def callback(ctx, param, value): - if not value or ctx.resilient_parsing: - return - - records = get_citation_records() - if records: - import io - import qiime2.sdk - - citations = qiime2.sdk.Citations( - [('key%d' % i, r) for i, r in enumerate(records)]) - with io.StringIO() as fh: - fh.write('% use `rachis tools citations` on a rachis result' - ' for complete list\n\n') - citations.save(fh) - click.echo(fh.getvalue(), nl=False) - ctx.exit() - else: - click.echo( - CONFIG.cfg_style('problem', 'No citations found.'), err=True) - ctx.exit(1) - - return click.Option(['--citations'], is_flag=True, expose_value=False, - is_eager=True, callback=callback, - help='Show citations and exit.') - - -def example_data_option(get_plugin, action_name=None): - import click - from q2cli.click.type import OutDirType - - def callback(ctx, param, value): - if not value or ctx.resilient_parsing: - return - else: - import q2cli.core.usage as usage - - plugin = get_plugin() - if action_name is not None: - action = plugin.actions[action_name] - generator = usage.write_example_data(action, value) - else: - generator = usage.write_plugin_example_data(plugin, value) - - ran = False - for hint, path in generator: - click.secho('Saved %s to: %s' % (hint, path), fg='green') - ran = True - - if ran: - ctx.exit() - else: - click.secho('No example data found.', fg='yellow', err=True) - ctx.exit(1) - - return click.Option(['--example-data'], type=OutDirType(), is_eager=True, - expose_value=False, callback=callback, - help='Write example data and exit.') - - -def get_plugin_manager(): - import qiime2.sdk - - try: - return qiime2.sdk.PluginManager.reuse_existing() - except qiime2.sdk.UninitializedPluginManagerError: - import os - - if 'MYSTERY_STEW' in os.environ: - from q2_mystery_stew.plugin_setup import create_plugin - - the_stew = create_plugin() - pm = qiime2.sdk.PluginManager(add_plugins=False) - pm.add_plugin(the_stew) - return pm - - return qiime2.sdk.PluginManager() - - -def load_metadata(fp): - import qiime2 - import sys - - metadata, error = _load_metadata_artifact(fp) - if metadata is None: - try: - metadata = qiime2.Metadata.load(fp) - except Exception as e: - if error and ':' in fp: - e = error - header = ("There was an issue with loading the file %s as " - "metadata:" % fp) - tb = 'stderr' if '--verbose' in sys.argv else None - exit_with_error(e, header=header, traceback=tb) - - return metadata - - -def _load_metadata_artifact(fp): - import qiime2 - import sys - - artifact, error = _load_input(fp) - artifact = artifact[1] - if isinstance(error, OutOfDisk): - raise error - - default_tb = 'stderr' - # if that worked, we have an artifact or we've - # already raised a critical error - # otherwise, any normal errors can be ignored as its - # most likely actually metadata not a qza - if artifact: - try: - default_tb = None - if isinstance(artifact, qiime2.Visualization): - raise Exception( - 'Visualizations cannot be viewed as rachis metadata.') - if not artifact.has_metadata(): - raise Exception( - f"Artifacts with type {artifact.type!r} cannot be viewed" - " as rachis metadata.") - - default_tb = 'stderr' - return artifact.view(qiime2.Metadata), None - - except Exception as e: - header = ("There was an issue with viewing the artifact " - f"{fp!r} as rachis Metadata:") - tb = 'stderr' if '--verbose' in sys.argv else default_tb - exit_with_error(e, header=header, traceback=tb) - - else: - return None, error - - -def _load_input(fp, view=False): - # Just initialize the plugin manager. This is slow and not necessary if we - # called this from rachis tools view. - import os - - key = None - - if not view: - _ = get_plugin_manager() - - # We are loading a collection from outside of a cache. This cannot be keyed - if os.path.isdir(fp): - if len(os.listdir(fp)) == 0: - raise ValueError(f"Provided directory '{fp}' is empty.") - - artifact, error = _load_collection(fp) - # We may be loading something from a cache with or without and additional - # key, or we may be loading a piece of data from outside of a cache with a - # key. We could also be loading a normal unkeyed artifact with a : in its - # path - elif ':' in fp: - # First we assume this is just a weird filepath - artifact, _ = _load_input_file(fp) - # Then we check if it is a key:path - if artifact is None: - key, new_fp = _get_path_and_collection_key(fp) - artifact, _ = _load_input_file(new_fp) - - # If we still have nothing - if artifact is None: - key = None - # We assume this is a cache:key. We keep this error because we - # assume if they had a : in their path they were trying to load - # something from a cache - artifact, error = _load_input_cache(fp) - if error: - # Then we check if it is a key:cache:key - key, new_fp = _get_path_and_collection_key(fp) - artifact, _ = _load_input_cache(new_fp) - - # If we ended up with an artifact, we disregard our error - if artifact is not None: - error = None - # We are just loading a normal artifact on disk without silly colons in the - # filepath - else: - artifact, error = _load_input_file(fp) - - if isinstance(error, OSError) and error.errno == 28: - # abort as there's nothing anyone can do about this - from qiime2.core.cache import get_cache - - path = str(get_cache().path) - return None, OutOfDisk(f'There was not enough space left on {path!r} ' - f'to use the artifact {fp!r}. (Try ' - f'setting $TMPDIR to a directory with more ' - f'space, or increasing the size of {path!r})') - - return (key, artifact), error - - -# NOTE: These load collection functions are now virtually identical to class -# methods on qiime2.sdk.Result -def _load_collection(fp): - import os - import warnings - - order_fp = os.path.join(fp, '.order') - - if os.path.isfile(order_fp): - artifacts, error = _load_ordered_collection(fp, order_fp) - else: - warnings.warn(f'The directory {fp} does not contain a .order file. ' - 'The files will be read into the collection in the ' - 'order the filesystem provides them in.') - artifacts, error = _load_unordered_collection(fp) - - return artifacts, error - - -def _load_ordered_collection(fp, order_fp): - import os - - artifacts = {} - - with open(order_fp) as order_fh: - for key in order_fh.read().splitlines(): - artifact_path = os.path.join(fp, f'{key}.qza') - artifacts[key], error = _load_input_file(artifact_path) - - if error: - return None, error - - return artifacts, None - - -def _load_unordered_collection(fp): - import os - - artifacts = {} - - for artifact in os.listdir(fp): - artifact_fp = os.path.join(fp, artifact) - artifacts[artifact], error = _load_input_file(artifact_fp) - - if error: - return None, error - - return artifacts, None - - -def _load_input_cache(fp): - artifact = error = None - try: - artifact = try_as_cache_input(fp) - except Exception as e: - error = e - - return artifact, error - - -def _load_input_file(fp): - import qiime2.sdk - from os.path import expanduser - - # If there is a leading ~ we expand it to be the path to home - fp = expanduser(fp) - - # test if valid - peek = None - try: - peek = qiime2.sdk.Result.peek(fp) - except Exception as error: - if isinstance(error, SyntaxError): - raise error - # ideally ValueError: X is not a rachis archive. - # but sometimes SyntaxError or worse - return None, error - - # try to actually load - try: - artifact = qiime2.sdk.Result.load(fp) - return artifact, None - - except Exception as e: - if peek: - # abort early as there's nothing else to do - raise ValueError( - "It looks like you have an Artifact but are missing the" - " plugin(s) necessary to load it. Artifact has type" - f" {peek.type!r} and format {peek.format!r}") from e - else: - error = e - - return None, error - - -def try_as_cache_input(fp): - """ Determine if an input is in a cache and load it from the cache if it is - """ - import os - from qiime2 import Cache - - cache_path, key = _get_cache_path_and_key(fp) - - # We don't want to invent a new cache on disk here because if their input - # exists their cache must also already exist - if not os.path.exists(cache_path) or not Cache.is_cache(cache_path): - raise ValueError(f"The path {cache_path!r} is not a valid cache.") - - cache = Cache(cache_path) - return cache.load(key) - - -def _get_cache_path_and_key(fp): - return fp.rsplit(':', 1) - - -def _get_path_and_collection_key(fp): - return fp.split(':', 1) - - -def get_default_recycle_pool(plugin_action): - from hashlib import sha1 - - return f'recycle_{plugin_action}_' \ - f'{sha1(plugin_action.encode("utf-8")).hexdigest()}' - - -@contextlib.contextmanager -def capture_rachis_warnings(): - ''' - Intercepts `RachisWarning` warnings and delays writing them to standard - error until the context is exited. This is useful if the standard error - stream is temporarily redirected at some point within this context manager. - ''' - import sys - import warnings - from qiime2.core.exceptions import RachisWarning - - captured_warnings = [] - - warnings_showwarning = warnings.showwarning - - def _capture_showwarning( - message, category, filename, lineno, file=None, line=None - ): - if issubclass(category, RachisWarning): - captured_warnings.append((str(message), filename, lineno)) - else: - warnings_showwarning( - message, category, filename, lineno, file, line - ) - - warnings.showwarning = _capture_showwarning - - yield - - if captured_warnings: - sys.stderr.write('\033[33m') # yellow text - sys.stderr.write('\nRACHIS WARNING(S):\n\n') - for warning, file, line in captured_warnings: - sys.stderr.write(f'{warning}\n') - sys.stderr.write(f'[{file}: {line}]\n\n') - - sys.stderr.write('\033[0m') # reset text color - sys.stderr.flush() - - warnings.showwarning = warnings_showwarning From c9e5994d0fc4e491fa77d7e3bcff0d7c1eb6beb4 Mon Sep 17 00:00:00 2001 From: Liz Gehret <54517601+lizgehret@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:00:23 +0200 Subject: [PATCH 06/10] remove duplicate TODO --- rachis_cli/core/usage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rachis_cli/core/usage.py b/rachis_cli/core/usage.py index 7fbdf46e..c5720f0d 100644 --- a/rachis_cli/core/usage.py +++ b/rachis_cli/core/usage.py @@ -130,7 +130,6 @@ def assert_output_type(self, semantic_type, key=None): if key: input_path = self._key_helper(input_path, key) - # TODO: update qiime-type to rachis-type ? lines = [ 'rachis dev assert-result-type %s \\' % (input_path,), INDENT + '--qiime-type %s' % (str(semantic_type),), From ebd0eab3b355005c19fda690a72de9033a16bc2a Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Tue, 8 Sep 2026 12:55:37 +0200 Subject: [PATCH 07/10] move qiime entrypoint into proxy pkg --- bin/tab-rachis | 2 +- conda-recipe/meta.yaml | 3 +-- pyproject.toml | 6 ++---- rachis_cli/__main__.py | 4 ++-- rachis_cli/core/completion.py | 2 +- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/bin/tab-rachis b/bin/tab-rachis index 97b289f0..13271d61 100644 --- a/bin/tab-rachis +++ b/bin/tab-rachis @@ -37,7 +37,7 @@ if [ -n "${_rachis_meta}" ]; then # Fall back to the front-ends provided by rachis-cli itself if the entry # point metadata is missing (e.g. an uninstalled source checkout). if [ -z "${_rachis_names}" ]; then - _rachis_names="rachis qiime" + _rachis_names="rachis" fi # NOTE: `_RACHIS_COMPLETION_PATH` is intentionally left set in the shell - diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index d520d7f1..6cffa710 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -6,8 +6,7 @@ source: build: script: make install entry_points: - - qiime=rachis_cli.__main__:qiime - - rachis=rachis_cli.__main__:qiime + - rachis=rachis_cli.__main__:rachis requirements: host: - python {{ python }} diff --git a/pyproject.toml b/pyproject.toml index 26fdbec0..1f46f001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,12 +13,10 @@ Homepage = "https://qiime2.org" Repository = "https://github.com/rachis-org/rachis-cli" [project.scripts] -"qiime" = "rachis_cli.__main__:qiime" -"rachis" = "rachis_cli.__main__:qiime" +"rachis" = "rachis_cli.__main__:rachis" [project.entry-points.'rachis.cli_commands'] -"qiime" = "rachis_cli.__main__:qiime" -"rachis" = "rachis_cli.__main__:qiime" +"rachis" = "rachis_cli.__main__:rachis" [project.entry-points.'rachis.usage_drivers'] "cli" = "rachis_cli.core.usage:ReplayCLIUsage" diff --git a/rachis_cli/__main__.py b/rachis_cli/__main__.py index b703d139..a138e05f 100644 --- a/rachis_cli/__main__.py +++ b/rachis_cli/__main__.py @@ -40,9 +40,9 @@ @click.version_option(prog_name='rachis-cli', message='%(prog)s version %(version)s\nRun `rachis info`' ' for more version details.') -def qiime(): +def rachis(): pass if __name__ == '__main__': - qiime() + rachis() diff --git a/rachis_cli/core/completion.py b/rachis_cli/core/completion.py index 39eaa3f2..67cb642f 100644 --- a/rachis_cli/core/completion.py +++ b/rachis_cli/core/completion.py @@ -29,7 +29,7 @@ def write_bash_completion_script(plugins, path): import os.path import stat import textwrap - from rachis_cli.__main__ import qiime as root + from rachis_cli.__main__ import rachis as root # `write_bash_completion_script` is called by # `rachis_cli.cache.DeploymentCache` when it is refreshing its cache. From ae1ecf4f11d30eaf9891912cc2dae0c188b40dc9 Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Tue, 8 Sep 2026 16:27:54 +0200 Subject: [PATCH 08/10] rename --qiime-type to --type --- rachis_cli/builtin/dev.py | 11 +++++------ rachis_cli/core/usage.py | 2 +- rachis_cli/tests/test_dev.py | 8 ++++---- rachis_cli/tests/test_usage.py | 8 ++++---- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/rachis_cli/builtin/dev.py b/rachis_cli/builtin/dev.py index 0b98e298..3f35dc29 100644 --- a/rachis_cli/builtin/dev.py +++ b/rachis_cli/builtin/dev.py @@ -151,10 +151,9 @@ def reset_theme(): @click.argument('input-path', type=click.Path(exists=True, file_okay=True, dir_okay=True, readable=True), metavar=_COMBO_METAVAR) -# TODO: we should probably change this to --rachis-type -@click.option('--qiime-type', required=True, +@click.option('--type', 'rachis_type', required=True, help='rachis data type.') -def assert_result_type(input_path, qiime_type): +def assert_rachis_type(input_path, rachis_type): import rachis_cli.util import rachis.sdk from os.path import isdir @@ -171,16 +170,16 @@ def assert_result_type(input_path, qiime_type): input_path rachis_cli.util.exit_with_error(e, header=header) - if str(result.type) != qiime_type: + if str(result.type) != rachis_type: try: - msg = 'Expected %s, observed %s' % (qiime_type, result.type) + msg = 'Expected %s, observed %s' % (rachis_type, result.type) raise AssertionError(msg) except Exception as e: header = 'There was a problem asserting the type:' rachis_cli.util.exit_with_error(e, header=header) else: msg = 'The input file (%s) type and the expected type (%s)' \ - ' match' % (input_path, qiime_type) + ' match' % (input_path, rachis_type) click.echo(CONFIG.cfg_style('success', msg)) diff --git a/rachis_cli/core/usage.py b/rachis_cli/core/usage.py index c5720f0d..4585aec3 100644 --- a/rachis_cli/core/usage.py +++ b/rachis_cli/core/usage.py @@ -132,7 +132,7 @@ def assert_output_type(self, semantic_type, key=None): lines = [ 'rachis dev assert-result-type %s \\' % (input_path,), - INDENT + '--qiime-type %s' % (str(semantic_type),), + INDENT + '--type %s' % (str(semantic_type),), ] self.use.recorder.extend(lines) diff --git a/rachis_cli/tests/test_dev.py b/rachis_cli/tests/test_dev.py index 7ff36b5b..40e2e280 100644 --- a/rachis_cli/tests/test_dev.py +++ b/rachis_cli/tests/test_dev.py @@ -83,7 +83,7 @@ def test_assert_result_type_artifact_success(self): result = self.runner.invoke(dev, ['assert-result-type', self.mapping_path, - '--qiime-type', 'Mapping']) + '--type', 'Mapping']) # single regex to account for tempdir path @@ -104,7 +104,7 @@ def test_assert_result_type_visualization_success(self): result = self.runner.invoke(dev, ['assert-result-type', self.viz_path, - '--qiime-type', + '--type', 'Visualization']) expected_regex = r'The input file \(.*viz\.qzv\) type and the'\ @@ -117,7 +117,7 @@ def test_assert_result_type_load_failure(self): result = self.runner.invoke(dev, ['assert-result-type', 'turkey_sandwhere.qza', - '--qiime-type', 'Mapping']) + '--type', 'Mapping']) self.assertEqual(result.exit_code, 1) self.assertRegex(result.stderr, @@ -127,7 +127,7 @@ def test_assert_result_type_invalid_rachis_type(self): result = self.runner.invoke(dev, ['assert-result-type', self.mapping_path, - '--qiime-type', 'Squid']) + '--type', 'Squid']) self.assertEqual(result.exit_code, 1) self.assertIn('Expected Squid, observed Mapping', result.stderr) diff --git a/rachis_cli/tests/test_usage.py b/rachis_cli/tests/test_usage.py index 622debd1..e951dbf8 100644 --- a/rachis_cli/tests/test_usage.py +++ b/rachis_cli/tests/test_usage.py @@ -112,9 +112,9 @@ def get_templated_tests(): --zip-data-path ints.txt \\ --expression 1 rachis dev assert-result-type right2.qza \\ - --qiime-type IntSequence1 + --type IntSequence1 rachis dev assert-result-type out-map1.qza \\ - --qiime-type Mapping"""), + --type Mapping"""), ('optional_artifacts_method', """\ rachis dummy-plugin optional-artifacts-method \\ @@ -154,7 +154,7 @@ def get_templated_tests(): --i-ints ints/ \\ --o-output out/ rachis dev assert-result-type out/Foo.qza \\ - --qiime-type SingleInt + --type SingleInt rachis dev assert-result-data out/Foo.qza \\ --zip-data-path file1.txt \\ --expression 1 @@ -177,7 +177,7 @@ def get_templated_tests(): --i-ints ints.qza \\ --o-visualizations visualizations/ rachis dev assert-result-type visualizations/ \\ - --qiime-type Collection[Visualization]""") + --type Collection[Visualization]""") ] From 38e5bf2992ad6fc25cd4155ab6f595654890a269 Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Tue, 8 Sep 2026 17:04:27 +0200 Subject: [PATCH 09/10] update internal env vars --- rachis_cli/builtin/dev.py | 5 +++-- rachis_cli/core/cache.py | 24 +++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/rachis_cli/builtin/dev.py b/rachis_cli/builtin/dev.py index 3f35dc29..408b9e8f 100644 --- a/rachis_cli/builtin/dev.py +++ b/rachis_cli/builtin/dev.py @@ -26,8 +26,9 @@ def dev(): "changes to take effect in the CLI. A refresh of the cache " "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.", + "Setting the environment variable RACHISCLIDEV (or the " + "historical Q2CLIDEV) to any value will always refresh the " + "cache when a command is run.", cls=ToolCommand) def refresh_cache(): import rachis_cli.core.cache diff --git a/rachis_cli/core/cache.py b/rachis_cli/core/cache.py index 732a8cae..099d579c 100644 --- a/rachis_cli/core/cache.py +++ b/rachis_cli/core/cache.py @@ -23,11 +23,11 @@ class DeploymentCache: rachis-cli itself will trigger a cache refresh. Two mechanisms are provided to force a cache refresh. Setting the - environment variable Q2CLIDEV to any value will cause the cache to be - refreshed upon instantiation. Calling `.refresh()` will also refresh the - cache. Forced refreshing of the cache is useful for plugin and/or - rachis-cli developers who want their changes to take effect in the CLI - without changing their package versions. + environment variable RACHISCLIDEV (or the historical Q2CLIDEV) to any + value will cause the cache to be refreshed upon instantiation. Calling + `.refresh()` will also refresh the cache. Forced refreshing of the cache + is useful for plugin and/or rachis-cli developers who want their changes + to take effect in the CLI without changing their package versions. Cached CLI state is stored in a state.json file under the cache directory. It is not a public file format and it is not versioned. rachis-cli is @@ -41,8 +41,9 @@ class DeploymentCache: multithreaded/multiprocessing situation). Also, having a single instance improves performance by only reading and/or refreshing the cache a single time during its lifetime. Having two instances could, for example, - trigger two cache refreshes if Q2CLIDEV is set. To support these use-cases, - a module-level `CACHE` variable stores a single instance of this class. + trigger two cache refreshes if RACHISCLIDEV is set. To support these + use-cases, a module-level `CACHE` variable stores a single instance of + this class. """ @@ -59,8 +60,9 @@ def __init__(self): self._cache_dir = self._get_cache_dir() - # TODO: update Q2CLIDEV to RACHISCLIDEV - refresh = 'Q2CLIDEV' in os.environ + # RACHISCLIDEV is the current name; Q2CLIDEV is historical + # and remains supported. The two are interchangeable. + refresh = 'RACHISCLIDEV' in os.environ or 'Q2CLIDEV' in os.environ self._state = self._get_cached_state(refresh=refresh) @property @@ -135,10 +137,10 @@ def decoder(obj): def _get_current_requirements(self): """Includes installed versions of rachis_cli and rachis plugins.""" - import os import itertools import importlib.metadata import rachis_cli + from rachis.core.util import in_test_mode reqs = {f'rachis_cli=={rachis_cli.__version__}'} @@ -158,7 +160,7 @@ def _get_current_requirements(self): for entry_point in itertools.chain( importlib.metadata.entry_points(group='rachis.plugins'), importlib.metadata.entry_points(group='qiime2.plugins')): - if 'QIIMETEST' in os.environ: + if in_test_mode(): if entry_point.name in ('dummy-plugin', 'other-plugin'): reqs.add(f'{entry_point.name}=={entry_point.dist.version}') else: From 3144d612b821cd34ccd137f8f180b6fd38dc6b18 Mon Sep 17 00:00:00 2001 From: Liz Gehret Date: Tue, 8 Sep 2026 17:43:44 +0200 Subject: [PATCH 10/10] opening on paired branch