Repository: canonical/charmcraft

Issue:
approach for using store commands programmatically 
Hello,

We are trying to create some release infrastructure to help us automate releasing charms to the store. Is anyone currently using store commands  programmatically? It seems we currently need to auth against the ubuntu one from the browser. Is there a way to do this without a human?

Thanks

Discussion:

Patch:
diff --git a/charmcraft/commands/store/__init__.py b/charmcraft/commands/store/__init__.py
index 9102dcc5..bb4e7849 100644
--- a/charmcraft/commands/store/__init__.py
+++ b/charmcraft/commands/store/__init__.py
@@ -1650,12 +1650,27 @@ class ListResourcesCommand(BaseCommand):
 
     def fill_parser(self, parser):
         """Add own parameters to the general parser."""
+        self.include_format_option(parser)
         parser.add_argument("charm_name", metavar="charm-name", help="The name of the charm")
 
     def run(self, parsed_args):
         """Run the command."""
         store = Store(self.config.charmhub)
         result = store.list_resources(parsed_args.charm_name)
+
+        if parsed_args.format:
+            info = [
+                {
+                    "charm_revision": item.revision,
+                    "name": item.name,
+                    "type": item.resource_type,
+                    "optional": item.optional,
+                }
+                for item in result
+            ]
+            emit.message(self.format_content(parsed_args.format, info))
+            return
+
         if not result:
             emit.message(f"No resources associated to {parsed_args.charm_name}.")
             return
@@ -1701,6 +1716,7 @@ class UploadResourceCommand(BaseCommand):
 
     def fill_parser(self, parser):
         """Add own parameters to the general parser."""
+        self.include_format_option(parser)
         parser.add_argument(
             "charm_name",
             metavar="charm-name",
@@ -1751,24 +1767,22 @@ class UploadResourceCommand(BaseCommand):
 
             # check if the specific image is already in Canonical's registry
             already_uploaded = ih.check_in_registry(image_digest)
+            # XXX Facundo 2022-06-13: converting the following four messages to progress ones
+            # so they don't interfere with the programmatic output; we need to add the
+            # permanent=True flag once is available from Craft CLI
             if already_uploaded:
-                emit.message("Using OCI image from Canonical's registry.", intermediate=True)
+                emit.progress("Using OCI image from Canonical's registry.")
             else:
                 # upload it from local registry
-                emit.message(
-                    "Remote image not found, uploading from local registry.", intermediate=True
-                )
+                emit.progress("Remote image not found, uploading from local registry.")
                 image_digest = ih.upload_from_local(image_digest)
                 if image_digest is None:
-                    emit.message(
+                    emit.progress(
                         f"Image with digest {parsed_args.image} is not available in "
                         "the Canonical's registry nor locally.",
-                        intermediate=True,
                     )
                     return
-                emit.message(
-                    f"Image uploaded, new remote digest: {image_digest}.", intermediate=True
-                )
+                emit.progress(f"Image uploaded, new remote digest: {image_digest}.")
 
             # all is green, get the blob to upload to Charmhub
             content = store.get_oci_image_blob(
@@ -1793,15 +1807,27 @@ class UploadResourceCommand(BaseCommand):
             resource_filepath.unlink()
 
         if result.ok:
-            emit.message(
-                f"Revision {result.revision} created of "
-                f"resource {parsed_args.resource_name!r} for charm {parsed_args.charm_name!r}.",
-            )
+            if parsed_args.format:
+                info = {"revision": result.revision}
+                emit.message(self.format_content(parsed_args.format, info))
+            else:
+                emit.message(
+                    f"Revision {result.revision} created of resource "
+                    f"{parsed_args.resource_name!r} for charm {parsed_args.charm_name!r}.",
+                )
             retcode = 0
         else:
-            emit.message(f"Upload failed with status {result.status!r}:")
-            for error in result.errors:
-                emit.message(f"- {error.code}: {error.message}")
+            if parsed_args.format:
+                info = {
+                    "errors": [
+                        {"code": error.code, "message": error.message} for error in result.errors
+                    ]
+                }
+                emit.message(self.format_content(parsed_args.format, info))
+            else:
+                emit.message(f"Upload failed with status {result.status!r}:")
+                for error in result.errors:
+                    emit.message(f"- {error.code}: {error.message}")
             retcode = 1
         return retcode
 
@@ -1827,6 +1853,7 @@ class ListResourceRevisionsCommand(BaseCommand):
 
     def fill_parser(self, parser):
         """Add own parameters to the general parser."""
+        self.include_format_option(parser)
         parser.add_argument(
             "charm_name",
             metavar="charm-name",
@@ -1838,6 +1865,19 @@ class ListResourceRevisionsCommand(BaseCommand):
         """Run the command."""
         store = Store(self.config.charmhub)
         result = store.list_resource_revisions(parsed_args.charm_name, parsed_args.resource_name)
+
+        if parsed_args.format:
+            info = [
+                {
+                    "revision": item.revision,
+                    "created at": format_timestamp(item.created_at),
+                    "size": item.size,
+                }
+                for item in result
+            ]
+            emit.message(self.format_content(parsed_args.format, info))
+            return
+
         if not result:
             emit.message("No revisions found.")
             return


Test patch:
diff --git a/tests/commands/test_store_commands.py b/tests/commands/test_store_commands.py
index 3a9e8cbb..f44db471 100644
--- a/tests/commands/test_store_commands.py
+++ b/tests/commands/test_store_commands.py
@@ -4180,60 +4180,112 @@ def test_listlib_properly_sorted(emitter, store_mock, config, formatted):
 # -- tests for list resources command
 
 
-def test_resources_simple(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_resources_simple(emitter, store_mock, config, formatted):
     """Happy path of one result from the Store."""
     store_response = [
         Resource(name="testresource", optional=True, revision=1, resource_type="file"),
     ]
     store_mock.list_resources.return_value = store_response
 
-    args = Namespace(charm_name="testcharm")
+    args = Namespace(charm_name="testcharm", format=formatted)
     ListResourcesCommand(config).run(args)
 
     assert store_mock.mock_calls == [
         call.list_resources("testcharm"),
     ]
-    expected = [
-        "Charm Rev    Resource      Type    Optional",
-        "1            testresource  file    True",
-    ]
-    emitter.assert_messages(expected)
+    if formatted:
+        expected = [
+            {
+                "charm_revision": 1,
+                "name": "testresource",
+                "type": "file",
+                "optional": True,
+            }
+        ]
+        emitter.assert_json_output(expected)
+    else:
+        expected = [
+            "Charm Rev    Resource      Type    Optional",
+            "1            testresource  file    True",
+        ]
+        emitter.assert_messages(expected)
 
 
-def test_resources_empty(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_resources_empty(emitter, store_mock, config, formatted):
     """No results from the store."""
     store_response = []
     store_mock.list_resources.return_value = store_response
 
-    args = Namespace(charm_name="testcharm")
+    args = Namespace(charm_name="testcharm", format=formatted)
     ListResourcesCommand(config).run(args)
 
-    emitter.assert_message("No resources associated to testcharm.")
+    if formatted:
+        emitter.assert_json_output([])
+    else:
+        emitter.assert_message("No resources associated to testcharm.")
 
 
-def test_resources_ordered_and_grouped(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_resources_ordered_and_grouped(emitter, store_mock, config, formatted):
     """Results are presented ordered by name in the table."""
     store_response = [
         Resource(name="bbb-resource", optional=True, revision=2, resource_type="file"),
         Resource(name="ccc-resource", optional=True, revision=1, resource_type="file"),
-        Resource(name="bbb-resource", optional=True, revision=3, resource_type="file"),
-        Resource(name="aaa-resource", optional=True, revision=2, resource_type="file"),
+        Resource(name="bbb-resource", optional=False, revision=3, resource_type="file"),
+        Resource(name="aaa-resource", optional=True, revision=2, resource_type="oci-image"),
         Resource(name="bbb-resource", optional=True, revision=5, resource_type="file"),
     ]
     store_mock.list_resources.return_value = store_response
 
-    args = Namespace(charm_name="testcharm")
+    args = Namespace(charm_name="testcharm", format=formatted)
     ListResourcesCommand(config).run(args)
 
-    expected = [
-        "Charm Rev    Resource      Type    Optional",
-        "5            bbb-resource  file    True",
-        "3            bbb-resource  file    True",
-        "2            aaa-resource  file    True",
-        "             bbb-resource  file    True",
-        "1            ccc-resource  file    True",
-    ]
-    emitter.assert_messages(expected)
+    if formatted:
+        expected = [
+            {
+                "charm_revision": 2,
+                "name": "bbb-resource",
+                "type": "file",
+                "optional": True,
+            },
+            {
+                "charm_revision": 1,
+                "name": "ccc-resource",
+                "type": "file",
+                "optional": True,
+            },
+            {
+                "charm_revision": 3,
+                "name": "bbb-resource",
+                "type": "file",
+                "optional": False,
+            },
+            {
+                "charm_revision": 2,
+                "name": "aaa-resource",
+                "type": "oci-image",
+                "optional": True,
+            },
+            {
+                "charm_revision": 5,
+                "name": "bbb-resource",
+                "type": "file",
+                "optional": True,
+            },
+        ]
+        emitter.assert_json_output(expected)
+    else:
+        expected = [
+            "Charm Rev    Resource      Type       Optional",
+            "5            bbb-resource  file       True",
+            "3            bbb-resource  file       False",
+            "2            aaa-resource  oci-image  True",
+            "             bbb-resource  file       True",
+            "1            ccc-resource  file       True",
+        ]
+        emitter.assert_messages(expected)
 
 
 # -- tests for upload resources command
@@ -4302,7 +4354,8 @@ def test_uploadresource_options_bad_combinations(config, sysargs, tmp_path, monk
         cmd.parsed_args_post_verification(parser, parsed_args)
 
 
-def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path, formatted):
     """Simple upload, success result."""
     store_response = Uploaded(ok=True, status=200, revision=7, errors=[])
     store_mock.upload_resource.return_value = store_response
@@ -4314,6 +4367,7 @@ def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path):
         resource_name="myresource",
         filepath=test_resource,
         image=None,
+        format=formatted,
     )
     retcode = UploadResourceCommand(config).run(args)
     assert retcode == 0
@@ -4321,17 +4375,24 @@ def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path):
     assert store_mock.mock_calls == [
         call.upload_resource("mycharm", "myresource", "file", test_resource)
     ]
-    emitter.assert_interactions(
-        [
-            call("progress", f"Uploading resource directly from file {str(test_resource)!r}."),
-            call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."),
-        ]
-    )
+    if formatted:
+        expected = {"revision": 7}
+        emitter.assert_json_output(expected)
+    else:
+        emitter.assert_interactions(
+            [
+                call("progress", f"Uploading resource directly from file {str(test_resource)!r}."),
+                call(
+                    "message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."
+                ),
+            ]
+        )
     assert test_resource.exists()  # provided by the user, don't touch it
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
-def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config, formatted):
     """Upload an oci-image resource, the image itself already being in the registry."""
     # fake credentials for the charm/resource, and the final json content
     store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
@@ -4368,6 +4429,7 @@ def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config)
         resource_name="myresource",
         filepath=None,
         image=original_image_digest,
+        format=formatted,
     )
     with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
         with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock:
@@ -4402,17 +4464,23 @@ def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config)
         call.upload_resource("mycharm", "myresource", "oci-image", uploaded_resource_filepath),
     ]
 
-    emitter.assert_interactions(
-        [
-            call(
-                "progress",
-                "Uploading resource from image "
-                "charm/charm-id/test-image-name @ test-digest-given-by-user.",
-            ),
-            call("message", "Using OCI image from Canonical's registry.", intermediate=True),
-            call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."),
-        ]
-    )
+    if formatted:
+        expected = {"revision": 7}
+        emitter.assert_json_output(expected)
+    else:
+        emitter.assert_interactions(
+            [
+                call(
+                    "progress",
+                    "Uploading resource from image "
+                    "charm/charm-id/test-image-name @ test-digest-given-by-user.",
+                ),
+                call("progress", "Using OCI image from Canonical's registry."),
+                call(
+                    "message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."
+                ),
+            ]
+        )
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
@@ -4437,6 +4505,7 @@ def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config
         resource_name="myresource",
         filepath=None,
         image=original_image_digest,
+        format=False,
     )
     with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
         with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock:
@@ -4471,14 +4540,12 @@ def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config
                 "charm/charm-id/test-image-name @ test-digest-given-by-user.",
             ),
             call(
-                "message",
+                "progress",
                 "Remote image not found, uploading from local registry.",
-                intermediate=True,
             ),
             call(
-                "message",
+                "progress",
                 "Image uploaded, new remote digest: new-digest-after-upload.",
-                intermediate=True,
             ),
             call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."),
         ]
@@ -4501,6 +4568,7 @@ def test_uploadresource_image_call_missing_everywhere(emitter, store_mock, confi
         resource_name="myresource",
         filepath=None,
         image=original_image_digest,
+        format=False,
     )
     with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
         with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock:
@@ -4532,21 +4600,20 @@ def test_uploadresource_image_call_missing_everywhere(emitter, store_mock, confi
                 "image charm/charm-id/test-image-name @ test-digest-given-by-user.",
             ),
             call(
-                "message",
+                "progress",
                 "Remote image not found, uploading from local registry.",
-                intermediate=True,
             ),
             call(
-                "message",
+                "progress",
                 "Image with digest test-digest-given-by-user is not available in "
                 "the Canonical's registry nor locally.",
-                intermediate=True,
             ),
         ]
     )
 
 
-def test_uploadresource_call_error(emitter, store_mock, config, tmp_path):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_uploadresource_call_error(emitter, store_mock, config, tmp_path, formatted):
     """Simple upload but with a response indicating an error."""
     errors = [
         Error(message="text 1", code="missing-stuff"),
@@ -4557,54 +4624,81 @@ def test_uploadresource_call_error(emitter, store_mock, config, tmp_path):
 
     test_resource = tmp_path / "mystuff.bin"
     test_resource.write_text("sample stuff")
-    args = Namespace(charm_name="mycharm", resource_name="myresource", filepath=test_resource)
+    args = Namespace(
+        charm_name="mycharm", resource_name="myresource", filepath=test_resource, format=formatted
+    )
     retcode = UploadResourceCommand(config).run(args)
     assert retcode == 1
 
-    emitter.assert_messages(
-        [
-            "Upload failed with status 400:",
-            "- missing-stuff: text 1",
-            "- broken: other long error text",
-        ]
-    )
+    if formatted:
+        expected = {
+            "errors": [
+                {"code": "missing-stuff", "message": "text 1"},
+                {"code": "broken", "message": "other long error text"},
+            ]
+        }
+        emitter.assert_json_output(expected)
+    else:
+        emitter.assert_messages(
+            [
+                "Upload failed with status 400:",
+                "- missing-stuff: text 1",
+                "- broken: other long error text",
+            ]
+        )
 
 
 # -- tests for list resource revisions command
 
 
-def test_resourcerevisions_simple(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_resourcerevisions_simple(emitter, store_mock, config, formatted):
     """Happy path of one result from the Store."""
     store_response = [
         ResourceRevision(revision=1, size=50, created_at=datetime.datetime(2020, 7, 3, 2, 30, 40)),
     ]
     store_mock.list_resource_revisions.return_value = store_response
 
-    args = Namespace(charm_name="testcharm", resource_name="testresource")
+    args = Namespace(charm_name="testcharm", resource_name="testresource", format=formatted)
     ListResourceRevisionsCommand(config).run(args)
 
     assert store_mock.mock_calls == [
         call.list_resource_revisions("testcharm", "testresource"),
     ]
-    expected = [
-        "Revision    Created at              Size",
-        "1           2020-07-03T02:30:40Z     50B",
-    ]
-    emitter.assert_messages(expected)
+    if formatted:
+        expected = [
+            {
+                "revision": 1,
+                "created at": "2020-07-03T02:30:40Z",
+                "size": 50,
+            },
+        ]
+        emitter.assert_json_output(expected)
+    else:
+        expected = [
+            "Revision    Created at              Size",
+            "1           2020-07-03T02:30:40Z     50B",
+        ]
+        emitter.assert_messages(expected)
 
 
-def test_resourcerevisions_empty(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_resourcerevisions_empty(emitter, store_mock, config, formatted):
     """No results from the store."""
     store_response = []
     store_mock.list_resource_revisions.return_value = store_response
 
-    args = Namespace(charm_name="testcharm", resource_name="testresource")
+    args = Namespace(charm_name="testcharm", resource_name="testresource", format=formatted)
     ListResourceRevisionsCommand(config).run(args)
 
-    emitter.assert_message("No revisions found.")
+    if formatted:
+        emitter.assert_json_output([])
+    else:
+        emitter.assert_message("No revisions found.")
 
 
-def test_resourcerevisions_ordered_by_revision(emitter, store_mock, config):
+@pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
+def test_resourcerevisions_ordered_by_revision(emitter, store_mock, config, formatted):
     """Results are presented ordered by revision in the table."""
     # three Revisions with all values weirdly similar, the only difference is revision, so
     # we really assert later that it was used for ordering
@@ -4617,14 +4711,39 @@ def test_resourcerevisions_ordered_by_revision(emitter, store_mock, config):
     ]
     store_mock.list_resource_revisions.return_value = store_response
 
-    args = Namespace(charm_name="testcharm", resource_name="testresource")
+    args = Namespace(charm_name="testcharm", resource_name="testresource", format=formatted)
     ListResourceRevisionsCommand(config).run(args)
 
-    expected = [
-        "Revision    Created at              Size",
-        "4           2020-07-03T20:30:40Z  856.0K",
-        "3           2020-07-03T20:30:40Z   32.9M",
-        "2           2020-07-03T20:30:40Z     50B",
-        "1           2020-07-03T20:30:40Z    4.9K",
-    ]
-    emitter.assert_messages(expected)
+    if formatted:
+        expected = [
+            {
+                "revision": 1,
+                "created at": "2020-07-03T20:30:40Z",
+                "size": 5000,
+            },
+            {
+                "revision": 3,
+                "created at": "2020-07-03T20:30:40Z",
+                "size": 34450520,
+            },
+            {
+                "revision": 4,
+                "created at": "2020-07-03T20:30:40Z",
+                "size": 876543,
+            },
+            {
+                "revision": 2,
+                "created at": "2020-07-03T20:30:40Z",
+                "size": 50,
+            },
+        ]
+        emitter.assert_json_output(expected)
+    else:
+        expected = [
+            "Revision    Created at              Size",
+            "4           2020-07-03T20:30:40Z  856.0K",
+            "3           2020-07-03T20:30:40Z   32.9M",
+            "2           2020-07-03T20:30:40Z     50B",
+            "1           2020-07-03T20:30:40Z    4.9K",
+        ]
+        emitter.assert_messages(expected)