Repository: qiime2/q2cli
Pull request #362: NEW: supporting commands for annotation actions on v7 archives
Author: login: lizgehret
Created: 2025-04-18T23:06:03Z | Merged: 2025-04-25T23:36:13Z

blocked by https://github.com/qiime2/qiime2/pull/821


Diff:
diff --git a/q2cli/builtin/tools.py b/q2cli/builtin/tools.py
index 7e53e727..05e4e66e 100644
--- a/q2cli/builtin/tools.py
+++ b/q2cli/builtin/tools.py
@@ -1338,3 +1338,220 @@ def supplement_replay(
         verbose=verbose,
         dump_recorded_metadata=dump_recorded_metadata
     )
+
+
+@tools.command(
+    name='annotation-create',
+    short_help='Add a new Annotation to a QIIME 2 Result.',
+    help='Attach an Annotation to an existing Artifact or Visualization.'
+         ' Supported for QIIME 2 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(['Note'], 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).'
+)
+@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`.'
+)
+@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, text, filepath, output_path):
+    """Load a Result, add an Annotation and save."""
+    import qiime2.sdk
+    from qiime2.core.annotate import Note
+    from q2cli.core.config import CONFIG
+
+    # 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.')
+
+    result = qiime2.sdk.Result.load(input_path)
+
+    # TODO: add support for additional annotation types post 7.0
+    if annotation_type == 'Note':
+        annotation = Note(name=name, text=text, filepath=filepath)
+    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 QIIME 2 Result.',
+    help='Remove an existing Annotation (by name) from'
+         ' an Artifact or Visualization.'
+)
+@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 QIIME 2 Result.',
+    help='Fetch an existing Annotation (by name) from'
+         ' an Artifact or Visualization.'
+)
+@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:
+        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 QIIME 2 Result.',
+    help='List all Annotations that are attached to'
+         ' an Artifact or Visualization.'
+)
+@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 = result.iter_annotations()
+    except Exception as e:
+        click.echo(CONFIG.cfg_style('error', str(e)), err=True)
+        raise click.Abort()
+
+    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)
diff --git a/q2cli/tests/test_tools.py b/q2cli/tests/test_tools.py
index 30af8643..54b9f8d3 100644
--- a/q2cli/tests/test_tools.py
+++ b/q2cli/tests/test_tools.py
@@ -1277,6 +1277,44 @@ def test_replay_supplement_zipfile(self):
 
             self.assertEqual(os.listdir(unzipped_path), ['supplement'])
 
+    # TODO: this is a super minimal test just to confirm these commands all
+    # run without failing. After 2025.4 release, I will add a more complete
+    # test suite to assert that the outputs of each of these commands are
+    # what we expect them to be
+    def test_annotation_commands_roundtrip(self):
+        with tempfile.TemporaryDirectory() as tempdir:
+            in_fp = os.path.join(self.tempdir, 'concated_ints.qza')
+            out_fp = os.path.join(tempdir, 'concated_ints_roundtrip.qza')
+
+            create_result = self.runner.invoke(
+                tools,
+                ['annotation-create', '--input-path', in_fp,
+                 '--annotation-type', 'Note',
+                 '--name', 'mynote', '--text', 'my special text',
+                 '--output-path', out_fp]
+            )
+            self.assertEqual(create_result.exit_code, 0)
+
+            fetch_result = self.runner.invoke(
+                tools,
+                ['annotation-fetch', '--input-path', out_fp,
+                 '--name', 'mynote']
+            )
+            self.assertEqual(fetch_result.exit_code, 0)
+
+            list_result = self.runner.invoke(
+                tools,
+                ['annotation-list', '--input-path', out_fp]
+            )
+            self.assertEqual(list_result.exit_code, 0)
+
+            remove_result = self.runner.invoke(
+                tools,
+                ['annotation-remove', '--input-path', out_fp,
+                 '--name', 'mynote', '--output-path', out_fp]
+            )
+            self.assertEqual(remove_result.exit_code, 0)
+
 
 if __name__ == "__main__":
     unittest.main()


Reviews:
user: login: Oddant1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T20:57:07Z
user: login: lizgehret
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T21:27:39Z

Review comments:
diff_hunk: @@ -1237,3 +1237,138 @@ def supplement_replay(
         verbose=verbose,
         dump_recorded_metadata=dump_recorded_metadata
     )
+
+
+@tools.command(
+    name='annotation-create',
+    short_help='Add a new Annotation to a QIIME 2 Result.',
+    help='Attach an Annotation to an existing Artifact or Visualization.'
+         ' Supported for QIIME 2 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(['Note'], 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).'
+)
+@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`.'
+)
+@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, text, filepath, output_path):
+    """Load a Result, add an Annotation and save."""
+    import qiime2.sdk
+    from qiime2.core.annotate import Note
+    from q2cli.core.config import CONFIG
+    from q2cli.util import get_plugin_manager
+
+    get_plugin_manager()
path: q2cli/builtin/tools.py
user: login: Oddant1
body: I don't believe this is necessary
created_at: 2025-04-21T20:43:46Z
author_association: MEMBER
diff_hunk: @@ -1237,3 +1237,138 @@ def supplement_replay(
         verbose=verbose,
         dump_recorded_metadata=dump_recorded_metadata
     )
+
+
+@tools.command(
+    name='annotation-create',
+    short_help='Add a new Annotation to a QIIME 2 Result.',
+    help='Attach an Annotation to an existing Artifact or Visualization.'
+         ' Supported for QIIME 2 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(['Note'], 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).'
+)
+@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`.'
+)
+@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, text, filepath, output_path):
+    """Load a Result, add an Annotation and save."""
+    import qiime2.sdk
+    from qiime2.core.annotate import Note
+    from q2cli.core.config import CONFIG
+    from q2cli.util import get_plugin_manager
+
+    get_plugin_manager()
+    # 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.')
+
+    result = qiime2.sdk.Result.load(input_path)
+
+    # TODO: add support for additional annotation types post 7.0
+    if annotation_type == 'Note':
+        annotation = Note(name=name, text=text, filepath=filepath)
+    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 QIIME 2 Result.',
+    help='Remove an existing Annotation (by name) from'
+         ' an Artifact or Visualization.'
+)
+@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
+    from q2cli.util import get_plugin_manager
+
+    get_plugin_manager()
path: q2cli/builtin/tools.py
user: login: Oddant1
body: I don't think this is necessary here either. We don't care about the type or format or anything of the result we are annotating
created_at: 2025-04-21T20:44:21Z
author_association: MEMBER
diff_hunk: @@ -1237,3 +1237,138 @@ def supplement_replay(
         verbose=verbose,
         dump_recorded_metadata=dump_recorded_metadata
     )
+
+
+@tools.command(
+    name='annotation-create',
+    short_help='Add a new Annotation to a QIIME 2 Result.',
+    help='Attach an Annotation to an existing Artifact or Visualization.'
+         ' Supported for QIIME 2 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(['Note'], 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).'
+)
+@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`.'
+)
+@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, text, filepath, output_path):
+    """Load a Result, add an Annotation and save."""
+    import qiime2.sdk
+    from qiime2.core.annotate import Note
+    from q2cli.core.config import CONFIG
+    from q2cli.util import get_plugin_manager
+
+    get_plugin_manager()
+    # 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.')
+
+    result = qiime2.sdk.Result.load(input_path)
+
+    # TODO: add support for additional annotation types post 7.0
+    if annotation_type == 'Note':
+        annotation = Note(name=name, text=text, filepath=filepath)
+    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 QIIME 2 Result.',
+    help='Remove an existing Annotation (by name) from'
+         ' an Artifact or Visualization.'
+)
+@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
+    from q2cli.util import get_plugin_manager
+
+    get_plugin_manager()
path: q2cli/builtin/tools.py
user: login: lizgehret
body: ok you're totally right - i gaslit myself on this bc i still had QIIMETEST set which is why none of the formats existed lol.
created_at: 2025-04-25T21:27:39Z
author_association: MEMBER

PR comments:
user: login: ebolyen
created_at: 2025-04-25T22:24:01Z
body: drive by comment: it might be nice to have the input result as a click.argument, then you don't have to type `--input-path` (similar to qiime tools view) and there's no overlap with the note's filepath (should one be used)
author_association: MEMBER
user: login: lizgehret
created_at: 2025-04-25T23:14:44Z
body: > drive by comment: it might be nice to have the input result as a click.argument, then you don't have to type `--input-path` (similar to qiime tools view) and there's no overlap with the note's filepath (should one be used)

@ebolyen has rescinded his comment due to the fact that outputdir is optional and exists. #myoutielovesoutputdirs
author_association: MEMBER