Merge branch 'becky/artifact_list_contents' into 'master'

Addition of bst artifact list-contents

See merge request BuildStream/buildstream!1529
diff --git a/NEWS b/NEWS
index 6c93dde..88cc860 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,10 @@
 buildstream 1.3.1
 =================
 
+  o Added `bst artifact list-contents` subcommand which can display the names
+    of files in artifacts in your artifact cache, either by element name
+    or by direct artifact reference.
+
   o BREAKING CHANGE: Reverted the default behaviour of junctions. Subproject
     elements will no longer interact with the parent project's remote (by
     default). To enable this behaviour, a new "cache-junction-elements" boolean
diff --git a/src/buildstream/_frontend/cli.py b/src/buildstream/_frontend/cli.py
index 220d104..5a585b2 100644
--- a/src/buildstream/_frontend/cli.py
+++ b/src/buildstream/_frontend/cli.py
@@ -1203,6 +1203,25 @@
             click.echo_via_pager(data)
 
 
+################################################################
+#                Artifact List-Contents Command                #
+################################################################
+@artifact.command(name='list-contents', short_help="List the contents of an artifact")
+@click.argument('artifacts', type=click.Path(), nargs=-1)
+@click.pass_obj
+def artifact_list_contents(app, artifacts):
+    """List the contents of an artifact.
+
+    Note that 'artifacts' can be element names, which must end in '.bst',
+    or artifact references, which must be in the format `<project_name>/<element>/<key>`.
+
+    """
+    with app.initialized():
+        elements_to_files = app.stream.artifact_list_contents(artifacts)
+        click.echo(app.logger._pretty_print_dictionary(elements_to_files))
+        sys.exit(0)
+
+
 ###################################################################
 #                     Artifact Delete Command                     #
 ###################################################################
diff --git a/src/buildstream/_frontend/widget.py b/src/buildstream/_frontend/widget.py
index 31f69a5..20f5d17 100644
--- a/src/buildstream/_frontend/widget.py
+++ b/src/buildstream/_frontend/widget.py
@@ -795,3 +795,36 @@
             text += '\n'
 
         return text
+
+    # _pretty_print_dictionary()
+    #
+    # Formats a dictionary so it can be easily read by the user
+    #
+    # Args:
+    #    values: A dictionary
+    #    style_value: Whether to use the content profile for the values
+    #
+    # Returns:
+    #    (str): The formatted values
+    #
+    def _pretty_print_dictionary(self, values, style_value=True):
+        text = ''
+        max_key_len = 0
+        max_key_len = max(len(key) for key in values.keys())
+
+        for key, value in values.items():
+            if isinstance(value, str) and '\n' in value:
+                text += self.format_profile.fmt("  {}:".format(key))
+                text += textwrap.indent(value, self._indent)
+                continue
+
+            text += self.format_profile.fmt("  {}:{}".format(key, ' ' * (max_key_len - len(key))))
+
+            value_list = "\n\t" + "\n\t".join(value)
+            if style_value:
+                text += self.content_profile.fmt(value_list)
+            else:
+                text += value_list
+            text += '\n'
+
+        return text
diff --git a/src/buildstream/_stream.py b/src/buildstream/_stream.py
index c54fee1..453670a 100644
--- a/src/buildstream/_stream.py
+++ b/src/buildstream/_stream.py
@@ -632,6 +632,30 @@
 
         return log_file_paths
 
+    # artifact_list_contents()
+    #
+    # Show a list of content of an artifact
+    #
+    # Args:
+    #    targets (str): Targets to view the contents of
+    #
+    # Returns:
+    #    elements_to_files (list): A list of tuples of the artifact name and it's contents
+    #
+    def artifact_list_contents(self, targets):
+        # Return list of Element and/or ArtifactElement objects
+        target_objects = self.load_selection(targets, selection=PipelineSelection.NONE, load_refs=True)
+
+        elements_to_files = {}
+        for obj in target_objects:
+            if isinstance(obj, ArtifactElement):
+                obj.name = obj.get_artifact_name()
+            files = obj._get_artifact_relative_file_paths()
+            if files == []:
+                files = ["This element has no associated artifacts"]
+            elements_to_files[obj.name] = files
+        return elements_to_files
+
     # artifact_delete()
     #
     # Remove artifacts from the local cache
diff --git a/src/buildstream/element.py b/src/buildstream/element.py
index bc8cde3..f28f482 100644
--- a/src/buildstream/element.py
+++ b/src/buildstream/element.py
@@ -2360,6 +2360,16 @@
         factory = self._get_project().config.element_factory
         return factory, self.__meta_kind, state
 
+    # _get_artifact_relative_path_files()
+    #
+    # Gets the file paths in the artifact and return them in a list
+    #
+    # Returns:
+    #   (list): A list of the file paths in the artifact
+    def _get_artifact_relative_file_paths(self):
+        casbd = self.__artifact.get_files()
+        return [f for f in casbd.list_relative_paths()]
+
     #############################################################
     #                   Private Local Methods                   #
     #############################################################
diff --git a/tests/frontend/artifact.py b/tests/frontend/artifact.py
index 177be8c..fed0b1a 100644
--- a/tests/frontend/artifact.py
+++ b/tests/frontend/artifact.py
@@ -71,6 +71,73 @@
     assert (log + log) == result.output
 
 
+@pytest.mark.datafiles(DATA_DIR)
+def test_artifact_list_exact_contents_element(cli, datafiles):
+    project = str(datafiles)
+
+    # Ensure we have an artifact to read
+    result = cli.run(project=project, args=['build', 'import-bin.bst'])
+    assert result.exit_code == 0
+
+    # List the contents via the element name
+    result = cli.run(project=project, args=['artifact', 'list-contents', 'import-bin.bst'])
+    assert result.exit_code == 0
+    expected_output = ("import-bin.bst:\n"
+                       "\tusr\n"
+                       "\tusr/bin\n"
+                       "\tusr/bin/hello\n\n")
+    assert expected_output in result.output
+
+
+@pytest.mark.datafiles(DATA_DIR)
+def test_artifact_list_exact_contents_ref(cli, datafiles):
+    project = str(datafiles)
+
+    # Get the cache key of our test element
+    key = cli.get_element_key(project, 'import-bin.bst')
+
+    # Ensure we have an artifact to read
+    result = cli.run(project=project, args=['build', 'import-bin.bst'])
+    assert result.exit_code == 0
+
+    # List the contents via the key
+    result = cli.run(project=project, args=['artifact', 'list-contents', 'test/import-bin/' + key])
+    assert result.exit_code == 0
+
+    expected_output = ("test/import-bin/" + key + ":\n"
+                       "\tusr\n"
+                       "\tusr/bin\n"
+                       "\tusr/bin/hello\n\n")
+    assert expected_output in result.output
+
+
+@pytest.mark.datafiles(DATA_DIR)
+def test_artifact_list_exact_contents_glob(cli, datafiles):
+    project = str(datafiles)
+
+    # Ensure we have an artifact to read
+    result = cli.run(project=project, args=['build', 'target.bst'])
+    assert result.exit_code == 0
+
+    # List the contents via glob
+    result = cli.run(project=project, args=['artifact', 'list-contents', 'test/*'])
+    assert result.exit_code == 0
+
+    # get the cahe keys for each element in the glob
+    import_bin_key = cli.get_element_key(project, 'import-bin.bst')
+    import_dev_key = cli.get_element_key(project, 'import-dev.bst')
+    compose_all_key = cli.get_element_key(project, 'compose-all.bst')
+    target_key = cli.get_element_key(project, 'target.bst')
+
+    expected_artifacts = ["test/import-bin/" + import_bin_key,
+                          "test/import-dev/" + import_dev_key,
+                          "test/compose-all/" + compose_all_key,
+                          "test/target/" + target_key]
+
+    for artifact in expected_artifacts:
+        assert artifact in result.output
+
+
 # Test that we can delete the artifact of the element which corresponds
 # to the current project state
 @pytest.mark.datafiles(DATA_DIR)
diff --git a/tests/frontend/completions.py b/tests/frontend/completions.py
index 3619242..e9fa25b 100644
--- a/tests/frontend/completions.py
+++ b/tests/frontend/completions.py
@@ -65,6 +65,7 @@
     'push ',
     'pull ',
     'log ',
+    'list-contents ',
 ]
 
 WORKSPACE_COMMANDS = [